Story-2802 · functions lane · unit economics

Every model call now says what it cost, and whose it was

The broker already wrote one cost row per provider generation. It could tell you what a firm spent. It could not tell you what a matter, a user, or a document spent, how many tokens went into it, how slow the model was, what it cost in rupees, or whether the work failed. It can now — on the one ledger that already existed.

The shape of the change

PRAMAAN already had a vendor cost ledger: billing.vendor_cost_event, created by migration 0178 under pramaan-docs ADR-173, written by the clerk broker on every consumed model.usage receipt. This Story extends that row rather than creating a second one.

brain emits model.usage ──► broker consumes it
                                  │
                                  ├─ joins firm / matter / user / document / run / agent from the command
                                  ├─ takes tokens + model id from the receipt
                                  ├─ measures latency between stream frames
                                  ├─ prices it: provider cost if reported, else model-pricing.yaml
                                  ├─ freezes fx_rate_to_inr and computes cost_inr_micros
                                  │
                                  └──► ONE billing.vendor_cost_event row

Governing decision: pramaan-docs ADR-177 (amends and extends ADR-173).

What the row carries now

GroupColumnsWhere it comes from
Whouser_id (with existing firm_id, matter_id, document_id)The broker's command and surface context
Runrun_id, agent_key, seatThe command; seat awaits the agent receipt
Modelmodel_id, routeThe receipt; model_id promoted out of JSONB
Tokensinput_tokens, output_tokens, cache_read_tokens, cache_write_tokensThe receipt
Timinglatency_ms, output_tokens_per_secBroker-observed; throughput is a GENERATED column
Costfx_rate_to_inr, cost_inr_micros (with existing reporting_cost_usd_micros)Frozen at write time from billing.pricing_config
Failurestatus, finish_reason, cause_type, retriesThe broker's failure path (ADR-124 R16)

Four decisions worth knowing about

1. The answering model left the metadata blob — and the old spelling was deleted

model_id used to live as event_metadata->>'served_model', where no index could reach it. Migration 0205 backfills it into a real column, strips the key from every existing row, and removes served_model from the allowlist CHECK and the service validator in the same migration. Two spellings of the answering model is exactly the drift ADR-176 D3 forbids, so nothing is left behind for "part 2".

2. Rupees are frozen per row, never recomputed

Each row stores the rate that was applied (fx_rate_to_inr) beside the amount it produced (cost_inr_micros), both in integer micros. A rollup sums what was frozen. Two identical $1.00 generations priced at ₹83.25 and ₹110.50 sum to ₹193.75 — not twice either rate. A closed month cannot reprice.

3. Throughput is computed by Postgres, not by us

output_tokens_per_sec is GENERATED ALWAYS ... STORED over output_tokens and latency_ms. A derived number written by hand drifts from its inputs the first time one writer forgets; this one cannot. A missing latency yields NULL, not a division error and not a fabricated rate.

4. Failed work is a row

A generation that never completed emits no receipt, so a failing matter used to look cheap. A failed run now writes one zero-cost status='error' row carrying cause_type — the original exception's class name, content-free by construction. The money stays honest at zero; the work becomes visible.

Where prices come from

Provider-reported cost always wins. When OpenRouter returns a realized charge, that integer micro-USD number is billed verbatim as event_kind='actual' and the rate card is never consulted.

The card prices only what no provider priced — a direct native-provider call (ADR-176's direct-signup route returns no per-call cost) or a gateway call that reported cost_source='unreported'. Those rows are event_kind='estimate', stamped with rate_card_version, and reconciled monthly against the vendor invoice.

QuestionAnswer
Where does model-pricing.yaml live?pramaan-functions, at src/shared/agent_contracts/model-pricing.yaml — beside the broker that applies it. Exactly one copy.
Why not pramaan-agents?pramaan-docs ADR-110: brains write no truth. Pricing a usage report is a billing act.
What replaced the old flat fallback?The per-model card. The conservative-high flat rate survives only as the card's default entry, so an unlisted model still over-bills slightly rather than running free.
Is it in the Lambda package?Yes, and the deploy workflow now asserts it. A data asset's absence produces no import error — it would deploy green and fail to price at runtime.

Asking the ledger a question

Each rollup is one query, served by a partial (dimension, incurred_at DESC) index. src/domains/vendor_cost_event/cost_rollups.py holds them all rather than scattering SQL across call sites.

cost_rollup(session, dimension="matter", dimension_id=..., grain="month")
cost_rollup(session, dimension="user",   dimension_id=..., grain="day")
cost_total_last_30_days(session, dimension="firm", dimension_id=...)
top_models_by_cost(session, firm_id=...)      # where the money actually goes

Windows are half-open (since inclusive, until exclusive) so consecutive windows tile without double-counting a boundary row, and buckets are UTC so a month boundary does not move with the reader's timezone.

top_models_by_cost is only answerable because model_id became a column — a GROUP BY event_metadata->>'served_model' could never be trusted at scale.

Two asks in the issue were narrowed, deliberately

Monthly partitioning — deferred. ADR-173 §4 already ruled "no launch partitioning; add it only after measured growth", and the blocker is structural as well as premature: PostgreSQL requires the partition key in every unique constraint. The ledger's exactly-once guarantee is a global unique idempotency_key — the one mechanism that makes SQS/EventBridge redelivery safe. Partitioning would force UNIQUE (idempotency_key, incurred_at), letting one provider generation bill twice across a month boundary, and would break the self-referencing reconciles_event_id FK. Revisit trigger is named: 50M rows or a 2s firm-month rollup.

Firm-scoped RLS read — not added. The RLS posture is unchanged: staff-only SELECT, producer INSERT, no firm read policy. ADR-173 §6.1 says customers see "never tokens, models, vendor names, vendor costs, prompts, or margin" — and after this change the table carries every one of those. A firm read policy would publish PRAMAAN's margin to its own customers. Firm-scoping is delivered instead as firm attribution plus firm-scoped rollups on staff-only routes. Showing a firm its own inference cost is a change to ADR-173 §6.1 and a founder call.

A third ask was mapped rather than duplicated: generation_id is source_id, already enforced by the openrouter:<generation_id> idempotency grammar. A second column holding the identical value is the drift this migration just deleted from metadata.

Acceptance evidence

Migration round-trip0205 upgrade → downgrade → upgrade against real Postgres, then alembic check clean (entity mirrors schema exactly).
Red-first proof10 of 14 new integration tests were observed failing against the pre-0205 schema before they passed against it. The downgrade path was also observed failing (the GENERATED column blocked dropping its own inputs) and fixed.
Generated column500 output tokens in 2000 ms reads back as exactly 250.000 tok/s from the database; a null latency reads back null.
Frozen FXTwo rows at different rates aggregate to the sum of what was frozen, not to either rate applied twice.
Dimension CHECKA textract row carrying inference dimensions is rejected by the service validator and, when planted directly, by the database.
Platform-private postureA firm session sees zero rows through raw SQL and through the rollup module.
RollupsSeeded events roll up correctly per firm / matter / user / document across day, month, and trailing-30 windows, with a sibling matter's spend correctly excluded.
Legacy deletedsrc/shared/agent_contracts/pricing.py and its test are removed; served_model is gone from the allowlist, the CHECK, the validator, and every stored row.
Full suite3856 passed, 3 skipped. Lint, alembic check, OpenAPI snapshot, domain-events, observability, and model-placement gates all pass.

Known limits, stated rather than hidden

latency_ms is broker-observedWall time from the previous stream frame to the model.usage frame, including stream transport — not provider-reported generation latency. The canonical agent receipt carries none today.
seat and finish_reason are NULLNeither rides the agent receipt yet. Both land when pramaan-agents stamps them; filed as a follow-up, not left implicit.
Ambient agents still write nothingRegistrar, framer, deep-read, and the context passes do not consume model.usage receipts at all, so their spend reaches no ledger. The clerk broker is the only wired producer; closing this is a filed follow-up.
The rate card is hand-maintainedIt will drift from vendor list prices. rate_card_version plus monthly invoice reconciliation is the containment — a stale card produces a reconcilable estimate, never a silent wrong actual. A CI test asserts the card still covers the seats the fleet runs.

Governing issue: pramaan-spec#2802.