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).
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.
| Concern | File |
|---|---|
billing:manage grant + the SECURITY DEFINER seat-sync fix | alembic/versions/d1f4b7c9a3e5_0199_billing_manage_permission.py (new) |
| Runtime matrix mirror | src/shared/authz/permissions.py |
| The gate, and the audited refusal | src/shared/billing/authority.py (new) |
| Seat accounting + the SB-28 floor | src/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, invoices | src/app_api/routes/firm_checkout.py, src/app_api/seats_and_billing/router.py, src/app_api/router_billing_usage.py |
| SB-27 invite refusal | src/domains/firm_invite/service.py |
| SB-29 handover rule | src/app_api/firm_service.py |
| SB-53 sighting stamp + flag call | src/domains/document/service.py, src/app_api/router_matters.py, src/shared/auth/clerk.py |
| Executable acceptance | tests/features/billing_authority_seats.feature, tests/steps/test_billing_authority_seats.py (new) |
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.
__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.
| Surface | Was | Now |
|---|---|---|
POST /app/firm/checkout/month1 | settings:write | billing:manage |
POST /app/firm/checkout/orders/{id}/provider-order | settings:write | billing:manage |
POST /app/firm/checkout/mandate | settings:write | billing:manage |
POST /app/firm/checkout/mandate/{id}/provider-token | settings:write | billing:manage |
GET /app/firm/checkout/mandate, GET /app/firm/checkout/state | ungated inside firm RLS | billing:manage |
GET /app/firm/seats-and-billing | ungated inside firm RLS | billing:manage |
GET /app/billing/usage/{current-period,receipts,breakdown,grants} | any active firm member | billing:manage |
GET / PATCH /app/firm/seats | did not exist | billing: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.
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.
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.
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.
| Attempt | Only admin | After handover |
|---|---|---|
| Lower own role | 422 cannot_demote_last_admin | 200 |
| Leave the firm | 422 cannot_deactivate_last_admin | 200 |
| Demote another Firm Admin | 422 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.
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.
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.
| Scenario | What it proves |
|---|---|
| SB-19 | A Member is refused on seats, checkout, and invoices; one audit row per refusal; the Firm Admin can do all three |
| SB-27 | The fourth invite into three seats is refused with both counts and a seat offer; no invite row survives |
| SB-28 | Two seats is one refusal naming the member count AND the three-seat minimum; the firm still holds three |
| SB-29 | Demote-self and leave both refused while sole admin; both open after a handover |
| SB-49 | A removed member's intake is refused and the page ledger is unchanged |
| SB-53 | Below 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.
| Question | Answer |
|---|---|
| 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. |
| Scenario | What was broken | Result |
|---|---|---|
| SB-27 | Seat-availability guard short-circuited to if False | RED |
| SB-28 | assert_seat_count_allowed made a no-op | RED |
| SB-29 | Handover condition removed - back to the blanket self-refusal | RED |
| SB-53 | flag_concurrent_sessions call removed from the intake path | RED |
| SB-49 | Negative control: the removed member is reactivated before the intake attempt | RED |
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.