Story 2723: Billing authority, and the seat / admin lifecycle refusals

The money rail exists. Story-2711 created the tenant, Story-2714 built the payment substrate, Story-2715 added grant codes, Story-2719 shipped the firm's month-one checkout and its e-mandate. Every one of those routes was gated with settings:write. This Story asks who is allowed to touch any of it - and closes the seat and admin-handover rules that make the answer stable over time. Out of scope and untouched: the renewal job, statements, and ledger emission internals (Story-2721, running in parallel).

Why settings:write was the wrong gate

RFD-BILL-003 D13 is one sentence: only Firm Admin manages billing. The permission matrix could not say it. settings:write is held by firm_admin, firm_editor, AND support - because admin and editor were deliberately identical in V1 (src/shared/authz/AGENTS.md), and app-support needs configuration access under ADR-098. Gating the firm's bank mandate on it meant every editor in the firm could register one, and an on-duty support session could read the firm's invoices.

So money becomes the first thing the two roles disagree about. Migration 0199 adds billing:manage and grants it to firm_admin alone.

1. Where things live

ConcernFile
billing:manage grant + the SECURITY DEFINER seat-sync fixalembic/versions/d1f4b7c9a3e5_0199_billing_manage_permission.py (new)
Runtime matrix mirrorsrc/shared/authz/permissions.py
The gate, and the audited refusalsrc/shared/billing/authority.py (new)
Seat accounting + the SB-28 floorsrc/shared/billing/seats.py (new)
Concurrent-login visibility (SB-53)src/shared/billing/session_visibility.py (new)
Firm-Admin seat control (GET/PATCH /app/firm/seats)src/app_api/routes/firm.py, src/domains/firm_subscription/dto.py
Gate applied to checkout, mandate, seats page, invoicessrc/app_api/routes/firm_checkout.py, src/app_api/seats_and_billing/router.py, src/app_api/router_billing_usage.py
SB-27 invite refusalsrc/domains/firm_invite/service.py
SB-29 handover rulesrc/app_api/firm_service.py
SB-53 sighting stamp + flag callsrc/domains/document/service.py, src/app_api/router_matters.py, src/shared/auth/clerk.py
Executable acceptancetests/features/billing_authority_seats.feature, tests/steps/test_billing_authority_seats.py (new)

2. The gate is a dependency, not a rule copied per route

require_billing_authority could have been require_permission("billing:manage") and it would have refused correctly. It is not, for one reason: SB-19 requires the refusal to be recorded, and a raised 403 rolls its own transaction back. A Firm Admin asking "who tried to change our seats?" is asking their audit log, not our CloudWatch.

So the dep writes one committed firm_billing.access_denied row in its own transaction, then re-raises - the same shape the staff unit-economics gate has used since Story-2543 (CL-12). The membership lookup runs under a transient staff GUC and the GUCs then flip to the refused caller's own firm context, so the row is stamped actor_kind='firm_user' and the audit_log insert policy holds honestly. An org-less caller named no tenant (Bug-2568), so there is no firm trail to write to and the structured log is the only record - writing into a firm the caller never selected would be a write side effect under no org.

The dep still stamps __pramaan_required_permissions__, so openapi/permission-map.json reports these routes as needing billing:manage. A gate that authorizes honestly but publishes nothing is a gate the Page Card catalog cannot see.

What is now Firm-Admin-only

SurfaceWasNow
POST /app/firm/checkout/month1settings:writebilling:manage
POST /app/firm/checkout/orders/{id}/provider-ordersettings:writebilling:manage
POST /app/firm/checkout/mandatesettings:writebilling:manage
POST /app/firm/checkout/mandate/{id}/provider-tokensettings:writebilling:manage
GET /app/firm/checkout/mandate, GET /app/firm/checkout/stateungated inside firm RLSbilling:manage
GET /app/firm/seats-and-billingungated inside firm RLSbilling:manage
GET /app/billing/usage/{current-period,receipts,breakdown,grants}any active firm memberbilling:manage
GET / PATCH /app/firm/seatsdid not existbilling:manage

The reads move too, and that is D15, not caution: Members never see amounts. A mandate ceiling, a seat price, and a receipt total are amounts. The two 410 Gone credit stubs in seats_and_billing/router.py stay ungated - a deprecation shape is not data, and gating a tombstone only changes which error a stale client sees.

One honest gap. SB-19's wording is "change seats, buy pages, or view invoices". The page-pack buy surface is T2c (pramaan-spec#2716, pramaan-app) and has no route in this repo yet. The acceptance scenario therefore exercises the money route that DOES exist here - the month-one checkout. Because the gate is a dependency and not a per-route copy of the rule, the pack route inherits it the day it lands; but nothing in this PR proves that, and it should not be read as proven.

3. Seats: a seat is committed when the invite is sent

load_seat_state returns three numbers in one round-trip: the seat limit, the active members, and the open invites. Counting only members would let a Firm Admin mint ten invites into three seats and discover the problem when the tenth colleague clicks their link - a refusal aimed at the wrong person, after the seat was promised. So SB-27 refuses at minting time, names both counts, and carries remedy: "increase_seats" so the UI can offer the way out instead of a dead end.

SB-28 is the mirror image and deliberately produces one refusal with both numbers. A Firm Admin cutting three seats to two is over the member count and under the plan floor simultaneously; being told about one, fixing it, and hitting the other is two round-trips through the same wall.

PATCH /app/firm/seats  {"seats": 2}
422 pramaan.billing.seats_below_floor
    "This firm cannot hold 2 seat(s): 3 member(s) are active, and the plan's
     minimum is 3 seat(s). Remove members first, or keep at least the minimum."
    details: {requested_seats, active_members, open_invites, min_seats, minimum_allowed_seats}

The staff door keeps no plan floor, on purpose. PATCH /admin/firms/{firm_id}/subscription/seats applies only the cannot-orphan-members guard it always had. Staff carry bespoke arrangements the rate card does not describe; the customer's own door is bound by exactly what the rate card says. The floor is min_seats read from the live firm rate card (load_firm_plan_terms), never a constant - same source the month-one checkout prices against.

The bug the seat route would have had

business.firms.seat_limit is a trigger-maintained mirror of the active subscription (migration 0015). The trigger ran as the invoker, which was fine while only staff sessions wrote subscriptions - business.firms has a firm-scoped SELECT policy, a staff-only write policy, and no firm-scoped write policy at all.

The moment a Firm Admin changes seats from the app, the trigger's UPDATE business.firms matches zero rows under RLS - silently, because a policy-filtered UPDATE is not an error. The subscription would carry the new count and the firm row the old one, and every downstream reader (checkout pricing, the mandate ceiling, the invite guard) reads the firm row.

Migration 0199 makes business.tg_sync_firm_seat_limit SECURITY DEFINER. That is the narrow fix: its only statement copies a column from a subscription row the caller's own RLS already authorized them to write, so it grants no new authority. The alternative - a firm-scoped UPDATE policy on business.firms - is far wider, because Postgres policies cannot be restricted to one column and it would hand the app write access to payment_status, the column the whole D28 intake gate rests on.

4. SB-29: stepping down is a handover, not a prohibition

Two guards already existed and both were too blunt. cannot_deactivate_self refused a Firm Admin leaving under any circumstances, and cannot_demote_self refused a Firm Admin lowering their own role under any circumstances. A founder who hired a partner could never hand over and leave.

The rule that was actually meant is the firm always has someone who can pay its bill. Both self-guards are replaced by the last-admin check that was already there for the other-target case: refused while you are the only Firm Admin, allowed the moment a second one exists. The refusal message names the way out.

AttemptOnly adminAfter handover
Lower own role422 cannot_demote_last_admin200
Leave the firm422 cannot_deactivate_last_admin200
Demote another Firm Admin422 cannot_change_admin_role - unchanged, staff-only

The two retired codes are deleted, not aliased: cannot_deactivate_self and cannot_demote_self no longer exist anywhere, and the integration tests that asserted them now assert the handover behaviour instead.

5. SB-49: removal is already immediate - this locks it in

No new code. get_db_with_app_session resolves firm scope through fm.status = 'active' and fails closed BEFORE any firm GUC is set (Bug-2568). A removed member's very next request resolves no tenant, so intake admission is never even reached and no presigned URL, no upload, and no vendor cost follow. There is no permission cache to wait out - the matrix cache holds the role→permission table, never a user's decision.

The scenario is therefore a lock-in, not a new detector, and it is written to say so. Its negative control is planted below: reactivate the member and the same intake succeeds.

6. SB-53: visible, never refused, and it costs no new write

One login shared by a whole chamber is how a three-seat firm does six people's work. The RFD does not refuse it - refusing on a heuristic would block a lawyer mid-matter over a coincidence (two offices, a phone, a VPN), and being wrong costs far more than the leakage. The decision is to make it visible.

The evidence substrate is deliberately not a new table. Every billable intake already writes a document.created audit row; this Story stamps the caller's Clerk session id (sid) into that row's metadata. Counting distinct session ids over a window against ix_audit_log_actor_created is then one query, on the path that already costs money. No sightings table, no per-request write, no sweep.

threshold  4 distinct sessions      (laptop + phone + one more is still ordinary)
window     60 minutes
action     member.concurrent_sessions_flagged   category=access_security  area=firm
dedupe     one row per member per window - a genuinely shared login must not bury
           its own firm's audit log under the evidence of the sharing

Placement is the billable path and not every request, because the risk being watched is seat sharing, and seat sharing shows up as spending from many places under one name. A login that reads is not the concern - reading never locks (D15) and reading is not billed. sid authorizes nothing; it is documented in docs/contracts/clerk-jwt-contract.md as read-for-counting only, and a token without it still works - the count is simply not taken.

7. Executable acceptance

ScenarioWhat it proves
SB-19A Member is refused on seats, checkout, and invoices; one audit row per refusal; the Firm Admin can do all three
SB-27The fourth invite into three seats is refused with both counts and a seat offer; no invite row survives
SB-28Two seats is one refusal naming the member count AND the three-seat minimum; the firm still holds three
SB-29Demote-self and leave both refused while sole admin; both open after a handover
SB-49A removed member's intake is refused and the page ledger is unchanged
SB-53Below threshold records nothing; crossing it records exactly one row; the upload still succeeds; crossing again in-window stays one row

Nothing is mocked. The authority scenarios go through the real FastAPI app over ASGI so the dependency graph - Clerk claims, the permission matrix, the RLS session, the gate - is the one production wires. Story-2723 adds no outbound call, so there is no provider to simulate.

8. Review gate

QuestionAnswer
New cloud capability?None. No new IAM, queue, network path, or invoke target. The only new privilege is inside Postgres: one SECURITY DEFINER trigger function, granted by the migration itself.
Who grants it, where?alembic/versions/d1f4b7c9a3e5_0199_billing_manage_permission.py, in this PR - both the billing:manage row and the trigger's security context.
Named check that goes red if absent?tests/unit/test_authz_permissions.py::test_role_permission_migration_seat_matches_runtime_matrix (matrix/migration parity) and tests/steps/test_billing_authority_seats.py SB-19/SB-28 (SB-28 is the one that goes red if the trigger stays invoker: the firm keeps the stale seat count).
Observed red?Yes - planted negatives, §9.
Legacy deleted in THIS PR?Yes. settings:write is gone from every checkout/mandate route; SettingsWriteDep is deleted from firm_checkout.py; the error codes cannot_deactivate_self and cannot_demote_self and their guards are deleted, not aliased; the "any active firm member" read policy on /app/billing/usage/* is replaced, and its docstring rewritten to say so rather than left describing the old rule.
Deploy smoke exercises the change?No deploy workflow changes here - this is app-API code reached by the existing App API Lambda deploy. The behaviour is proven by the six scenarios above against a real database.

9. Planted negatives (no green counts until it has been red)

ScenarioWhat was brokenResult
SB-27Seat-availability guard short-circuited to if FalseRED
SB-28assert_seat_count_allowed made a no-opRED
SB-29Handover condition removed - back to the blanket self-refusalRED
SB-53flag_concurrent_sessions call removed from the intake pathRED
SB-49Negative control: the removed member is reactivated before the intake attemptRED

SB-19 is the one scenario whose refusal comes from the permission matrix rather than a branch in this repo's code; its red proof is the same edit reviewers can make - remove BillingAuthorityDep from any route in the table above and the scenario's every attempt is refused step fails on that surface.