Story-2543 — Private Vendor-Cost Ledger & Staff Unit-Economics API

pramaan-functions · RFD-BILL-002 §5.2/§5.3/§7.2/§8/§9 · pramaan-docs ADR-173 · Epic-2469 Phase 3

1. Why two books

Customer page usage and PRAMAAN's own machine spend are different facts. A failed 200-page INTAKE bills the customer nothing but still cost PRAMAAN a Textract job and several model calls. Story-2539 owns the customer book; this story adds the platform-private cost book — billing.vendor_cost_event — and the staff-only view that joins the two into per-firm/month unit economics.

2. The ledger — billing.vendor_cost_event

FieldMeaning
idempotency_key (unique)Producer receipt: textract:<job> / openrouter:<generation> / storage:<firm>:<date>:<class> — grammar CHECK-enforced per category
firm_id · matter_id · document_idNullable attribution — identifiers only, never content
category · vendor · source_idtextract|inference|storage · aws_textract|openrouter|aws_s3 · provider receipt id
quantity · unitPages / generation / byte-hours
native_cost_micros · native_currency · reporting_cost_usd_microsInteger micros (D-8) — a $0.000033 model call survives aggregation exactly (CL-11)
fx_rate_version · rate_card_versionFrozen conversion and vendor rate references
event_kind · reconciles_event_idestimate|actual|adjustment; an adjustment must link the event it reconciles
event_metadataAllowlisted JSONB: region, feature set, served model, provider, cache class, storage class — nothing else
Append-only (D-3). A database trigger rejects every UPDATE and DELETE. When the AWS invoice says the Textract job cost less than estimated, reconciliation appends an adjustment event linked via reconciles_event_id (CL-14) — the original estimate is never edited, and an adjustment can never reconcile another adjustment.
No legal content, ever (D-12). Two independent guards: a Pydantic extra="forbid" validator at the single write seam, and a Postgres CHECK (event_metadata - ARRAY[allowed keys] = '{}'). Prompts, completions, filenames, quotes, and legal text cannot enter the ledger through either door.
Platform-private (D-9). RLS grants SELECT to platform staff only; there is deliberately no firm read policy. A firm's own RLS session reads zero rows, and no customer route exposes the table.

3. The view — billing.firm_monthly_unit_economics

A security_invoker SQL view (projection, rebuildable from the immutable ledgers) joining vendor cost against the Story-2539 commercial customer ledger per firm/month: native revenue split by currency (revenue_inr_micros / revenue_usd_micros — never FX-mixed at read time, D-8), pages, cost by category, total direct machine cost, and gross contribution.

4. The staff API

GET /admin/unit-economics?period=2026-07                      → all firms, one month (§7.2 table)
GET /admin/firms/{firm_id}/unit-economics?period=2026-07      → summary + drill-down receipts

Drill-down identifiers per event: matter → document/artifact → provider receipt (source_id), with adjustment linkage. Both routes run under the staff RLS session (any active platform staff).

5. Denied means audited (CL-12)

A firm member calling either route receives 403 — and the refusal itself becomes evidence: one committed unit_economics.access_denied row in ops.audit_log, stamped with the caller's real firm-user identity (never as staff), carrying the route and method. Integration-proven: two denied requests → two audit rows on the member's firm trail.

6. What was proven

InvariantProof
UPDATE/DELETE blocked after insertintegration — trigger raises on both
Duplicate delivery writes once (D-4)integration — same key twice → one row
Sub-cent micros survive aggregation (CL-11)integration — 3 × 33µ$ → exactly 99µ$ in the view
Estimate + linked adjustment (CL-14)integration — linkage kept, estimate untouched, adjust-an-adjustment refused
Metadata allowlistunit (validator) + integration (DB CHECK) — forbidden keys rejected at both layers
Firm session reads zero vendor rowsintegration — RLS
Staff totals reconcile from seeded eventsintegration — 300,000 − 50,000 + 99 + 26 = 250,125 µ$
Firm member denied + auditedintegration — 403 + audit rows

Postgres only (D-2): no DynamoDB, no OpenSearch. Producers (Textract completion, inference receipt capture, the daily storage allocation job) wire into the single write seam in later Epic-2469 stories.