Story 2711: Self-serve provisioning API

Covers the pramaan-functions half of RFD-BILL-003 W1: POST /provision/self-serve, the individual/firm org-kind model, firm invites, the D25 region attestation, and the D28 payment-status lifecycle. The /signup surface itself is pramaan-web (#2712); Razorpay money is W2/W3 (#2714, #2719).

The decision that shapes everything: payment is a status, not a gate

The founder amendment of 2026-07-21 (Q-A v2 / D28) supersedes the earlier "workspace opens on captured payment" ruling. Accounts and firms are always created and login is never blocked. A firm that skipped payment sits at payment_status='pending_payment': it can log in, browse, and invite — it just cannot start billable intake, so it costs us nothing.

The lifecycle lives on business.firms.payment_status:

StatusWhat the workspace can do
pending_paymentLogin, browse, invite. No intake.
trialTeam-granted 14/30 days. Full use.
activePaid, mandate live. Full use.
past_dueRenewal failed, inside the 14-day grace. Full use.
pausedGrace ended or overage ceiling. Read and export only.
archivedExport only.

1. Where the payment status is actually enforced

D28 says an unpaid workspace incurs zero vendor cost. The INTAKE ledger charge is emitted after extraction succeeds — far too late, since by then the OCR and model bills are already paid. So the gate sits at upload admission, in DocumentService.presign_upload:

firm_id = await cls._current_firm_id(session)
user_id = await cls._current_user_id(session)
await assert_firm_may_intake(session, firm_id=firm_id)   # <- no URL, no upload, no cost

src/shared/billing/intake_admission.py is the single chokepoint, so a future upload caller cannot forget it. trial, active, and past_due pass — cutting a lawyer off mid-matter over a failed mandate is a worse failure than the arrears (D15). Reading the record never locks.

2. Authorization is proof of ownership, not a permission (D19 / SB-40)

Lambda has no egress to api.clerk.com (ADR-167 no-NAT), so this API cannot ask Clerk who owns an organization. It does not need to: the caller's Clerk session token already carries an org_id claim that API Gateway verified (ADR-035).

if current_user.clerk_org_id != clerk_org_id:
    raise PramaanForbiddenError(code="pramaan.provision.org_ownership_unproven")

A request that merely names someone else's org id gets nothing, and the mismatch is logged so a probing campaign is visible rather than silent. There is no permission that grants "may provision" — that is deliberate.

3. Idempotency lives in the database, not in a read (SB-41)

Migration 0195 adds two partial unique indexes to business.firms:

UNIQUE (created_by_user_id)     WHERE org_kind = 'individual'
UNIQUE (provision_attempt_id)   WHERE provision_attempt_id IS NOT NULL

A replayed attempt_id — or a retry against an org already mirrored — returns the tenant that exists, with alreadyProvisioned: true, rather than erroring on what the user experienced as one click. A concurrent double submit cannot mint two workspaces because the index, not a read-then-write, is the enforcement.

4. The seat floor is config, not a constant (ADR-151 / SB-14)

billing.plan already encodes the D3 rate card: business is min_seats=3 / seat_price=25000, startup is min_seats=1. Org kind maps onto those rows, and the minimum is read from billing.plan.min_seats at provisioning time. The DTO validates only the structural shape, so "three seats" is stated in exactly one place.

5. Region is a recorded attestation, and it is immutable (D25 / SB-47)

region, region_attested_at, and region_attested_source land together. Immutability is a database trigger rather than a rule every future writer has to remember:

CREATE TRIGGER firms_block_immutable_claims
  BEFORE UPDATE ON business.firms
  FOR EACH ROW EXECUTE FUNCTION business.tg_firms_block_immutable_claims()
Partial by design. SB-47's other half — comparing the claim against the payment instrument's country — needs the payment rail, which is #2719. What ships here is the attestation record and the guarantee that the claim survives as attested, which is exactly what D25 says staff review acts on.

6. Invites: PRAMAAN owns the contract, Clerk owns delivery

Clerk still sends the invitation and still owns accounts; identity.firm_memberships still records only accepted memberships. What Clerk cannot express is the D19 contract — single-use, expiring, firm-scoped, with auditable refusal reasons. So business.firm_invites is the authority and the Clerk invitation is the transport.

The raw token is returned once at mint time and never stored; only its SHA-256 hash is. A database read cannot be replayed into a working invite link.

Why redemption is a SECURITY DEFINER function

At the moment someone accepts, they have no membership in the inviting firm — that is the entire point of an invite — so neither firm_isolation_policy nor the staff grant policy can see their row. Widening either would expose every firm's invitee emails to any signed-in caller. Instead, redemption goes through one narrow function reachable only by presenting the token hash:

business.redeem_firm_invite(p_token_hash text, p_user_id uuid)
  -> (outcome, firm_id, invite_id, role)

Single-use is the UPDATE's own WHERE accepted_at IS NULL: two concurrent accepts contend on the row and exactly one wins. The loser gets already_used.

OutcomeMeaning
acceptedJoined. Audit row written in the same transaction.
expired / already_used / revokedSB-25 refusals. HTTP 200 with a plain-language next step — an expired link is an answer, not a server error.
already_memberG5c structured outcome; the invite stays open for whoever it was meant for.
unknownNo such token.

SB-26: accepting never merges tenants

Joining a firm adds a second tenant. Nothing moves between workspaces, because PRAMAAN has no cross-tenant move path at all — an invitee who already has their own workspace keeps it and its matters untouched. The acceptance test asserts exactly that.

7. The pre-tenant session, and why it has its own name

business.firms accepts writes only under staff_write_policy (app.is_staff = true, migration 0016), and self-serve provisioning writes the firm row before any firm scope can exist. SelfServeProvisionDbDep opens that bounded bracket. It is deliberately not a reuse of AppMeDbDep: its privilege is strictly larger because it writes, and an authority that wide should be greppable. What bounds it is the ownership check that runs before any write.

8. Reused, not rebuilt

A firm without its default catalogs lands its owner in a workspace where no matter can be created. Rather than duplicate that seeding, the staff-led path's block was lifted into FirmService.seed_firm_defaults and both doors call it — so a new door cannot forget one.

Verification. 13 pytest-bdd scenarios in tests/features/self_serve_provisioning.feature cover SB-01, SB-02 (API side), SB-06, SB-14, SB-25 (expired and revoked), SB-26, SB-40, SB-41 (both the second-workspace refusal and the idempotent replay), SB-47, plus the two D28 intake legs. business.firm_invites is registered in _SUPPORT_CONFIDENTIAL_TABLES, so the RLS census proves support reaches invitee emails only through a Tier-2 grant. Full suite 3503 passed; Alembic upgrade/downgrade/upgrade/check round-trips clean.