Files
curriculum-project-hub/hub/prisma/migrations/20260718120000_usage_fact_ledger/migration.sql
T
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

65 lines
2.6 KiB
SQL

-- ADR-0026: append-only UsageFact ledger. AgentRun.costUsd/inputTokens/
-- outputTokens become a derived rollup cache; the truth is in UsageFact.
CREATE TABLE "UsageFact" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"kind" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"model" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"quantity" DECIMAL(18, 6),
"unit" TEXT,
"costUsd" DECIMAL(18, 8),
"costSource" TEXT NOT NULL,
"capabilityId" TEXT,
"correlationId" TEXT,
"metadata" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UsageFact_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "UsageFact_runId_occurredAt_idx" ON "UsageFact"("runId", "occurredAt");
CREATE INDEX "UsageFact_runId_kind_idx" ON "UsageFact"("runId", "kind");
CREATE INDEX "UsageFact_provider_model_occurredAt_idx"
ON "UsageFact"("provider", "model", "occurredAt");
CREATE INDEX "UsageFact_capabilityId_occurredAt_idx"
ON "UsageFact"("capabilityId", "occurredAt");
ALTER TABLE "UsageFact"
ADD CONSTRAINT "UsageFact_runId_fkey"
FOREIGN KEY ("runId") REFERENCES "AgentRun"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Backfill: one synthetic model_completion fact per AgentRun that has any
-- recorded usage (cost or tokens). correlationId = run id marks these as
-- backfill artefacts (real facts use an external correlation id or null).
-- Runs with no recorded usage stay fact-less and remain runsWithoutCost,
-- matching ADR-0022's "missing cost ≠ zero" rule and the pre-existing
-- migration 20260709143000_agent_run_cost_tracking's "no backfill for the
-- truly unrecorded" stance.
INSERT INTO "UsageFact" (
"id", "runId", "occurredAt", "kind", "provider", "model",
"inputTokens", "outputTokens", "costUsd", "costSource",
"correlationId", "metadata"
)
SELECT
'usagefact_backfill_' || "AgentRun"."id",
"AgentRun"."id",
COALESCE("AgentRun"."finishedAt", "AgentRun"."startedAt", CURRENT_TIMESTAMP),
'model_completion',
"AgentRun"."provider",
"AgentRun"."model",
"AgentRun"."inputTokens",
"AgentRun"."outputTokens",
"AgentRun"."costUsd",
COALESCE("AgentRun"."costSource", 'unknown'),
"AgentRun"."id",
'{}'::jsonb
FROM "AgentRun"
WHERE "AgentRun"."costUsd" IS NOT NULL
OR "AgentRun"."inputTokens" IS NOT NULL
OR "AgentRun"."outputTokens" IS NOT NULL;