Story 2163: the dial gets a knob — staff turn pricing without a deploy

WHY. S1 (Story-2159) moved every commercial number out of Python and into two editable rows (billing.plan, billing.pricing_config). But those rows were read-only to the running app — the only way to change a number was still a migration and a deploy. That is a dial you can see but not turn. The catastrophe this Story prevents is the founder needing an engineer to change a price: markup 3×→4×, GST 18→12, a plan retired — each of those is a commercial decision, not a code change. Story-2163 ships the S2 write API so staff turn the knob by editing data. It does so under three laws that never bend: a firm can never edit its own pricing (STAFF-scoped only), every edit is audited old→new, and an edit affects the next order/run only — past ledger rows and past orders are immutable (append-only, pramaan-docs ADR-158).

🛰 Satellite — the one sentence

A staff-only /admin/billing surface reads and PATCHes every commercial number on billing.plan (per plan) and billing.pricing_config (global), with the write gated at two layers (a platform-admin dependency plus a new RLS FOR UPDATE policy), each PATCH audited as a sparse old→new diff, and the change picked up live by the next debit because the debit path already reads the row.

✈️ Airplane — the thermostat gets a faceplate with a lock

S1 mounted a thermostat (the config rows) but left it behind glass. Story-2163 fits the knob and a keyed faceplate. The knob is the PATCH route. The lock is two independent tumblers that must both turn: the app-layer dependency verifies the caller is an active platform admin, and the database-layer RLS policy independently refuses the UPDATE unless the session carries app.is_staff=true. A firm-scoped session — the one a law firm's own app uses — sets neither, so a firm literally cannot turn its own price knob even if a bug let the request through. And every turn of the knob leaves a tamper-evident mark in the audit log: who, when, from what to what.

🔬 Microscopic — the five moving parts

1. The routes — src/shared/routes/billing_config.py

Five endpoints under /admin/billing, mounted on the admin Lambda:

Method + pathSession depPurpose
GET /plansStaffEvery plan, active first — inactive/legacy rows kept so a retired plan can be found and reactivated (lifecycle doctrine).
GET /plans/{plan_key}StaffOne plan; 404 pramaan.billing_plan.not_found.
PATCH /plans/{plan_key}Platform adminSparse edit of a plan's numbers.
GET /pricing-configStaffThe single global-knobs row.
PATCH /pricing-configPlatform adminSparse edit of the global knobs.

Reads use StaffDbDep (any active staff, Tier-1); writes use PlatformAdminDbDep (platform-admin only, no firm in the URL) — the same read-staff / write-admin split the existing firm-billing route uses.

2. The write door in the database — migration 0159

S1 granted the app role only SELECT on the config tables. A route cannot UPDATE a row it has no privilege on, and FORCE ROW LEVEL SECURITY subjects every writer to a policy that did not exist. Migration 0159 opens exactly one door and no more:

GRANT UPDATE ON TABLE billing.plan TO pramaan_app;
CREATE POLICY config_staff_write_policy ON billing.plan
  FOR UPDATE
  USING (current_setting('app.is_staff', true)::bool = true)
  WITH CHECK (current_setting('app.is_staff', true)::bool = true);

Only UPDATE — never INSERT/DELETE. Adding or retiring a plan, and creating the singleton row, stay migration-owned (the config-driven taxonomy flip, ADR-151). The migration is additive and fully reversible; the roundtrip reports no model drift.

3. Validation at the trust boundary — the PATCH DTOs

Every PATCH field is optional (sparse edit). Ranges are enforced in Pydantic before a value reaches the row: ai_markup>0, fx>0, gst_pct 0–28 (the statutory ceiling), min_topup≥0, min_seats≥1. The DB CHECKs from S1 (markup/fx positive, cap_warn 0–100) remain the authoritative floor.

Sparse semantics differ by table because nullability differs. For a plan, an omitted field is left unchanged and an explicit null clears a nullable column (seat price is genuinely null on Startup) — so the service applies model_dump(exclude_unset=True). For the global config, every column is required, so a null is not a clear; nulls are dropped on apply.

4. Audit — who/when/old→new, and no-op writes nothing

Each service loads the row, computes the set of fields whose value actually changes, and if that set is empty returns unchanged with no UPDATE and no audit row (an idempotent no-op is not an event). Otherwise it applies only the changed fields, flushes, and emits a typed billing_plan.edited / pricing_config.edited diff. Two new audit subjects were registered in the closed registry (area settings, no matter scope); the diff carries only commercial numbers, never PII. The route-coverage manifest maps both PATCH routes to their action, so a forgotten emit would fail the build.

5. Why “next order only” is free

Nothing in this Story touches a past row. The edit is a single UPDATE of the config row; the append-only ledger and past orders are separate tables, never written here. The AI debit path already reads ai_markup × fx_usd_to_inr live from the config row (S1) and stamps the markup it applied onto each frozen ledger line — so a dial move re-prices the next run and can never re-price a past invoice. The test proves this end-to-end: PATCH the markup, then load_ai_pricing returns the new value on a fresh session.

Verify

Recall in one sentence: Story-2163 gives PRAMAAN staff a locked knob on the billing dial — a STAFF-scoped read/edit API over the config rows, guarded at the app and RLS layers, audited old→new, that changes the next order and never a past one.