Story 2715: Customer-redeemable grant codes

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.

The decision that shapes everything: a grant is pages, and nothing else

A grant code is page credit only — never refundable, never transferable, never a cash value (D6 / SB-20). The AGENT3 panel specifically struck the "₹5,000 page credit" framing from the mockups: showing a rupee value on a credit that SB-20 must then refuse manufactures the exact expectation it refuses, and it publishes what a partner deal is worth.

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.

1. Redemption is a separate call, and that is what makes SB-11 true

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:

OutcomeMeaningScenario
creditedPages granted. The only outcome that grants anything.SB-03
grant_unrecognizedNo such code. Workspace untouched.SB-11
grant_exhaustedRedemption limit reached.SB-12, SB-39
grant_expiredCampaign's own validity date passed.SB-12
already_redeemedThis person already used it.SB-38
grant_not_yoursEmail-bound to a different recipient.D26
grant_binding_requiredHigh-value code configured as bearer — refuses rather than leaks.D26
grant_withdrawn / grant_not_yet_validCampaign off, or not started.—
throttledToo 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).

2. Uniqueness is per PERSON, and that is why it runs in staff scope

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
The subtle part: SB-38 is "the same person redeems from a second workspace". In firm scope, the prior redemption lives in another tenant and is invisible — the pre-check would read zero rows and happily grant again. So redemption runs in the pre-tenant staff bracket (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".

3. The cap under concurrency: one lock, taken before the count

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.

Observed red before it was believed. With 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.

4. Two different clocks

These are easy to conflate and mean different things:

ClockWhereWhat it bounds
Campaign expirygrant_code.expires_atHow long the code may still be redeemed (SB-12).
Page validityledger valid_until90 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.

5. Bearer by default, email-bound above the line (D26)

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.

6. The throttle, and why it counts attempts rather than successes

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.

The same statement returns the window count, which is emitted on every attempt as 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.

7. The firm comes from an org the caller can prove they own

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.

8. Deleted, not duplicated

That scope switch already existed — privately, inside 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.

9. Reused, not rebuilt

ReusedInstead of
campaign_grant grant type + its CHECK (migration 0196)A new grant type or a second credit mechanism
record_allowance_grantA bespoke ledger insert for grants
SelfServeProvisionDbDepA fourth pre-tenant session dep
business_key idempotency (keyed code + user)A grant-specific dedupe table
emit_legacy with a firm overrideAn 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.

What is deliberately not here