Covers RFD-BILL-003 W2 / T2b (D6 + D26): billing.grant_code (campaign
config), billing.grant_redemption (evidence), and
POST /provision/redeem-grant, which turns a ?grant= code
carried through signup into page credit as a campaign_grant ledger event.
Staff CRUD over campaigns is Story-2722 (pramaan-admin); the /signup
surface that carries the code is pramaan-web.
That is not a rule the service enforces — it is a rule the ledger enforces. Migration 0196 (Story-2714) already froze the shape:
grant_type IS DISTINCT FROM 'campaign_grant' OR (
allowance_bucket = 'offline_purchase'
AND amount_micros = 0 <-- the money is structurally absent
AND grant_reason IS NOT NULL
AND external_reference IS NOT NULL <-- the redeemed code
AND valid_until IS NOT NULL
AND granted_by_staff_user_id IS NULL) <-- machine-minted, not staff-granted
So SB-20's proof does not go through our code at all. The test writes a
money-carrying campaign_grant directly to the ledger,
bypassing the service entirely, and asserts the database refuses it. A check the
service performs is only as good as every future caller remembering it.
D6/SB-11 says an unknown code must never block signup. The cheapest way to
guarantee that is structural: redemption is not a field on
POST /provision/self-serve. It is its own call, made after the tenant
already exists.
pramaan-web: create Clerk org
-> POST /provision/self-serve (Story-2711: the workspace exists now)
-> POST /provision/redeem-grant (Story-2715: pages, or a reason)
By the time a code can fail, the thing a customer cares about has already succeeded. Every refusal is therefore a 200 with an outcome, never an exception:
| Outcome | Meaning | Scenario |
|---|---|---|
credited | Pages granted. The only outcome that grants anything. | SB-03 |
grant_unrecognized | No such code. Workspace untouched. | SB-11 |
grant_exhausted | Redemption limit reached. | SB-12, SB-39 |
grant_expired | Campaign's own validity date passed. | SB-12 |
already_redeemed | This person already used it. | SB-38 |
grant_not_yours | Email-bound to a different recipient. | D26 |
grant_binding_required | High-value code configured as bearer — refuses rather than leaks. | D26 |
grant_withdrawn / grant_not_yet_valid | Campaign off, or not started. | — |
throttled | Too many attempts on this IP or account. | D26 |
Every one of them logs a structured outcome. A silent skip is a design defect (COVERAGE-GATE G5c). Only two failures are not outcomes, because they are not the customer's business: a session that cannot prove it owns the org it names (403) and an org with no tenant behind it (404).
D26 makes redemption unique per Clerk user, not per tenant. The key
is identity.users.id, which is 1:1 with the Clerk user:
UNIQUE (grant_code_id, redeemed_by_user_id) -- uq_grant_redemption_code_user
SelfServeProvisionDbDep), where the cap and the uniqueness are
evaluated across every tenant. The firm-scoped write is only the ledger credit.
The pre-check exists to give a named reason. The guarantee is the unique
constraint. Proven by deleting the pre-check and re-running: the redemption then
dies on duplicate key value violates unique constraint
"uq_grant_redemption_code_user".
SB-39 is two signups redeeming the last remaining slot at the same instant. The naive shape — count, compare, insert — lets both transactions read "one remaining" and both win. The fix is one clause:
SELECT ... FROM billing.grant_code WHERE code = :code FOR UPDATE
Taken before anything is counted, so concurrent redemptions serialize on
the code row: the second transaction's count sees the first's committed redemption
and refuses with grant_exhausted.
FOR UPDATE
removed, the SB-39 test fails with AssertionError: ['credited',
'credited'] — the cap exceeded, exactly as predicted. The test drives two
genuinely concurrent transactions on two connections, with distinct users
and distinct IPs so neither serializes on the throttle counter first. A
sequential version of that test passes with the lock removed, so it would prove
nothing.
These are easy to conflate and mean different things:
| Clock | Where | What it bounds |
|---|---|---|
| Campaign expiry | grant_code.expires_at | How long the code may still be redeemed (SB-12). |
| Page validity | ledger valid_until | 90 days from redemption — how long the granted pages live (founder ruling). |
A code redeemed on its last valid day still grants pages that live a full 90 days. Contrast the money rail, where purchased pages live twelve months (D4): a bought page is an asset, a granted one is a promotion.
AGENT4's objection: a grant code is a bearer token in a URL — forwarded in a bootcamp WhatsApp group, scraped from browser history, a Referer header, or Vercel logs. A cap-100 campaign drains in an afternoon and the intended attendees hit SB-12's refusal.
Low-value codes stay bearer, which is the honest trade for a frictionless signup
link. Above GRANT_CODE_EMAIL_BINDING_PAGE_THRESHOLD (default 1000
pages) a code must carry issued_to_email; one that
does not is refused with grant_binding_required rather than quietly
handing out the pages. A misconfigured high-value campaign fails closed.
The cap and the per-user uniqueness already make a successful over-redeem
impossible. What they do not stop is enumeration — guessing codes — and
that is what billing.grant_redemption_attempt counts, on two axes (IP
and account), in one atomic tumbling-window upsert.
grant_redemption_velocity. That is D26's campaign velocity
metric (restating the 2561 D11 tripwire): a drain campaign is visible in the logs
from the first burst, not after the cap is gone. DB-backed because there is no
Redis in this runtime — the same reason the agent perimeter's bucket is.
There is no firm_id in the request body. The caller names a
clerk_org_id, and it is refused unless the caller's own verified
session token carries that same org claim — the D19/SB-40 shape Story-2711
established. The ledger credit then drops into that firm's own RLS scope:
enter_firm_scope(session, firm_id) # is_staff -> false, current_firm_id set record_allowance_grant(... grant_type="campaign_grant" ...) enter_staff_scope(session) # back, for the redemption link-up
is_staff goes false deliberately. Left true, the ledger's
staff_grant_append_policy would OR-permit an insert for any
firm, and crediting the wrong tenant would be prevented only by our control flow
being right. In firm scope the database is the thing that refuses.
src/domains/payment/service.py, as _set_firm_scope /
_set_staff_scope. Copying it into a second money rail would have made
two private copies of one security-relevant primitive: precisely the "two writers
for one resource" shape that made the omission invisible in the incident behind
AGENTS.md R6.
So it moved to src/shared/db/rls/scope.py, and the payment rail's two
private copies were deleted in this PR (R1), along with updating
the one test that imported them. Both helpers now route through
apply_rls_context, making each switch one round-trip with bound
parameters instead of two (ADR-130 DR-4) — a small correctness bonus on top.
| Reused | Instead of |
|---|---|
campaign_grant grant type + its CHECK (migration 0196) | A new grant type or a second credit mechanism |
record_allowance_grant | A bespoke ledger insert for grants |
SelfServeProvisionDbDep | A fourth pre-tenant session dep |
business_key idempotency (keyed code + user) | A grant-specific dedupe table |
emit_legacy with a firm override | An unaudited allowlist entry |
The redemption emits firm.grant_code_redeemed because it moves a
tenant's page balance. A refused redemption writes no audit row — it
changed nothing — and its structured outcome log is the trail.
?grant= through the
flow and rendering the outcome is pramaan-web.offline_purchase bucket
either way.