Files
curriculum-project-hub/hub/prisma
hongjr03 f3b087371a feat(hub): usage fact ledger for run-scoped cost attribution (ADR-0026)
Replace the single-scalar cost model on AgentRun with an append-only
UsageFact ledger. One AgentRun owns zero or more UsageFact rows; each
records one billable consumption event (model completion, external
capability, or tool proxy) with its own provider/model/tokens/quantity/
cost. AgentRun.costUsd/inputTokens/outputTokens become a derived rollup
cache.

This unblocks external capabilities (PDF->MD bundle, audio/video->text)
that bill in non-token units (pages, seconds) through a different
provider than the main agent loop, without per-capability schema changes
or nested AgentRuns (which would pollute lock/admission/session
semantics).

Contract:
- spec/Spec/System/Agent/Usage.lean pins UsageFact, UsageFactKind,
  CostSource and three invariants: append-only; belongs to one run,
  never holds a lock; missing cost != zero (ADR-0022).
- ADR-0026 records the decision, the rejected nested-Run alternative,
  the rollup cache strategy, and the deferred capability registry /
  pricebook / post-hoc correction flows.

Schema:
- UsageFact model with indexes on (runId, occurredAt), (runId, kind),
  (provider, model, occurredAt), (capabilityId, occurredAt).
- Migration backfills one synthetic model_completion fact per existing
  run with recorded cost/tokens (correlationId = runId marks backfill);
  truly unrecorded runs stay runsWithoutCost per ADR-0022.

Write path (trigger finish):
- Write the UsageFact first, then mirror it onto AgentRun as two
  separate statements (not one transaction). The fact is the truth so it
  goes first; the cache is derived so it goes second. A crash between
  them leaves the cache stale but the usage service re-reads facts
  directly, so this is recoverable; the reverse order would lose the
  truth. Separate statements also avoid an AgentRun row lock held across
  the insert's FK ShareLock, which deadlocked concurrent workspace
  teardown under the Organization->Project->AgentRun->UsageFact cascade.

Read paths:
- org/usage.ts aggregates from UsageFact, ignoring the AgentRun cache.
- slash /usage buckets by (fact.provider, fact.model); a run with a
  main loop + an external call lands in two buckets.
- session detail exposes usageFacts[] + costSource for future per-run
  cost-breakdown UI.

Tests:
- usage.test.ts: 6 integration tests pin fact aggregation, missing-cost-
  !=-zero, multi-fact-per-run, empty-run, project-level, empty-org.
- trigger.test.ts: existing /usage assertion ($0.0023,
  openrouter / mock-model) passes on the fact path.
- feishu-reactions mock prisma gains usageFact.create.
2026-07-18 14:44:23 +08:00
..