Files
kandev/docs/specs/task-cost-ledger/spec.md
T
Henry 4c0d92154b feat(backend): add per-run cost/token ledger for task usage (#2960)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-23 22:15:19 +00:00

90 KiB

status, created, owner
status created owner
draft 2026-08-23 nova28

Task Cost & Token Ledger

Umbrella: costs · Related: office/costs - this spec amends four named passages in that document and Build lands the amendment in the same change; see Out of scope. Everything else there is unchanged.

Why

An operator running Kandev today cannot answer "what did this task cost?". Token counts for each turn are captured but buried in a JSON metadata blob with no cost attached and no way to aggregate; dollar cost is computed only by the Office subscriber, and Office ships disabled in production (KANDEV_FEATURES_OFFICE: prod: "false"). Every Kanban-only install — the shipped default — accumulates real spend with no durable, queryable record of it.

What

This feature adds a production-owned, append-only usage ledger: one immutable row per observed agent usage event, carrying token counts, resolved dollar cost, and the inputs needed to re-derive that cost. It runs regardless of the Office feature flag, and it is the single writer of the per-session usage rollup. It also adds the first read surface over that ledger: per-task and per-session usage totals.

Recording

  • AC-1 (Ubiquitous) The system SHALL persist one ledger row for every agent usage event it observes, in every installation, independent of the features.office toggle. This feature introduces no new runtime feature toggle: it is additive, it cannot alter agent behaviour (see Failure modes), and gating it would reproduce the exact defect it exists to fix — a shipped default that records nothing.

  • AC-2 (Event-driven) WHEN a usage event is observed for a known task, the system SHALL record the event's input tokens, cached-read tokens, cached-write tokens, output tokens, thought tokens, total tokens, resolved cost in subcents, cost source, and estimation flag as a single new row.

  • AC-3 (Ubiquitous) Ledger rows SHALL be immutable once written. The system SHALL provide no operation that updates or partially rewrites a recorded row. Correction is expressed by recording a further row, never by mutating one.

  • AC-4 (Event-driven) WHEN a usage event carries an output-token count that the adapter did not observe, the system SHALL record output tokens as not recorded (NULL) rather than as zero, so an unobserved sample is distinguishable from a measured zero.

  • AC-30 (Ubiquitous) Of the four nullable token columns, only tokens_out is reachable as not recorded from the current producer, and the writer SHALL be specified accordingly rather than inventing a rule. streams.PromptUsage (internal/agentctl/types/streams/agent.go) carries a presence bit for output tokens alone - OutputTokensPresent bool, always serialized, which is what makes AC-4 implementable - while CachedReadTokens, CachedWriteTokens and ThoughtTokens are plain int64 with omitempty, so an absent field and a measured zero are byte-identical on the wire. For those three the writer SHALL record the decoded numeric value, which is 0 when the field is absent, and SHALL NOT map absence to NULL: that would write not recorded over a genuine measured zero, the same conflation AC-4 forbids in the other direction, and it would do so for the overwhelming majority of rows. The three columns nonetheless stay nullable, so a transport that later carries presence bits can record the distinction without a migration; AC-12's explicit coalesce therefore remains required, both for tokens_out today and for those columns under any such transport. Consequently a NULL in tokens_cached_read, tokens_cached_write or tokens_thought is unreachable through the event path today. Scenarios asserting NULL handling for those three are forward-compatibility cover over a directly inserted row and SHALL say so; the reachable NULL path is exercised through tokens_out.

  • AC-5 (Ubiquitous) Each row SHALL carry the contract version under which it was written, so a later contract change can be detected per row rather than inferred from a date.

  • AC-23 (Ubiquitous) tokens_total SHALL be computed by the writer as tokens_in + tokens_cached_read + tokens_cached_write + tokens_out + tokens_thought, each not recorded value contributing zero. It SHALL NOT be copied from the payload. A provider-reported total that disagreed with the components would make a single API response self-contradictory; the components are authoritative and a differing payload total is ignored, not recorded. An overflowing sum is dropped as overflow.

  • AC-24 (Ubiquitous) The ledger writer SHALL consume the prompt-usage event that already exists on the internal bus: subject events.BuildSessionPromptUsageWildcardSubject() (session_prompt_usage.updated.*, declared in internal/events/types.go), published by the orchestrator's publishPromptUsage (internal/orchestrator/event_handlers_streaming.go). This feature introduces no new event, subject, or publisher, and does not change what the orchestrator emits. That publisher is not unconditional, and this spec SHALL NOT be read as claiming it is: it returns early when the session id is empty, when the event bus is nil, or when payload.Data.Usage is nil. A turn whose agent reported no usage object therefore produces no event, and so no ledger row and no drop counter - it is invisible to this feature rather than dropped by it. The ledger records exactly what is published and no more. Widening the publisher's precondition would change the orchestrator's published contract and is named in Out of scope.

  • AC-25 (Ubiquitous) The subscriber and writer SHALL live in a new package internal/task/usage, in the task tier that owns the table, and SHALL NOT import any internal/office/** package. It SHALL decode the bus payload into a struct it declares itself, whose JSON tags mirror lifecycle.SessionPromptUsageEventPayload - exactly as office/service.PromptUsageData already does for the same event. The wire contract is the JSON, not a shared Go type. This is what keeps the dependency direction the backend requires: internal/agent/runtime/lifecycle imports internal/task/models, so importing the lifecycle payload type from the task tier would point an edge back up the stack.

  • AC-34 (Ubiquitous) The subscriber SHALL NOT do its work on the bus callback goroutine. MemoryEventBus.Publish delivers to every regular subscription synchronously, on the publisher's own goroutine - its call site says so ("deliver to all synchronously to preserve ordering") - and the publisher here is publishPromptUsage, on the orchestrator's streaming path. A blocking handler would therefore put a database write, and under AC-32 up to three attempts with backoff between them, directly in front of agent streaming, contradicting the guarantee in Failure modes that recording never affects an agent run. The bus callback SHALL decode the payload, hand the result to a single long-lived worker over a bounded buffered channel, and return nil immediately. Specifically:

    • The channel SHALL hold 1024 events. A send that would block SHALL be abandoned rather than waited on, counted dropped:overflow (AC-27), and logged at warn with the task id. Blocking on a full channel reintroduces exactly the coupling this criterion removes, and silently discarding without a counter reproduces the invisible-writer failure AC-27 exists to bound.
    • Exactly one worker SHALL drain the channel, serially. That is what makes occurred_at assignment monotonic and therefore what makes AC-16's (occurred_at, id) order match arrival order, and it keeps AC-32's backoff off every other goroutine in the process. A goroutine per event - the shape office/service's maybeAsync uses for this same subject - SHALL NOT be used: it is unbounded under burst and it makes occurred_at acquisition racy against arrival, so two events could be ordered opposite to the order they were published in.
    • On shutdown the writer SHALL stop accepting new events, drain what is already buffered, and return once the buffer is empty or after 5 seconds, whichever comes first. Events still buffered at that deadline are counted dropped:drain_timeout and logged. An unbounded drain would hang process shutdown behind a wedged database; a drain of zero would discard every buffered event on every restart.
    • An event offered by the bus callback after the drain has begun SHALL be refused rather than queued, counted dropped:shutdown (AC-27), and logged; the callback SHALL still return nil immediately, because the bus delivers on the publisher's goroutine and a closing writer may no more block an agent turn than a busy one. shutdown marks a closed writer, not queue backpressure.
    • The event in flight at the deadline - the one the worker is writing, as distinct from those still queued behind it - SHALL be resolved too. The worker's per-event context SHALL derive from a context cancelled at the drain deadline, and AC-32's backoff sleeps SHALL honour that cancellation rather than run to completion. A cancelled in-flight event is counted dropped:error, its transaction rolls back, and no ledger row or rollup increment is written after the drain returns. Left unspecified, a writer may commit into a pool the process is closing, which is a use-after-close rather than a late row; and "shutdown returns within 5 seconds" becomes untestable, because the assertion cannot distinguish a drain that returned from a worker still running behind it.
    • The writer SHALL be constructed, started, and subscribed to the bus in internal/backendapp/main.go outside the if !cfg.Features.Office early return that today encloses RegisterEventSubscribers - the Office-disabled install is the one this feature exists for - and its drain SHALL be registered through the same addCleanup mechanism as the other shutdown steps, at a point after the repository cleanups are registered. runCleanups runs registrations in LIFO order, so registering later means draining earlier, which is what puts the drain ahead of the database pool's close. Registered before them it would run after the pool closed and every drained event would fail on a closed pool - the exact failure the bounded drain exists to prevent, reached by getting the ordering backwards.

    Decoding on the callback and writing on the worker is deliberate: a decode failure is attributable to the event that caused it (dropped:decode_error) and costs no queue slot, while the expensive and retry-prone part is what gets detached.

  • AC-35 (Event-driven) WHEN a usage event is recorded, the writer SHALL carry model, agent_type and agent_profile_id onto the row from the correspondingly named payload fields, writing '' for one only when the payload's own field is empty. AC-2 enumerates the token and cost values and stops there; without this criterion none of these three is required by anything, all three are NOT NULL DEFAULT '', and a writer that leaves all three empty satisfies every other criterion in this spec. It would also make AC-7's audit columns useless: the row would record the rates and the catalogue version that priced it, but not what was priced, so the cost could be neither re-derived nor attributed to a model or a profile. agent_type is additionally an input AC-31 derives provider from, so leaving it empty silently degrades that derivation to its model-prefix tier.

  • AC-27 (Ubiquitous) The writer SHALL export two expvar maps, visible at /debug/vars, mirroring the shape of internal/office/service/cost_metrics.go: task_usage_events_written_total keyed source=<cost_source>;provider=<provider>, and task_usage_events_dropped_total keyed reason=<reason> where reason is one of decode_error, unattributable, invalid, duplicate, overflow, drain_timeout, shutdown, error. Every terminal transition in State machine SHALL increment exactly one counter, so a writer that has silently stopped is observable and the drop classes are distinguishable from each other. The stages SHALL run in the order decode, admit, validate, ownership, insert - each stage's failure terminal, so exactly one counter is incremented however many stages an event could have failed. Admit is AC-34's queue hand-off, placed after decode so a malformed payload costs no queue slot: an event refused because the queue is full counts overflow, and one refused because the shutdown drain has already begun counts shutdown (AC-34). Uniqueness is deliberately not a stage of its own - duplicates are detected by the unique constraint at insert, which is the last stage (AC-13, AC-32) - so an event that is both a redelivery and invalid counts invalid, because it never reaches the insert. Validation likewise precedes AC-33's session-ownership check, so an event that is both invalid and session/task-mismatched counts invalid and is not recorded at all. The reason is not preference: validation reads only the decoded payload, while the ownership check costs a database read, and an event already known unrecordable SHALL NOT cause one.

    Within the validate stage the sub-checks SHALL themselves run in a fixed order, so an event failing more than one is classified deterministically: (1) usage_event_id present and non-empty, else invalid; (2) task_id present and non-empty, else unattributable; (3) no token or cost value negative, else invalid. That order follows the schema's own requiredness - the idempotency key, then the mandatory attribution column, then value sanity

    • and only the (2)-versus-(3) pairing is observable, since (1) and (3) share a label: an event missing task_id and carrying a negative token count counts unattributable, not invalid. Left unstated, two conforming implementations disagree on that event's counter and both satisfy this criterion.

    These counters observe the subscriber, not the producer. They bound the failure "the writer received events and stopped recording them"; they deliberately do not bound "the producer stopped publishing", because per AC-24 an unpublished turn increments neither counter. No surface SHALL present them as proof that a task's recorded spend is complete.

Cost resolution

Cost resolution reuses the two-layer contract already proven in office/costs and records which layer produced the number.

  • AC-6 (Event-driven) WHEN the usage event carries a provider-reported cost and its presence flag is set, the system SHALL record that amount verbatim (including an explicit zero) with cost_source = provider_reported, and SHALL NOT perform a pricing lookup.

  • AC-7 (Event-driven) WHEN no provider-reported cost is present and pricing is resolvable for the event's model, the system SHALL compute cost from the token counts and record it with cost_source = models_dev_list, together with the per-million rates and pricing-catalog version used. The computation is exactly:

    cost_subcents = (tokens_in           * rate_input_per_million
                   + tokens_cached_read  * rate_cached_read_per_million
                   + tokens_cached_write * rate_cached_write_per_million
                   + tokens_out          * rate_output_per_million) / 1000000
    

    All four products are summed in 64-bit integer arithmetic before the single division, and that division truncates toward zero. Overflow records an unpriced row with zero cost. Rounding happens once, on the summed numerator, never per token class: rounding four times drifts from rounding once, and two writers that disagree about where to round produce different money for identical input. A not recorded (NULL) token count contributes zero to the numerator. tokens_thought is not priced. This reproduces costs.CalculateCostSubcents (internal/office/costs/pricing.go) exactly, so a row priced here and a row priced by Office from the same tokens and rates are identical to the subcent.

  • AC-8 (Unwanted behaviour) IF no provider-reported cost is present and pricing cannot be resolved, THEN the system SHALL record the row with cost_subcents = 0 and cost_source = unpriced, and SHALL NOT drop the row. Token counts are still recorded. A lookup that has not returned within a writer-imposed deadline of 2 seconds counts as "cannot be resolved": the writer SHALL bound every pricing call with its own context deadline and treat a breach exactly as a miss. The deadline is the writer's, not the lookup's, because the lookup's own default is far larger - internal/office/costs/modelsdev/client.go constructs its HTTP client with Timeout: 30 * time.Second, six times AC-34's entire 5-second drain - and because the injected interface (AC-26) admits any implementation. Unbounded, the degradation this criterion promises does not happen during the outage it exists for: the single serial worker (AC-34) stalls per event, the 1024-slot queue fills behind it, and dropped:overflow becomes the steady state exactly where unpriced rows were specified. 2 seconds is chosen to sit comfortably inside the 5-second drain, so an event being priced when shutdown begins can still finish and commit rather than being abandoned.

  • AC-9 (Ubiquitous) estimated SHALL reflect the usage sample's authority as reported by the adapter and SHALL be recorded independently of cost_source. An estimated row counts toward totals at face value.

  • AC-26 (Ubiquitous) The pricing arithmetic of AC-7 SHALL be extracted to internal/common/costs - the rate struct and the cost function - with internal/office/costs delegating to it, so both writers compute identically and neither imports the other's tier. The models.dev catalogue lookup SHALL be constructed in internal/backendapp/main.go outside the features.office branch that holds it today (the SetPricingLookup / SetModelInfoLookup wiring) and injected into the ledger writer through a narrow interface the writer declares itself, so no office type crosses the boundary. Only the construction moves. Both existing setter calls stay inside the if cfg.Features.Office block: services.Office.SetPricingLookup must, because services.Office is nil in a gated install, and orchestratorSvc.SetModelInfoLookup SHALL as well. That second call feeds a wholly unrelated capability - the orchestrator's context-window fallback, per its own doc comment in internal/orchestrator/model_info.go - which is gated today and which this card does not touch. It shares only the pricingLookup variable and the enclosing if, so a literal reading of "construct it outside the gate" would carry it out too and silently enable context-window fallback for every Office-disabled install, which is the shipped default this feature exists for. The block therefore splits: an always-run construction whose value is injected into the ledger writer, and a still-gated pair of setter calls. Changing when SetModelInfoLookup runs is named in Out of scope. That interface SHALL return the rates and the catalogue version from a single call, so both come from one snapshot - mirroring shared.PricingLookupWithVersion (internal/office/shared/interfaces.go), which exists for exactly this reason and which lookupPricingWithVersion type-asserts. Two independent calls SHALL NOT be used: a catalogue that refreshes between them writes a new pricing_catalog_version beside the previous rate_*_per_million values, and the resulting row is indistinguishable from a correct one while being unreproducible, which defeats the only purpose those columns have. IF that wiring is absent, THEN AC-8 applies and rows record as unpriced. That degradation bounds build order; it is not permission to leave the lookup unwired.

  • AC-31 (Ubiquitous) The provider value SHALL be derived by the writer, because the bus payload does not carry one: lifecycle.SessionPromptUsageEventPayload has agent_type, agent_id and model, and no provider field. Derivation SHALL be the first non-empty of, in order: the CLI-id mapping applied to agent_type; the same mapping applied to agent_id; a model-prefix match; then ''. The CLI mapping is claude-acp to anthropic, codex-acp and openai-acp to openai, gemini and gemini-acp to google. The two CLI tiers are not redundant with the prefix match and SHALL NOT be collapsed into it: claude-acp reports logical aliases (sonnet, haiku) that no prefix match resolves, which is the documented reason providerFromCLI exists. Both helpers SHALL be extracted to internal/common/costs alongside the rate struct above - ProviderForModel from internal/office/costs/pricing.go and providerFromCLI from internal/office/service/event_subscribers.go - with Office's resolveProvider delegating to the extracted versions, so the two writers label the same event identically. Copying the mapping into internal/task/usage SHALL NOT be used to satisfy AC-25's no-office-import rule: internal/common/ is where this backend puts cross-tier shared code, and a second copy is how the two PTY implementations drifted. An unresolved provider is the empty string, which is a valid value for a NOT NULL DEFAULT '' column and a valid expvar label.

Rollup

  • AC-10 (Ubiquitous) The system SHALL maintain cumulative tokens_in, tokens_cached_in, tokens_out, and cost_subcents on the owning task_sessions row, and this ledger writer SHALL be the only writer of those columns.

  • AC-21 (Event-driven) WHEN features.office is enabled, the Office cost subscriber SHALL NOT increment the task_sessions rollup columns. The increment is removed from recordCostEventAndRollup (internal/office/service/event_subscribers.go), which continues to insert its own office_cost_events row exactly as today; the ledger writer performs the increment for both. This is the concrete edit AC-10's sole-writer rule requires. Without it an Office-enabled install double-counts every turn, and Office is enabled in the dev and e2e profiles, so the defect ships to every developer before it reaches an operator. The tests that pin the removed increment, and the shared-transaction plumbing it leaves behind, are resolved by AC-36.

  • AC-29 (Ubiquitous) Repository.BackfillSessionTokensCachedIn (internal/task/repository/sqlite/base_migrations.go) and its composition-root call SHALL be removed. It is a third writer of the AC-10 columns, and left in place it destroys ledger data on every restart. It is an assignment, not an increment: it sets task_sessions.tokens_cached_in to COALESCE((SELECT SUM(tokens_cached_in) FROM office_cost_events WHERE session_id = task_sessions.id), 0) for every row matching EXISTS (SELECT 1 FROM office_cost_events ...) OR tokens_cached_in <> 0. Its own doc comment records that it runs unconditionally on every boot with no run-once tracking, and internal/backendapp/storage.go calls it on every startup with no features.office gate - it sits immediately after office.Provide, which is itself ungated. In the Office-disabled install this feature exists for, office_cost_events is empty, so the OR tokens_cached_in <> 0 arm matches every session this ledger has just written and assigns zero; a failure there also aborts boot. Removal SHALL also delete the sessionCachedTokenBackfiller interface, the backfillSessionCachedTokens helper, and the construction-order comment that ties repository provisioning to it. Rows already reconciled by it keep their current values and are not recomputed, consistent with the no-backfill rule in Out of scope. Gating it behind features.office SHALL NOT be used instead: after AC-21 the Office subscriber no longer maintains those columns in any install, so office_cost_events is no longer their source of truth anywhere and re-deriving from it is wrong everywhere, not merely misplaced. Seven existing tests pin the surface this criterion deletes; their disposition is AC-36, and Build SHALL NOT decide it ad hoc.

  • AC-36 (Ubiquitous) Every production surface removed or changed by AC-21 and AC-29 SHALL have the tests that pin it resolved in the same change, and this criterion states which, because deleting or skipping a test to make a build green is otherwise forbidden and Build has no authority to grant itself the exception. Two of the tests below assert as correct the exact behaviour those criteria identify as destructive, so they cannot be left passing either. The rule is: a test whose subject no longer exists is deleted with it, which is not a weakening; a test whose subject moved to a new owner is rewritten against that owner, never deleted; and where a deleted test carried an assertion nothing else reproduces, that assertion SHALL be re-established against the ledger writer before the deletion lands, so no round of this change is net-negative on coverage.

    Surface removed or changed Tests pinning it Disposition
    Repository.BackfillSessionTokensCachedIn (AC-29) task/repository/sqlite/session_usage_test.go: TestBackfillSessionTokensCachedIn_SumsLedger, _CorrectsDriftedRollup, _ZeroesRollupWithNoMatchingLedgerRows, _SkipsAlreadyCorrectUntouchedSessions Deleted with the function. Their subject is gone. _ZeroesRollupWithNoMatchingLedgerRows in particular asserts the zeroing AC-29 identifies as data loss, so it cannot be retained or adapted. AC-12 is the surviving statement of the rollup-equals-ledger invariant these defended, and is separately tested.
    same, Postgres parity (AC-29) task/repository/sqlite/session_usage_postgres_test.go: TestPostgresBackfillSessionTokensCachedIn_SumsLedger, _HandlesValuesBeyondInt32Range Deleted with the function, with one precondition: _HandlesValuesBeyondInt32Range is the only beyond-int32 rollup coverage in the suite, so AC-28's equivalent boundary assertion against the ledger writer's rollup SHALL land first.
    backfillSessionCachedTokens call site, helper, and sessionCachedTokenBackfiller interface in backendapp/storage.go (AC-29) backendapp/storage_cached_tokens_test.go: TestProvideRepositoriesBackfillsSessionCachedTokens Deleted. It asserts the boot path calls the backfiller, which is precisely the behaviour being removed. Its replacement observable already exists: AC-29's two-boot restart scenario, which passes only because nothing recomputes the rollup.
    the rationale comment in backendapp/storage_telemetry_test.go citing that test as its precedent (comment only) Rewritten to cite a surviving neighbour. A comment pointing at a deleted test is a dangling reference inside the suite, and this one explains why an unrelated boot-order assertion exists.
    rollup increment inside recordCostEventAndRollup (AC-21) office/service/session_usage_reconcile_test.go: TestPromptUsage_RollupReconcilesWithCostLedger Deleted. It asserts Office's rollup equals the sum of office_cost_events, which AC-21 makes false by design in every install.
    same (AC-21) office/service/event_subscribers_cost_atomicity_test.go: TestPromptUsage_RollupFailureRollsBackLedgerInsertThenRetrySucceeds Rewritten against the ledger writer. The behaviour it encodes - a rollup failure rolls the insert back, a retry then succeeds - is still required, by AC-11 and AC-32; only its owner changes. Deleting it would drop the one existing test of that interaction.
    the shared-transaction plumbing (IncrementTaskSessionUsageTx and the repository-sharing helpers) office/repository/sqlite/cost_event_tx_atomicity_test.go: TestCostEventTxAtomicity_RealReposShareTransaction Retained, repointed. The abstraction is explicitly NOT removed: AC-11 requires exactly this capability for the ledger writer, so AC-21 removes a caller, not the mechanism. The test SHALL exercise the ledger writer's repositories instead of Office's.
  • AC-32 (Event-driven) WHEN a ledger insert fails, the writer SHALL classify the failure before reacting, and SHALL NOT distinguish failure kinds by matching the driver's message text uniformly across dialects: SQLite reports FOREIGN KEY constraint failed without naming the constraint, while Postgres names it, so one string match is wrong on one dialect. Classification SHALL use dialect-aware helpers added to internal/db beside the existing IsDuplicateColumnError / IsAlreadyExistsError and following their shape (a pgconn.PgError SQLSTATE check with a SQLite-specific fallback): IsForeignKeyViolation (Postgres 23503) and IsTransientError (Postgres 40001 serialization failure and 40P01 deadlock; SQLite SQLITE_BUSY and SQLITE_LOCKED).

    A unique violation is classified by a third detector, which deliberately does NOT live in internal/db: it SHALL be constraint-specific, declared in the task repository package (internal/task/repository/sqlite) beside the table it guards, matching Postgres on pgErr.Code == "23505" && pgErr.ConstraintName == "uniq_task_usage_events_usage_event_id" and SQLite on the constraint-bearing message UNIQUE constraint failed: task_usage_events.usage_event_id, and SHALL surface to its caller as a package-level sentinel error matched with errors.Is. This is not a new idea in this repo: it is the shape of isExternalIDUniqueViolation / uniq_tasks_external_id (internal/task/repository/sqlite/task_external_id.go) and of isUsageEventUniqueViolation / ErrDuplicateUsageEvent / uniq_office_cost_usage_event (internal/office/repository/sqlite/costs.go), and it is already a stated contract in a prior spec of this feature's own tier - docs/specs/tasks/external-id-idempotency/spec.md, "Unique-violation classification across dialects" - which gives the reason: "A bare 23505 check is insufficient: it matches any unique violation, so a primary-key failure would be misread as a Found outcome." Here the misread is worse than a wrong label: duplicate is the one drop class this spec declares not an error, so a violation of some other constraint classified as a duplicate is swallowed silently and counted as expected behaviour.

    Detection SHALL be by the constraint at insert, NOT by a pre-insert SELECT. A read-then-insert pair is not atomic: two concurrent deliveries of the same deterministic identifier can both read before either writes, both find nothing, and both attempt the insert - so the constraint decides the outcome regardless, after the read has already misled the writer about which branch it is on. The reaction SHALL be:

    • transient - re-run the whole unit of work, unchanged, in a fresh transaction per attempt, to a maximum of three attempts in total. "The insert" here means the AC-11 transaction entire - the ledger insert and the rollup increment - not the INSERT statement alone. Postgres aborts the whole transaction on both 40001 and 40P01, so re-issuing one statement inside the failed transaction returns 25P02 current transaction is aborted instead of reproducing the original condition, which would make the three-attempt guarantee unreachable on that dialect while appearing to work on SQLite. The row keeps its session_id and its rollup increment. Backoff between attempts SHALL be 50 ms then 100 ms - fixed exponential, base 50 ms, factor 2, no jitter, no cap needed at two waits - bounding a retried event at roughly 150 ms of added latency. That latency lands on the writer's own worker goroutine (AC-34) and never on the agent's, which is what makes a blocking sleep acceptable here at all. Jitter is deliberately omitted: with a single serial writer (AC-34) there is no fleet of peers to synchronize with, so it would add variance without reducing contention. Exhausted attempts are dropped:error.
    • foreign key - retry exactly once with session_id = NULL and no rollup increment. This deliberately requires no session-versus-task discrimination: if the violated key was task_id, the retry is rejected too and the row is dropped:error, which is already the specified outcome for that case.
    • unique violation on usage_event_id - no retry, no rollup increment, counted dropped:duplicate (AC-14), never dropped:error. This is the only insert failure that is an expected outcome rather than a fault, and it is what makes AC-14 reachable at all: without this bucket the "anything else" branch below claims every redelivery, so a correctly deduplicating ledger would report a climbing error count and a permanently zero duplicate count, and Failure modes' "not an error" row would be unimplementable.
    • anything else - dropped:error, no retry.

    A transient failure SHALL NOT take the foreign-key path. Nulling the session of a row that would have committed on a second attempt silently discards attribution the writer actually had, and does so precisely under load, where it is least likely to be noticed.

  • AC-33 (Event-driven) WHEN a usage event carries both a task id and a session id, the writer SHALL verify that the referenced session belongs to that task before inserting. IF they disagree, THEN the row SHALL be recorded under the payload's task_id with session_id = NULL and no rollup increment, and a warning SHALL be logged. It is not dropped: the spend is real, the task attribution is what the caller asserted, and the session reference is the field proven inconsistent. This is deliberately the same end state as an absent session row, so the two share one code path. Without this rule the writer can create rows the read side is specified to refuse - a session-scoped read returns 404 when the session does not belong to the task in the path - and IncrementTaskSessionUsageTx updates by session id alone, so it would have incremented another task's session rollup.

  • AC-11 (Event-driven) WHEN a ledger row is recorded, the ledger insert and the rollup increment SHALL be applied in a single transaction, so no committed state exists in which one landed and the other did not.

  • AC-12 (Ubiquitous) For any session whose usage was recorded by this ledger, the rollup columns SHALL equal the sum of that session's ledger rows, where tokens_cached_in sums cached_read + cached_write. Every nullable token column contributes zero when it is not recorded (NULL): this applies to tokens_cached_read, tokens_cached_write, tokens_out, and tokens_thought alike, in the rollup increment and in every read aggregation. The zero SHALL be produced by an explicit coalesce, not left to SQL, where NULL + n is NULL and one unrecorded sample would null out a whole session's total. Consequently the session output total is a lower bound when any of its rows has an unrecorded output count. The equality is a forward guarantee. Sessions whose rollup was written by Office before AC-21 removed that increment have no ledger rows behind those values, are not backfilled (see Out of scope), and are outside this criterion.

  • AC-28 (Ubiquitous) task_sessions.cost_subcents, task_sessions.tokens_in and task_sessions.tokens_out SHALL be widened to BIGINT, so all four rollup columns satisfy the same 64-bit rule as the ledger. The migration is idempotent and dialect-aware in the established runMigrations() shape: on Postgres it alters the column type; on SQLite it is a no-op, because SQLite's INTEGER is already 64-bit.

Idempotency and ordering

  • AC-13 (Ubiquitous) The externally minted usage event identifier SHALL be unique across the ledger and SHALL be the idempotency key.

  • AC-14 (Unwanted behaviour) IF an event bearing a deterministic identifier is redelivered with an identifier already present in the ledger, THEN the system SHALL record no new row, apply no rollup increment, and report the drop as a duplicate rather than as an error.

  • AC-22 (Ubiquitous) Deduplication SHALL be exactly as strong as the identifier the producer minted, and SHALL NOT be described as stronger. usageEventIDFor (internal/orchestrator/event_handlers_streaming.go) derives a deterministic UUIDv5 from (session_id, execution_id, prompt_generation) only when all three are present; when any is missing it returns a fresh random UUID, which office/costs already documents deliberately: "Generation-less transports receive a random per-publish key, so those rows are intentionally not deduplicated." For that transport class the system SHALL record a redelivered event as a NEW row and SHALL apply its rollup increment. The ledger records what it was told. AC-14 is therefore a guarantee about identifiers, not a guarantee that spend is never counted twice, and no read surface may present it as the latter. Changing the minting rule is out of scope (see Out of scope). A test that exercises AC-14 with a hand-chosen fixed identifier proves nothing about this path; the redelivery scenarios below cover both classes deliberately.

  • AC-15 (Ubiquitous) A single turn MAY produce multiple ledger rows. (session_id, turn_id) is an aggregation key, NOT a uniqueness constraint.

  • AC-16 (Ubiquitous) Ledger reads SHALL be ordered by occurred_at ascending with the monotonic id column as the tiebreak. This pair is a total order: no two rows compare equal, so repeated reads return an identical sequence. id orders rows by the instant the writer acquired it, which under concurrent commits may differ from commit order; the contract is determinism and stability, not a claim that id reproduces commit sequence.

    This ordering binds a named read. The repository SHALL expose ListTaskUsageEvents(ctx, taskID string, limit int) ([]TaskUsageEvent, error) returning that task's rows in (occurred_at, id) ascending order, limit of zero or less meaning no limit. An unknown task and a task with no rows both return an empty slice and a nil error, never an error and never a nil slice. It is repository-internal: it backs the append-only and ordering assertions in Scenarios, and Out of scope keeps it off the HTTP surface. Without a named method this criterion would constrain a read no builder is required to write, and the two specified routes return aggregates whose MIN/MAX and SUM are order-independent - they cannot observe it.

  • AC-17 (Unwanted behaviour) IF a usage event arrives with no turn identifier, THEN the system SHALL still record the row with a null turn_id and SHALL still apply the rollup. A missing turn identifier degrades per-turn grouping only; it never costs a row. In the sampled database this is the majority case (1,135 of 1,962 rows), so dropping such events would discard most recorded spend.

Reading

  • AC-18 (Ubiquitous) The system SHALL expose per-task usage totals and per-session usage totals over HTTP, in every installation, independent of the features.office toggle.
  • AC-19 (Ubiquitous) Totals SHALL report tokens by kind, total cost in subcents, the number of contributing events, and whether any contributing row was estimated or unpriced, so a caller can tell a confident total from a partial one. tokens_total in a response SHALL be the sum of the stored tokens_total column over the contributing rows, never recomputed from the per-kind columns at read time. The response SHALL report every kind AC-23 sums - tokens_in, tokens_cached_read, tokens_cached_write, tokens_out and tokens_thought - so that "the two agree by construction" is a property the caller can actually verify. Because AC-23 computes the stored column on write from exactly those kinds and the response neither recomputes nor omits one, a response cannot contradict itself. Omitting a summed kind would make tokens_total exceed the visible kinds for every reasoning-heavy model while the spec claimed the opposite.
  • AC-20 (Ubiquitous) A task or session with no recorded usage SHALL return zeroed totals with a zero event count, not an error and not an empty body. output_tokens_complete SHALL be true in that case: no contributing row has an unrecorded output count, so the zero total is exact rather than a lower bound. estimated_event_count and unpriced_event_count are 0 and both timestamps are null.

Data model

task_usage_events (new)

Owned by the task repository (internal/task/repository/sqlite), alongside task_step_transitions, whose append-only-log conventions it follows: dialect-aware monotonic integer primary key, a contract_version column, and (occurred_at, id) ordering.

task_usage_events
  id                       integer   PK, monotonic
                                     (SQLite AUTOINCREMENT / Postgres BIGSERIAL)
  usage_event_id           text      NOT NULL — idempotency key (AC-13)
  task_id                  text      NOT NULL, FK -> tasks(id) ON DELETE CASCADE
  session_id               text      nullable, FK -> task_sessions(id)
                                     ON DELETE SET NULL
  turn_id                  text      nullable, no FK
  agent_profile_id         text      NOT NULL DEFAULT ''
  agent_type               text      NOT NULL DEFAULT ''  -- CLI engine slug
  model                    text      NOT NULL DEFAULT ''
  provider                 text      NOT NULL DEFAULT ''
  tokens_in                bigint    NOT NULL DEFAULT 0
  tokens_cached_read       bigint    nullable — NULL = not recorded
  tokens_cached_write      bigint    nullable — NULL = not recorded
  tokens_out               bigint    nullable — NULL = not observed (AC-4)
  tokens_thought           bigint    nullable
  tokens_total             bigint    NOT NULL DEFAULT 0 - computed, AC-23
  cost_subcents            bigint    NOT NULL DEFAULT 0
  cost_source              text      NOT NULL — provider_reported
                                     | models_dev_list | unpriced
  estimated                integer   NOT NULL DEFAULT 0
  rate_input_per_million         bigint   nullable
  rate_cached_read_per_million   bigint   nullable
  rate_cached_write_per_million  bigint   nullable
  rate_output_per_million        bigint   nullable
  pricing_catalog_version  text      nullable
  contract_version         integer   NOT NULL
  occurred_at              timestamp NOT NULL
  created_at               timestamp NOT NULL

  UNIQUE INDEX uniq_task_usage_events_usage_event_id (usage_event_id)
  INDEX (task_id, occurred_at, id)
  INDEX (session_id, turn_id)
  INDEX (occurred_at)

The uniqueness of usage_event_id SHALL be that named unique index, not an inline column constraint. AC-32's Postgres detector matches on pgErr.ConstraintName, so the name is part of the contract: an auto-generated name would differ between a fresh install and a migrated one and make the detector unportable between them. It is created the way uniq_office_cost_usage_event is (internal/office/repository/sqlite/base_migrations.go) and named the way uniq_tasks_external_id is.

Units and nullability rules:

  • cost_subcents is hundredths of a cent (int64). A caller renders dollars by dividing by 10,000. This matches office_cost_events.cost_subcents and task_sessions.cost_subcents; no new unit is introduced. There is no cost_usd column — a float dollar amount would make sums non-associative and is deliberately not stored.
  • A nullable token column means not recorded; zero means measured zero. The two are never conflated (AC-4). Which columns can actually carry not recorded is bounded by what the wire can express: only tokens_out today (AC-30). In every arithmetic context - the rollup increment, the read aggregations, and the AC-7 pricing numerator - a not recorded value contributes zero and MUST be coalesced explicitly. SQL's NULL + n = NULL would otherwise let one unrecorded sample null out an entire session's total (AC-12).
  • Every token and cost column is 64-bit (BIGINT), never a 32-bit INTEGER. This is not defensive sizing: a single sampled turn reports 8,203,943 cached-read tokens, and the merged per-session cached-input total has been measured at 98,805,109 on one already-completed task. On Postgres INTEGER is int4 (ceiling 2,147,483,647), so a long-running session would abort the insert and take the whole transaction with it. SQLite's INTEGER is already 64-bit; the widening exists for Postgres parity.
  • occurred_at is the ingest instant in UTC, acquired once, before the first insert attempt, and reused byte-for-byte by every AC-32 retry of that event. It SHALL NOT be re-read on a later attempt: a retried event would otherwise carry a timestamp later than events that arrived after it, which breaks the (occurred_at, id) total order AC-16 promises and lets first_event_at / last_event_at move under contention rather than under real event arrival. It is deliberately NOT taken from the payload's timestamp field: that value is producer-clock-derived and would let a skewed or replayed producer reorder the ledger. created_at equals occurred_at for a first write; the two are separate columns so a future out-of-band import can carry a true event time without lying about when the row was recorded.
  • contract_version starts at 1 for every row written by this feature. It is bumped only when a column's meaning changes, not when a column is added.
  • The rate_* and pricing_catalog_version columns are populated only on the models_dev_list path. They are recorded so a computed cost can be re-derived and audited later; without them an unpriced-to-priced catalog change is indistinguishable from a pricing bug.
  • tokens_cached_read / tokens_cached_write are stored separately, not pre-merged. Providers charge different rates for the two, and a merged value cannot be decomposed afterwards. The merged sum exists only on the rollup.

Foreign-key rationale (this deviates from the task brief's stated ON DELETE CASCADE from task_sessions; see Corrections to the task brief):

  • task_id cascades from tasks. Deleting a task deletes its spend record, so task deletion remains a complete deletion.
  • session_id is ON DELETE SET NULL, matching task_step_transitions.session_id and its stated reason: the historical fact must survive deletion of the thing it referred to. A ledger row whose session was pruned still contributes to its task's total.
  • turn_id carries no foreign key, for the same reason.

task_sessions (existing columns, newly written in production)

cost_subcents, tokens_in, tokens_cached_in, tokens_out already exist on task_sessions and are already incremented atomically by the Office subscriber. This feature makes the production ledger writer the sole writer of those four columns (AC-10) and surfaces them on the session model (models.TaskSession) and on dto.TaskSessionDTO, which they are not today.

dto.TaskSessionDTO is the only DTO widened. The repo has a second, dto.TaskSessionSummaryDTO - declared in the same file as "a lightweight version of TaskSessionDTO without snapshot fields" - which backs the GET /tasks/:id/sessions list projection (task/handlers/task_session_summary_projection.go), the boot payload's Sessions array, and the MCP list_task_sessions tool. It is deliberately left unchanged, along with every projection built from it. Widening it would add per-session cost to an agent-facing tool and to a list endpoint this card never scoped, and the deliverable for querying cost is the HTTP read surface below, not a field on a list row. Naming one DTO also settles which existing API responses and frontend types change: exactly those carrying the full TaskSessionDTO.

Three of the four are declared INTEGER; only tokens_cached_in is BIGINT (base_schema.go, and base_migrations.go's migrateSessionsAddCostColumns). On Postgres INTEGER is int4, and that migration's own comment records the consequence: an overflow "would abort the single multi-column UPDATE in IncrementTaskSessionUsage ... silently taking tokens_in/tokens_out/cost_subcents down with it for that session". Under AC-11 that abort would now roll back the ledger insert too, so a derived column would destroy the source of truth. AC-28 widens all three, and the boundary scenarios below deliberately name columns that can actually overflow rather than the one already safe.

API surface

Both routes register beside the existing task routes in internal/task/handlers and are available regardless of features.office.

GET /api/v1/tasks/:id/usage
GET /api/v1/tasks/:id/sessions/:sessionId/usage

Response body (identical shape for both scopes):

{
  "scope": "task",
  "scope_id": "…",
  "tokens_in": 80,
  "tokens_cached_read": 8203943,
  "tokens_cached_write": 264177,
  "tokens_out": 44979,
  "tokens_thought": 0,
  "tokens_total": 8513179,
  "cost_subcents": 79118,
  "event_count": 1,
  "estimated_event_count": 0,
  "unpriced_event_count": 0,
  "output_tokens_complete": true,
  "first_event_at": "2026-08-23T04:15:58Z",
  "last_event_at":  "2026-08-23T04:15:58Z"
}
  • output_tokens_complete is false when any contributing row has an unrecorded output count, marking tokens_out as a lower bound (AC-12, AC-19). It is true for a scope with no rows at all (AC-20).
  • tokens_total is the sum of the stored per-row tokens_total, which the writer computed from the per-kind counts (AC-23). It is never recomputed at read time, and all five summed kinds are reported beside it, so it cannot disagree with them (AC-19).
  • tokens_thought is reported because AC-23 includes it in tokens_total. It is zero for most models and non-zero for reasoning-heavy ones; it is never priced (AC-7).
  • estimated_event_count and unpriced_event_count let a caller distinguish a confident total from a partial one without reading raw rows.
  • Timestamps are null when event_count is 0.
  • Response codes: 200 with zeroed totals for a known scope with no usage (AC-20); 404 when the task or session does not exist; 404 when the session exists but does not belong to the task in the path.

The task-scoped total sums every ledger row for the task, including rows whose session_id has been set to NULL by session deletion. The session-scoped total sums only rows still bound to that session.

No new WebSocket contract. The existing session.prompt_usage frame is unchanged; this feature does not alter what the frontend receives today.

State machine

A usage event has a single, terminal lifecycle. There is no draft, no pending, and no reconciliation state.

From Trigger To Actor
(none) usage event observed, all required identifiers present recorded ledger writer
(none) usage event observed, identifier already present dropped:duplicate ledger writer
(none) usage event observed, task identifier missing dropped:unattributable ledger writer
(none) usage event observed, session identifier missing recorded with null session_id, no rollup ledger writer
(none) usage event observed, turn identifier missing recorded with null turn_id ledger writer
(none) usage event observed, a token or cost value is negative dropped:invalid ledger writer
(none) usage event observed, invalid and session/task mismatched (AC-27, AC-33) dropped:invalid ledger writer
(none) usage event decoded, worker queue full (AC-34) dropped:overflow ledger writer
(none) usage event still buffered when the shutdown drain deadline expires (AC-34) dropped:drain_timeout ledger writer
(none) usage event decoded, shutdown drain already begun (AC-34) dropped:shutdown ledger writer
(none) usage event in flight when the shutdown drain deadline expires (AC-34) dropped:error, transaction rolled back, nothing written after the drain returns ledger writer
(none) usage event observed, no task identifier and a negative value (AC-27) dropped:unattributable ledger writer
(none) usage event observed, usage_event_id missing or empty dropped:invalid ledger writer
(none) bus payload fails to decode dropped:decode_error ledger writer
(none) usage event observed, its session row does not exist recorded with null session_id, no rollup ledger writer
(none) usage event observed, its session row belongs to a different task (AC-33) recorded with null session_id, no rollup ledger writer
(none) usage event redelivered with a randomly minted identifier (AC-22) recorded as a new row, rollup applied ledger writer
(none) insert fails transiently (AC-32) retried unchanged, up to three attempts; then recorded or dropped:error ledger writer
(none) insert fails on the usage_event_id unique constraint (AC-32) dropped:duplicate, transaction rolled back, no rollup increment ledger writer
(none) insert fails on a foreign key (AC-32) retried once with null session_id; then recorded without rollup, or dropped:error ledger writer
(none) insert or rollup fails for any other reason dropped:error, transaction rolled back ledger writer
recorded task deleted row removed by cascade database
recorded session deleted session_id cleared, row retained database

A retry is not a state: only the outcome of the final attempt is a transition, so a retried event still increments exactly one counter.

recorded has no outgoing transition that changes the row's contents (AC-3).

Every terminal transition above increments exactly one AC-27 counter: a recorded row bumps task_usage_events_written_total, and each dropped:<x> bumps task_usage_events_dropped_total under reason=<x>. The stage order is AC-27's - decode, admit, validate, ownership, insert - so the redelivery of an invalid event is counted invalid rather than duplicate, because it never reaches the insert where uniqueness is enforced, and an event that is both invalid and mismatched is counted invalid rather than recorded with a null session. Within validate, the fixed sub-check order makes an event that is both unattributable and negative count unattributable.

Permissions

Usage totals are visible to any caller authorized to read the task itself; they introduce no new authorization tier. Where features.auth is enabled, both routes follow the same workspace-scoped rule as the sibling GET /api/v1/tasks/:id/sessions route. No caller can write to the ledger over HTTP; the only writer is the internal event subscriber.

Failure modes

Condition Behaviour
Decoded payload carries no task id Row dropped, counted unattributable, warning logged. There is nothing to attribute the spend to and task_id is NOT NULL. Distinct from "task row absent" below, where the id is present and the row is gone. Defensive only: no current producer can reach it, since publishPromptUsage sets task_id alongside the session id it has already required to be non-empty.
Decoded payload carries a task id but no session id Row recorded with session_id = NULL and no rollup increment, reaching the same end state as "session row absent" below. The spend is kept; only per-session grouping is lost. Dropping it would contradict the same principle applied to a missing turn_id (AC-17) and to an absent session row. Also unreachable from the current producer, which returns early on an empty session id (AC-24).
Payload's session_id names a session belonging to a different task Row recorded under the payload's task_id with session_id = NULL and no rollup increment, warning logged (AC-33). Same end state as an absent session row, by design.
usage_event_id already present Row dropped, counted as duplicate. Not an error; no rollup increment. Redelivery is expected, not exceptional.
Rollup increment fails after ledger insert Whole transaction rolls back. No row, no increment. A later redelivery of the same event retries cleanly instead of colliding with a half-landed row.
Session row absent when the rollup runs The writer records the row with session_id = NULL and applies no rollup increment; that transaction contains the insert alone, which satisfies AC-11 vacuously. Task attribution and the spend record survive, and only per-session grouping is lost. This is the same end state as a session deleted afterwards (ON DELETE SET NULL), so the two are deliberately indistinguishable. The earlier form of this row - insert with a non-null session_id and let the rollup match zero rows - is impossible: the DSN sets _foreign_keys=on (internal/db/sqlite.go), so that insert is rejected outright rather than committing.
Session deleted between the writer's read and its commit The insert violates the session_id foreign key, classified by IsForeignKeyViolation per AC-32. The writer retries exactly once with session_id = NULL and no rollup, reaching the row above. A second failure is counted error.
Insert fails with a transient error (SQLite SQLITE_BUSY / SQLITE_LOCKED, Postgres serialization failure or deadlock) The whole insert-plus-rollup transaction is re-run in a fresh transaction per attempt, at most three attempts, waiting 50 ms then 100 ms, keeping session_id and the rollup increment (AC-32). Retrying the INSERT statement alone inside the aborted transaction would fail with 25P02 on Postgres instead of retrying. Only exhausted attempts are counted error. This is what makes the two-concurrent-events guarantee in Concurrency hold under real contention rather than only in its absence, and it is why a transient failure must never take the foreign-key path.
Task row absent or deleted mid-write The task_id foreign key rejects the insert. AC-32's single foreign-key retry, which nulls session_id, is rejected for the same reason, so the row is dropped and counted error. A ledger row is never written without a task to attribute it to - and reaching that outcome needs no session-versus-task classifier, only the retry.
Pricing lookup unavailable, times out, or misses Row recorded unpriced with zero cost (AC-8). Never blocks, never drops, never retries inline. "Times out" is bounded by the writer's own 2-second context deadline, not by the lookup's, whose shipped default is 30 seconds (AC-8, AC-26). Unbounded, the single serial worker stalls per event and the queue fills behind it, turning an outage that should produce unpriced rows into dropped:overflow.
Pricing lookup unwired (Office off today) Same as a miss: unpriced. Wiring the lookup outside the Office gate is in scope and its target is named in AC-26; a missing lookup must degrade, not fail.
Provider reports a negative token or cost value Row dropped, counted as invalid, warning logged. Negative values are never written; an append-only ledger has no correcting update.
Database unavailable Insert fails and is not transient-retryable; event dropped and counted error. The agent turn is unaffected: usage recording never blocks or fails an agent run.
Worker queue full when an event is decoded Event abandoned rather than waited on, counted overflow, warning logged with the task id (AC-34). Blocking instead would push backpressure onto the orchestrator's streaming goroutine, which is the one thing this writer may never do.
Process shuts down with events still buffered The writer stops accepting, drains the buffer, and returns when it is empty or after 5 seconds (AC-34). Events still buffered at the deadline are counted error and logged. Bounded on purpose: an unbounded drain would hang shutdown behind a wedged database.
Event arrives after the shutdown drain has begun Refused at the admit stage rather than buffered, counted shutdown, logged; the callback still returns immediately (AC-34). A reason distinct from overflow, so a clean restart is not read as sustained backpressure.
Event in flight when the drain deadline expires Its context is cancelled, the transaction rolls back, it is counted error, and nothing is written after the drain returns (AC-34). Committing into a pool the process is closing is a use-after-close, not a late row.
Insert rejected by the usage_event_id unique constraint Counted duplicate, not error; no retry, no rollup increment (AC-32, AC-14). Classified by a constraint-specific detector, so a unique violation raised by any other constraint takes the error path instead of being silently swallowed as expected behaviour.
Ledger writer stops silently The AC-27 expvar counters task_usage_events_written_total and task_usage_events_dropped_total are exported at /debug/vars, so a stopped writer is observable rather than presenting as "this task was free".
Producer stops publishing, or a turn reports no usage object Not observable through this feature, by construction: the event never reaches the subscriber, so neither AC-27 counter moves and no row is written. AC-24 states the publisher's real precondition and AC-27 scopes its counters to the subscriber. This bounds what the counters prove - they show the writer is alive, never that a task's recorded spend is complete.

The overriding rule: recording usage is never allowed to affect the agent run. Every failure above degrades the record, not the work. AC-34 is what makes that rule structural rather than aspirational: because the bus delivers to subscribers synchronously on the publisher's goroutine, the guarantee holds only while the writer hands off and returns. Every retry, backoff and database wait above happens on the writer's own worker.

Persistence guarantees

  • Ledger rows survive process restart, agent crash, session deletion, and Office being toggled on or off. They are removed only when their task is deleted.
  • Rollup columns survive restart and are recomputable from the ledger at any time, because the ledger is the source of truth and the rollup is purely derived. This holds only once AC-29 removes BackfillSessionTokensCachedIn, which today reassigns tokens_cached_in from office_cost_events on every boot, ungated: with that code in place the guarantee is false in exactly the install this feature targets, and it fails on the second boot rather than the first, so any test that starts the backend once will not see it.
  • Nothing about this feature is cached in memory across a restart. A restart mid-turn loses only usage events not yet observed; already-committed rows are unaffected.
  • No retention policy, TTL, or compaction. The ledger grows monotonically with usage for the life of the task.
  • Usage recorded before this feature ships is not backfilled. Historical turns carry prompt_usage in task_session_turns.metadata; that data is left where it is and is not migrated.

Concurrency

  • Two events for the same session arriving concurrently each take their own transaction. The rollup is applied as a relative increment (SET col = col + ?), never a read-modify-write, so concurrent increments compose and no update is lost.
  • Two deliveries of the same event racing each other, where the producer minted a deterministic identifier: exactly one commits; the other fails the uniq_task_usage_events_usage_event_id constraint at insert and is dropped as a duplicate through AC-32's unique-violation branch (AC-14), not as an error. The rollup is incremented exactly once, because the loser's increment shares the loser's rolled-back transaction. This is the collision the constraint actually has to resolve: no pre-insert read can, since both deliveries may read before either writes (AC-32). Where the producer minted a random identifier (AC-22) the two deliveries carry different keys, so both commit and the rollup is incremented twice. That is the honest outcome of a producer contract this card does not change, not a race this card loses.
  • A read of task totals concurrent with an insert returns either the pre-insert or the post-insert total, never a torn total, because the insert and its rollup share one transaction.
  • Ordering under concurrency is by (occurred_at, id); two rows with an identical occurred_at are ordered by the monotonic id, so the total order is deterministic and stable across repeated reads.

Scenarios

Golden path

  • GIVEN a Kanban-only installation with features.office disabled, WHEN an agent turn completes and reports usage, THEN a task_usage_events row exists for that turn and the session's cost_subcents is greater than zero.
  • GIVEN a recorded usage event with a provider-reported cost, WHEN the row is read, THEN cost_source is provider_reported and cost_subcents equals the provider's amount, and no pricing lookup was performed.
  • GIVEN a task with three recorded usage events, WHEN GET /api/v1/tasks/:id/usage is called, THEN the response event_count is 3 and cost_subcents equals the sum of the three rows.

Cache-heavy turn (the dominant real shape)

  • GIVEN a usage event reporting input_tokens=80, cached_read_tokens=8203943, cached_write_tokens=264177, output_tokens=44979, WHEN the row is recorded, THEN tokens_cached_read and tokens_cached_write are stored separately and the session rollup's tokens_cached_in increases by 8,468,120.

Multiple events per turn

  • GIVEN a turn that emits five usage events over fifteen minutes, WHEN all five are observed, THEN five ledger rows exist sharing one turn_id, and the per-turn total is their sum.
  • GIVEN two ledger rows sharing (session_id, turn_id), WHEN the ledger is written, THEN no uniqueness constraint is violated.
  • GIVEN a usage event carrying no turn identifier, WHEN it is observed, THEN a row is recorded with a null turn_id, the session rollup is incremented, and the row is included in the task total.

Boundaries and ordering

  • GIVEN a session whose cumulative output tokens exceed 2,147,483,647 - a column declared INTEGER before this feature - WHEN a further event is recorded, THEN the ledger row and the rollup increment both commit on Postgres as well as SQLite (AC-28). Run against the pre-widening schema on Postgres this scenario MUST fail; a version of it that passes before the migration is not testing the migration.
  • GIVEN a session whose cumulative cost_subcents exceeds 2,147,483,647 (about $214,748 of recorded spend), WHEN a further event is recorded, THEN both writes commit on Postgres, and the ledger row is not lost to a rollup-column overflow.
  • GIVEN a session whose cumulative cached-input tokens exceed 2,147,483,647, WHEN a further event is recorded, THEN the insert and the rollup both succeed on Postgres as well as SQLite.
  • GIVEN two ledger rows with an identical occurred_at, WHEN the ledger is read twice, THEN both reads return them in the same order.
  • GIVEN two usage events for the same session processed concurrently, WHEN both commit, THEN the session rollup reflects both, with neither increment lost.

Idempotency

  • GIVEN a usage event already recorded whose identifier was derived deterministically from (session_id, execution_id, prompt_generation), WHEN the identical event is redelivered, THEN no second row is written, the session rollup is unchanged, and the drop is counted as a duplicate rather than surfaced as an error.
  • GIVEN a usage event published with no execution_id or no prompt_generation, so usageEventIDFor minted a random identifier, WHEN the identical event is redelivered, THEN a second row IS written with a different identifier and the rollup IS incremented twice (AC-22). This is the documented behaviour of the producer contract, and a test asserting the opposite is asserting something production does not do.
  • GIVEN a usage event arriving with an empty usage_event_id, WHEN it is observed, THEN no row is written and the drop is counted as invalid.
  • GIVEN an event that is both a redelivery and carries a negative token count, WHEN it is observed, THEN the drop is counted as invalid, not duplicate (AC-27).
  • GIVEN two deliveries of the same event bearing the same deterministic identifier, whose inserts are made to overlap so both pass any pre-insert check and race at the constraint, WHEN both are processed, THEN exactly one row exists, the rollup was incremented exactly once, and the loser is counted duplicate and not error (AC-13, AC-14, AC-32, Concurrency). Only a genuine insert-time collision exercises AC-32's unique-violation branch; a redelivery arriving after the first has committed is served by any pre-insert read and proves nothing about it.
  • GIVEN a unique violation raised by a constraint other than uniq_task_usage_events_usage_event_id - on Postgres a 23505 naming a different constraint, on SQLite a UNIQUE constraint failed: message naming a different column - WHEN it is classified, THEN it is NOT counted duplicate and takes AC-32's "anything else" branch. A detector matching a bare 23505, or the bare substring UNIQUE constraint failed, passes the scenario above and fails this one, which is why the prior contract row in docs/specs/tasks/external-id-idempotency/spec.md exists.
  • GIVEN an event that carries no task_id and a negative token count, WHEN it is validated, THEN the drop is counted unattributable, not invalid (AC-27). The validate stage's sub-checks share labels with each other except for this pairing, so it is the only one whose ordering is observable.

Estimation and pricing

  • GIVEN a usage event with no provider-reported cost and a model absent from the pricing catalogue, WHEN the row is recorded, THEN cost_source is unpriced, cost_subcents is 0, and the token counts are still recorded.
  • GIVEN a task whose events include one unpriced row, WHEN task totals are read, THEN unpriced_event_count is 1, so the caller can see the total understates actual spend.
  • GIVEN a usage event whose adapter could not observe output tokens, WHEN task totals are read, THEN output_tokens_complete is false.
  • GIVEN a provider that explicitly reports a cost of zero with its presence flag set, WHEN the row is recorded, THEN cost_source is provider_reported with cost_subcents 0, and no pricing estimate replaces it.

Deletion and durability

  • GIVEN a task with recorded usage, WHEN its session is deleted, THEN the ledger rows remain, their session_id is null, and the task's total is unchanged.
  • GIVEN a task with recorded usage, WHEN the task is deleted, THEN its ledger rows are removed.
  • GIVEN recorded usage in an installation with features.office disabled, WHEN the backend restarts twice, THEN every row and every rollup value is unchanged after each boot, tokens_cached_in included (AC-29). One restart is not enough: the defect this covers is a boot-time reassignment, so a test that starts the backend once passes with the bug present. Run this against a build that still calls BackfillSessionTokensCachedIn and it MUST fail with tokens_cached_in at zero; a version that passes before the removal is not testing the removal.

Failure and degradation

  • GIVEN a usage event with an empty task id, WHEN it is observed, THEN no row is written and the drop is counted as unattributable.
  • GIVEN a usage event reporting a negative output-token count, WHEN it is observed, THEN no row is written and the drop is counted as invalid.
  • GIVEN the rollup increment fails, WHEN the event is processed, THEN no ledger row is committed for that event.
  • GIVEN the pricing lookup is not wired, WHEN an event without a provider-reported cost is observed, THEN the row is recorded unpriced and the agent turn completes normally.
  • GIVEN an insert that fails with a transient contention error twice and succeeds on the third attempt, WHEN the event is processed, THEN the row is recorded with its session_id intact and its rollup applied, and no drop is counted (AC-32).
  • GIVEN an insert that fails with a transient contention error on every attempt, WHEN the event is processed, THEN no row is written, exactly one reason=error drop is counted, and no row was ever written with a nulled session_id (AC-32). A transient failure taking the foreign-key path is the specific regression this covers.
  • GIVEN an insert that fails because the task row is gone, WHEN the event is processed, THEN the single foreign-key retry fails for the same reason, no row is written, and exactly one reason=error drop is counted (AC-32).
  • GIVEN an agent turn that reports no usage object, WHEN the turn completes, THEN no event is published, no row is written, and neither AC-27 counter changes. This is the specified outcome, not a defect: the producer's precondition is stated in AC-24 and the counters are scoped to the subscriber in AC-27.

Read surface

  • GIVEN a task that has never run an agent, WHEN its usage is read, THEN the response is 200 with all totals zero, event_count 0, and null timestamps.
  • GIVEN a session id that belongs to a different task, WHEN it is read under this task's path, THEN the response is 404.

Session attribution and durability under a missing session

  • GIVEN a usage event whose session_id names a session row that does not exist, WHEN it is observed, THEN a ledger row is recorded with a null session_id, no rollup increment is applied, and the row still counts toward the task total.
  • GIVEN a usage event whose session_id names a session belonging to a different task, WHEN it is observed, THEN a row is recorded under the payload's task_id with a null session_id, no rollup increment is applied to either session, and a warning is logged (AC-33).
  • GIVEN a usage event carrying a task id and an empty session id, WHEN it is observed, THEN a row is recorded with a null session_id and no rollup, rather than dropped as unattributable. Only a missing task id is unattributable.

Aggregation and observability

  • GIVEN a row whose tokens_out is not recorded (NULL) and whose other counts are present, WHEN the session rollup and the task totals are read, THEN the NULL contributes zero, neither total is null, and output_tokens_complete is false (AC-12, AC-19). This is the reachable NULL path: the wire carries a presence bit for output tokens only (AC-30).
  • GIVEN a row inserted directly with tokens_cached_read NULL - which the current producer cannot generate, so this is forward-compatibility cover for a transport that later carries presence bits (AC-30) - WHEN the task and session total reads are performed, THEN the NULL contributes zero and neither total is null (AC-12). The scope is the read aggregation only: a row inserted directly runs no rollup increment, so this scenario cannot assert anything about the increment's coalesce, and a test written as though it could would pass against a writer whose increment path has no coalesce at all. The increment side of AC-12 is covered on the reachable NULL path, through tokens_out, in the scenario above.
  • GIVEN a usage event whose payload omits cached_read_tokens entirely, WHEN it is observed, THEN the row records 0, not NULL, because the wire cannot distinguish absence from a measured zero for that field (AC-30).
  • GIVEN a task with rows whose per-kind counts are known and at least one row reporting non-zero tokens_thought, WHEN totals are read, THEN tokens_total equals tokens_in + tokens_cached_read + tokens_cached_write + tokens_out + tokens_thought as reported in that same response, with all five present in the body (AC-19, AC-23). A fixture whose thought tokens are all zero passes even when the response omits the field, so it does not test this.
  • GIVEN a usage event that is dropped as unattributable, WHEN /debug/vars is read, THEN task_usage_events_dropped_total carries a reason=unattributable entry, and a successfully recorded event increments task_usage_events_written_total under its source and provider (AC-27).
  • GIVEN a usage event whose agent_type is claude-acp and whose model is the alias sonnet, WHEN it is observed, THEN the row records provider as anthropic, and the written counter carries provider=anthropic (AC-31). A model-prefix match alone resolves this to the empty string, so a fixture using a full model name would not detect the loss of the CLI tiers.
  • GIVEN an installation with features.office enabled and one usage event, WHEN both the ledger writer and the Office subscriber derive a provider for it, THEN the two values are identical, because both call the helper extracted by AC-31.
  • GIVEN the pricing catalogue is refreshed between two lookups, WHEN an event is priced, THEN the row's rate_*_per_million values and its pricing_catalog_version come from the same snapshot, so the recorded cost is reproducible from the recorded rates (AC-26). A writer that reads rates and version through two separate calls fails this.

Execution model, identity, and ordering

  • GIVEN the ledger writer's database is made to block on insert, WHEN a prompt-usage event is published on the bus, THEN Publish returns without waiting for the write (AC-34). A handler that does its work on the callback goroutine fails this, and it is the only assertion that distinguishes the two designs: both record the same row eventually.
  • GIVEN a worker queue already holding its 1024-event capacity, WHEN another event arrives, THEN it is abandoned rather than waited on and task_usage_events_dropped_total carries a reason=overflow entry (AC-34, AC-27).
  • GIVEN events buffered but not yet written, WHEN the process shuts down, THEN the buffered events are drained and recorded, and shutdown returns within the 5-second deadline even if the database never responds (AC-34). Asserting only that buffered events are recorded would pass against an unbounded drain that hangs shutdown.
  • GIVEN the writer's drain has begun, WHEN the bus publishes another prompt-usage event, THEN the callback returns without blocking, no row is written, and task_usage_events_dropped_total carries a reason=shutdown entry distinct from reason=overflow (AC-34, AC-27).
  • GIVEN an event in flight against a database that never responds, WHEN the 5-second drain deadline expires, THEN the drain returns, that event is counted dropped:error, and no ledger row or rollup increment appears afterwards (AC-34). A writer that lets the in-flight write continue past the deadline passes the buffered-drain scenario above and fails this one.
  • GIVEN a pricing lookup that never returns, WHEN an event is priced, THEN the row is recorded unpriced with zero cost within the writer's 2-second deadline and the queue does not reach its 1024-event capacity (AC-8, AC-26, AC-34). Asserting only that the row is unpriced passes against a writer that waits out the lookup's own 30 seconds.
  • GIVEN two usage events published in a known order, WHEN ListTaskUsageEvents is called, THEN they are returned in that same order, and a repeated call returns an identical sequence (AC-16, AC-34). A goroutine-per-event writer can fail this intermittently, so the fixture SHALL publish enough events to make an ordering inversion observable rather than relying on two.
  • GIVEN a task with no ledger rows, and separately a task id that does not exist, WHEN ListTaskUsageEvents is called for each, THEN both return an empty slice and a nil error (AC-16).
  • GIVEN an insert that fails transiently twice and succeeds on the third attempt, WHEN the event is recorded, THEN the row's occurred_at is the instant acquired before the first attempt, not the third (AC-32, AC-16). A fixture whose retries complete inside the timestamp's resolution cannot observe this and SHALL force a measurable gap.
  • GIVEN a usage event carrying a non-empty model, agent_type and agent_profile_id, WHEN it is recorded, THEN all three appear on the row with those values (AC-35). A writer that defaults all three to '' passes every other criterion, so this is the only assertion that catches it.
  • GIVEN a usage event that is both invalid and whose session belongs to a different task, WHEN it is processed, THEN it is dropped as invalid with no row written and no ownership lookup performed (AC-27, AC-33). An implementation that checks ownership first records a null-session row instead, which is a different observable outcome.

Office coexistence

  • GIVEN an installation with features.office enabled, WHEN an agent turn reports usage, THEN the session rollup reflects that turn exactly once, not twice.
  • GIVEN an installation with features.office enabled, WHEN a turn reports usage, THEN both an office_cost_events row and a task_usage_events row exist for it, and the task_sessions rollup was incremented by the ledger writer only (AC-21). Asserting the rollup value alone is not sufficient: an Office subscriber that still increments and a ledger writer that does not would produce the same number.

Corrections to the task brief

Recorded because they change the contract, and each is backed by evidence taken from current code and a live database (503 sessions / 5,664 turns / 1,962 existing cost rows).

  1. "Nothing persists token usage per task run outside Office" is only half true. internal/orchestrator/event_handlers_streaming.go (persistPromptMetadataOnTurn) already writes a prompt_usage object into task_session_turns.metadata on every turn, unconditionally — 839 turns carry it in the sampled database. What is missing in production is cost and queryability, not token capture. The gap this spec closes is a queryable, priced, append-only record.

  2. (session_id, turn_id) is not unique and cannot be the ledger key. In the sampled ledger, 811 turns have one event, 4 have two, 1 has three, and 1 has five — a single turn spanning 10:33 to 10:48 with five distinct provider-reported costs. This follows from the design: the idempotency key is derived from (session, execution, prompt_generation), and one turn contains many prompt generations. The unique key is usage_event_id (827 rows, 827 distinct values); (session_id, turn_id) is an aggregation index (AC-13, AC-15).

  3. FK ON DELETE CASCADE from task_sessions would defeat the durability requirement. The brief asks for an append-only durable record and for a cascade that erases it when a session is pruned. This spec follows the repository's own append-only-log precedent instead: task_step_transitions.session_id is ON DELETE SET NULL, explicitly because "the historical fact ... must survive that deletion". The live office_cost_events table already holds 110 rows whose session no longer exists. Cascade is kept on task_id, so task deletion is still complete.

  4. tokens_in / tokens_out alone would misreport real turns by orders of magnitude. A sampled turn reports input_tokens=80 against cached_read_tokens=8,203,943 and a provider-reported cost of $7.91. Cache token classes are first-class ledger columns, stored unmerged.

  5. cost_usd is not stored. The established unit across office_cost_events, task_sessions, and the adapter wire type is integer subcents. Introducing a float dollar column would make sums non-associative and would disagree with the columns it must reconcile against.

  6. internal/agent/usage is confirmed unrelated, as the brief states: it probes provider plan quota (Claude OAuth usage endpoint, Codex auth file) and is specified by costs/subscription-usage. It is untouched here.

  7. Scope note, per the brief's instruction to flag growth. The single genuinely larger option is converging this ledger with Office's office_cost_events into one table. That is deliberately not attempted here (see Out of scope); the no-double-count rule (AC-10, and the Office coexistence scenario) bounds the cost of deferring it. Everything else fits the brief's stated scope.

Out of scope

  • Converging with office_cost_events. Office keeps its own ledger, budget policies, and cost explorer unchanged. Migrating Office's eight aggregation queries and 1,962 existing rows onto this table is a follow-up.

    This card does make one narrow amendment to office/costs, and Build must land it in the same change. A spec cannot remove a writer while leaving another document asserting that writer in place, so the passages there stating that the Office subscriber maintains the task_sessions rollup are superseded by AC-10 and AC-21: the sentence that cost_subcents, tokens_in, tokens_out and tokens_cached_in are incrementally updated as cost events arrive; the recordCostEventAndRollup atomicity paragraph; the TaskSession ... running totals line; and the redelivery scenario that asserts a rollup increment. Everything else in that document - Office's own office_cost_events contract, its budgets, and the two-layer cost model this spec reuses - is unchanged and remains frozen.

  • Making generation-less transports deduplicable. usageEventIDFor mints a random identifier when execution_id or prompt_generation is absent, and those rows are not deduplicated (AC-22). Changing that minting rule would change the orchestrator's published contract and a behaviour office/costs documents as intentional, so it is a follow-up rather than part of this card. The consequence is named in AC-22 and in the idempotency scenarios rather than left for a reader to discover.

  • Widening the prompt-usage publisher's precondition. publishPromptUsage returns early when the session id is empty, when the event bus is nil, or when the usage object is nil, so a turn reporting no usage is never published and is invisible to this ledger (AC-24). Publishing those turns would change the orchestrator's published contract, which this card does not touch, and it would mean deciding what a usage-less turn should record - a question with no answer the ledger can supply. The consequence is named in AC-24, in AC-27's scoping of its counters, in the failure-modes table, and in a scenario, rather than left for a reader to discover.

  • A dedicated boot-time reconciliation of the rollup against the ledger. AC-29 removes the one that exists because it destroys data; it does not replace it. The rollup is maintained transactionally on every write (AC-11) and is recomputable from the ledger on demand, so a periodic or startup reconciler has nothing to correct and would be a third writer again.

  • Backfilling or recomputing the rollup for sessions written before AC-21 removes Office's increment. Existing values stay as they are; the ledger is authoritative from its first row forward, and AC-12's equality is a forward guarantee, not a claim about pre-existing rows.

  • Budgets, limits, or enforcement. This ledger records; it does not cap, warn, throttle, or block. Per-period budgets remain an Office feature.

  • A cost UI. No dashboard, chart, or task-detail cost panel. The frontend store already has an unrendered promptUsage slice; this feature does not render it. The deliverable is the HTTP read surface.

  • Backfilling historical usage from task_session_turns.metadata or from office_cost_events.

  • Subscription-plan spend. Flat-fee plans report no per-token cost; those rows record tokens with cost_source = unpriced. Utilization tracking is costs/subscription-usage.

  • Per-turn or per-task cost attribution across sub-agents. Sub-agent turns are attributed to the session that spawned them, as today.

  • Currency other than USD, currency conversion, and any billing, invoicing, or payment integration.

  • Retention, archival, or compaction of ledger rows.

  • Listing raw ledger rows over HTTP, and any pagination or cursor contract for them. The read surface returns totals only; raw rows are reachable from the database and from the internal repository, not from the API.

  • Widening dto.TaskSessionSummaryDTO or any projection built from it - the GET /tasks/:id/sessions list, the boot payload's Sessions array, and the MCP list_task_sessions tool. Only the full dto.TaskSessionDTO gains the four rollup fields. Adding per-session cost to a list row and to an agent-facing tool is a surface decision this card did not scope, and the deliverable for querying cost is the HTTP read surface.

  • Changing when orchestratorSvc.SetModelInfoLookup runs. AC-26 moves the models.dev construction out of the features.office gate and nothing else; that setter, which feeds the orchestrator's context-window fallback, stays gated exactly as today. Ungating context-window fallback for every install is a separate behaviour change with its own review.

  • Tuning the AC-32 backoff or the AC-34 queue and drain constants at runtime. The values are fixed in code: 50 ms/100 ms backoff, a 1024-event buffer, a 5-second drain deadline. Making them configurable adds a configuration surface before any evidence exists that the defaults are wrong.

  • Aggregation surfaces beyond task and session scope — by model, by provider, by agent profile, by period. The columns support them; the routes are not part of this iteration.