Story 2164: staff can grant a firm credits — once, on the record, and never twice

WHY. The catastrophe this Story prevents is a DBA hand-editing a money row at 2am. A firm’s batch fails, we owe them goodwill credits. Today the 'adjustment' credit-event slot has existed in the ledger since migration 0137 but no writer backs it — so the only way to grant credits is a raw UPDATE on billing.firm_credit_balance. That is every money bug at once: no audit of who did it or why, no idempotency (the on-call runs it twice, the firm gets double), no cap (a typo grants ₹10,00,000 instead of ₹10,000), and it mutates a projection the append-only ledger is supposed to be the truth for. pramaan-docs ADR-158 says money moves by appending a signed ledger row, never mutating one. Story-2164 (RFD-BILL-001 S3 / §5.8, goal G7) builds the one missing writer so a staff grant is a first-class, audited, exactly-once ledger event — and makes the pilot manually billable while unblocking the S4 cap-override.

🛰 Satellite — the one sentence

A single record_adjustment writer appends one signed 'adjustment' row to the append-only billing.credit_ledger — positive grants, negative claws back — idempotent by a caller-supplied adjustment_id, scoped to the firm by RLS, bounded by a config cap, stamped with the staff actor and a mandatory reason, and surfaced as a staff-only POST /admin/firms/{firm_id}/billing/credits/adjustment.

✈️ Airplane — a bank teller’s deposit slip, not a pencilled ledger

Picture the old option as erasing a number in a paper ledger and writing a bigger one: no trace of the change, and if your hand slips you can’t prove what it was. This Story replaces the eraser with a deposit slip. Each grant is a fresh slip (an appended row) that says who (the staff id, taken from their login, never typed into the form), why (a mandatory reason), how much and into which pocket (grant vs top-up bucket). The slip carries a reference number — the adjustment_id — and the teller’s rule is: one slip per reference number, ever. Hand in the same slip twice (a double-click) and the second is filed as a no-op; the balance moves once. And there’s a ceiling stamped on the counter: a single slip over the config cap is refused before any ink touches the ledger.

🔬 Microscopic — the four moving parts

1. The schema the writer needed — migration 0159 (additive, reversible)

The 'adjustment' enum existed; three small pieces did not. Migration 0159 adds them without touching one existing row’s meaning:

The entity models mirror it exactly — alembic check reports “No new upgrade operations detected.”

2. The pure math — two testable helpers in credits.py

No I/O, so the money rules are unit-tested without a database. credits_to_millicredits is the signed unit bridge (a +1,000-credit grant is +1,000,000 mc; a −250 clawback is −250,000 mc — no FX, credits are already ₹). adjustment_exceeds_cap bounds the magnitude of both signs against the USD cap (|delta| > cap), so a clawback is capped just like a grant.

3. The writer — record_adjustment, one append, same path as record_topup

Deliberately no new machinery: it reuses the exact append + firm-scope-reset + projection-update shape the top-up writer already uses. The order is the whole correctness story:

reason present? bucket valid? delta != 0? magnitude <= cap?   → else 422 (nothing written)
set app.current_firm_id = firm_id      (staff sessions carry no firm scope; RLS WITH CHECK needs it)
INSERT ... 'adjustment' ... WHERE NOT EXISTS (same firm_id + adjustment_id)  RETURNING id
  → None: already applied, return False   (idempotent no-op, no double-credit)
UPDATE the targeted projection bucket by the signed delta
restore app.current_firm_id  (finally)

Two subtleties worth their titles. Why set the firm scope at all: a staff session runs with is_staff=true but no current_firm_id, and the ledger’s firm_isolation policy’s WITH CHECK would reject a firm-scoped INSERT — so the writer sets the target firm itself and restores it, exactly as seed_starter_credit does. Why a NOT EXISTS guard, not a read-then-write: the idempotency lives in the single atomic INSERT, never in a separate SELECT that a concurrent call could slip between (the ticket’s explicit “DB guard only”). The cap is checked before the insert so a rejected grant writes nothing.

4. The route — staff-only, thin, on the existing billing prefix

POST /admin/firms/{firm_id}/billing/credits/adjustment sits on the existing firm_billing.py router (StaffDbDep). The handler is thin: the service guards the firm exists (404 for a ghost firm, so a phantom balance can never be conjured), calls the writer, and reads back the resulting two-bucket balance for the admin form. The staff id comes from the authenticated session (current_user.user_id), never the request body — audit authority is session-derived, not caller-claimed. The pramaan-admin grant form is a separate fast-follow.

What was deliberately not built

SkippedWhy it’s correct to skip
A dedicated billing.adjustment.write manual span The write is already covered by the HTTP request span (perimeter T1) + SQLAlchemy DB spans (T1). A new manual span name extends the ADR-124 closed registry, which needs an ADR-124 amendment the RFD flags as a prerequisite — same posture as the not-yet-built billing.topup.record span. Documented exemption, not an omission.
A separate ops.audit_log row The append-only credit_ledger row is the audit artifact (staff id + reason + timestamp, immutable), matching the sibling credit writers (record_topup / record_run_debit), which also emit no audit_log entry.
A “bonus” bucket distinct from top-up The projection has two buckets today: allowance and top-up. The RFD “bonus” bucket is the never-expiring top-up until the S5 expiry split lands; the writer targets the two real buckets and the DB CHECK is the backstop.

How it was proven (system boundary, not just green units)

One sentence to remember. A staff credit grant is a deposit slip, not an eraser: one appended, signed, capped ledger row per reference number — who, why, how much — that the database itself refuses to duplicate.