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>
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.officetoggle. 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_outis 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 - whileCachedReadTokens,CachedWriteTokensandThoughtTokensare plainint64withomitempty, 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 is0when 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 fortokens_outtoday and for those columns under any such transport. Consequently a NULL intokens_cached_read,tokens_cached_writeortokens_thoughtis 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 throughtokens_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_totalSHALL be computed by the writer astokens_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 asoverflow. -
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 ininternal/events/types.go), published by the orchestrator'spublishPromptUsage(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 whenpayload.Data.Usageis 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 anyinternal/office/**package. It SHALL decode the bus payload into a struct it declares itself, whose JSON tags mirrorlifecycle.SessionPromptUsageEventPayload- exactly asoffice/service.PromptUsageDataalready 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/lifecycleimportsinternal/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.Publishdelivers 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 ispublishPromptUsage, 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 returnnilimmediately. 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_atassignment 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 shapeoffice/service'smaybeAsyncuses for this same subject - SHALL NOT be used: it is unbounded under burst and it makesoccurred_atacquisition 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_timeoutand 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 returnnilimmediately, because the bus delivers on the publisher's goroutine and a closing writer may no more block an agent turn than a busy one.shutdownmarks 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.gooutside theif !cfg.Features.Officeearly return that today enclosesRegisterEventSubscribers- the Office-disabled install is the one this feature exists for - and its drain SHALL be registered through the sameaddCleanupmechanism as the other shutdown steps, at a point after the repository cleanups are registered.runCleanupsruns 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. - The channel SHALL hold 1024 events. A send that would block SHALL be
abandoned rather than waited on, counted
-
AC-35 (Event-driven) WHEN a usage event is recorded, the writer SHALL carry
model,agent_typeandagent_profile_idonto 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 areNOT 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_typeis additionally an input AC-31 derivesproviderfrom, 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 ofinternal/office/service/cost_metrics.go:task_usage_events_written_totalkeyedsource=<cost_source>;provider=<provider>, andtask_usage_events_dropped_totalkeyedreason=<reason>where reason is one ofdecode_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 countsoverflow, and one refused because the shutdown drain has already begun countsshutdown(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 countsinvalid, 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 countsinvalidand 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_idpresent and non-empty, elseinvalid; (2)task_idpresent and non-empty, elseunattributable; (3) no token or cost value negative, elseinvalid. 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_idand carrying a negative token count countsunattributable, notinvalid. 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.
- and only the (2)-versus-(3) pairing is observable, since (1) and (3) share
a label: an event missing
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) / 1000000All four products are summed in 64-bit integer arithmetic before the single division, and that division truncates toward zero. Overflow records an
unpricedrow 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_thoughtis not priced. This reproducescosts.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 = 0andcost_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.goconstructs its HTTP client withTimeout: 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, anddropped:overflowbecomes the steady state exactly whereunpricedrows 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)
estimatedSHALL reflect the usage sample's authority as reported by the adapter and SHALL be recorded independently ofcost_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 - withinternal/office/costsdelegating to it, so both writers compute identically and neither imports the other's tier. The models.dev catalogue lookup SHALL be constructed ininternal/backendapp/main.gooutside thefeatures.officebranch that holds it today (theSetPricingLookup/SetModelInfoLookupwiring) 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 theif cfg.Features.Officeblock:services.Office.SetPricingLookupmust, becauseservices.Officeis nil in a gated install, andorchestratorSvc.SetModelInfoLookupSHALL as well. That second call feeds a wholly unrelated capability - the orchestrator's context-window fallback, per its own doc comment ininternal/orchestrator/model_info.go- which is gated today and which this card does not touch. It shares only thepricingLookupvariable and the enclosingif, 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 whenSetModelInfoLookupruns 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 - mirroringshared.PricingLookupWithVersion(internal/office/shared/interfaces.go), which exists for exactly this reason and whichlookupPricingWithVersiontype-asserts. Two independent calls SHALL NOT be used: a catalogue that refreshes between them writes a newpricing_catalog_versionbeside the previousrate_*_per_millionvalues, 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 asunpriced. That degradation bounds build order; it is not permission to leave the lookup unwired. -
AC-31 (Ubiquitous) The
providervalue SHALL be derived by the writer, because the bus payload does not carry one:lifecycle.SessionPromptUsageEventPayloadhasagent_type,agent_idandmodel, and no provider field. Derivation SHALL be the first non-empty of, in order: the CLI-id mapping applied toagent_type; the same mapping applied toagent_id; a model-prefix match; then''. The CLI mapping isclaude-acptoanthropic,codex-acpandopenai-acptoopenai,geminiandgemini-acptogoogle. 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 reasonproviderFromCLIexists. Both helpers SHALL be extracted tointernal/common/costsalongside the rate struct above -ProviderForModelfrominternal/office/costs/pricing.goandproviderFromCLIfrominternal/office/service/event_subscribers.go- with Office'sresolveProviderdelegating to the extracted versions, so the two writers label the same event identically. Copying the mapping intointernal/task/usageSHALL 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 aNOT NULL DEFAULT ''column and a valid expvar label.
Rollup
-
AC-10 (Ubiquitous) The system SHALL maintain cumulative
tokens_in,tokens_cached_in,tokens_out, andcost_subcentson the owningtask_sessionsrow, and this ledger writer SHALL be the only writer of those columns. -
AC-21 (Event-driven) WHEN
features.officeis enabled, the Office cost subscriber SHALL NOT increment thetask_sessionsrollup columns. The increment is removed fromrecordCostEventAndRollup(internal/office/service/event_subscribers.go), which continues to insert its ownoffice_cost_eventsrow 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 thedevande2eprofiles, 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 setstask_sessions.tokens_cached_intoCOALESCE((SELECT SUM(tokens_cached_in) FROM office_cost_events WHERE session_id = task_sessions.id), 0)for every row matchingEXISTS (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, andinternal/backendapp/storage.gocalls it on every startup with nofeatures.officegate - it sits immediately afteroffice.Provide, which is itself ungated. In the Office-disabled install this feature exists for,office_cost_eventsis empty, so theOR tokens_cached_in <> 0arm matches every session this ledger has just written and assigns zero; a failure there also aborts boot. Removal SHALL also delete thesessionCachedTokenBackfillerinterface, thebackfillSessionCachedTokenshelper, 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 behindfeatures.officeSHALL NOT be used instead: after AC-21 the Office subscriber no longer maintains those columns in any install, sooffice_cost_eventsis 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,_SkipsAlreadyCorrectUntouchedSessionsDeleted with the function. Their subject is gone. _ZeroesRollupWithNoMatchingLedgerRowsin 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,_HandlesValuesBeyondInt32RangeDeleted with the function, with one precondition: _HandlesValuesBeyondInt32Rangeis the only beyond-int32rollup coverage in the suite, so AC-28's equivalent boundary assertion against the ledger writer's rollup SHALL land first.backfillSessionCachedTokenscall site, helper, andsessionCachedTokenBackfillerinterface inbackendapp/storage.go(AC-29)backendapp/storage_cached_tokens_test.go:TestProvideRepositoriesBackfillsSessionCachedTokensDeleted. 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.gociting 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_RollupReconcilesWithCostLedgerDeleted. 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_RollupFailureRollsBackLedgerInsertThenRetrySucceedsRewritten 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 ( IncrementTaskSessionUsageTxand the repository-sharing helpers)office/repository/sqlite/cost_event_tx_atomicity_test.go:TestCostEventTxAtomicity_RealReposShareTransactionRetained, 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 failedwithout naming the constraint, while Postgres names it, so one string match is wrong on one dialect. Classification SHALL use dialect-aware helpers added tointernal/dbbeside the existingIsDuplicateColumnError/IsAlreadyExistsErrorand following their shape (apgconn.PgErrorSQLSTATE check with a SQLite-specific fallback):IsForeignKeyViolation(Postgres23503) andIsTransientError(Postgres40001serialization failure and40P01deadlock; SQLiteSQLITE_BUSYandSQLITE_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 onpgErr.Code == "23505" && pgErr.ConstraintName == "uniq_task_usage_events_usage_event_id"and SQLite on the constraint-bearing messageUNIQUE constraint failed: task_usage_events.usage_event_id, and SHALL surface to its caller as a package-level sentinel error matched witherrors.Is. This is not a new idea in this repo: it is the shape ofisExternalIDUniqueViolation/uniq_tasks_external_id(internal/task/repository/sqlite/task_external_id.go) and ofisUsageEventUniqueViolation/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 bare23505check 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:duplicateis 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
40001and40P01, so re-issuing one statement inside the failed transaction returns25P02 current transaction is abortedinstead 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 itssession_idand 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 aredropped:error. - foreign key - retry exactly once with
session_id = NULLand no rollup increment. This deliberately requires no session-versus-task discrimination: if the violated key wastask_id, the retry is rejected too and the row isdropped:error, which is already the specified outcome for that case. - unique violation on
usage_event_id- no retry, no rollup increment, counteddropped:duplicate(AC-14), neverdropped: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.
- 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
-
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_idwithsession_id = NULLand 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 - andIncrementTaskSessionUsageTxupdates 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_insumscached_read + cached_write. Every nullable token column contributes zero when it is not recorded (NULL): this applies totokens_cached_read,tokens_cached_write,tokens_out, andtokens_thoughtalike, in the rollup increment and in every read aggregation. The zero SHALL be produced by an explicit coalesce, not left to SQL, whereNULL + nisNULLand 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_inandtask_sessions.tokens_outSHALL be widened toBIGINT, so all four rollup columns satisfy the same 64-bit rule as the ledger. The migration is idempotent and dialect-aware in the establishedrunMigrations()shape: on Postgres it alters the column type; on SQLite it is a no-op, because SQLite'sINTEGERis 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_atascending with the monotonicidcolumn as the tiebreak. This pair is a total order: no two rows compare equal, so repeated reads return an identical sequence.idorders 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 thatidreproduces 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,limitof 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 whoseMIN/MAXandSUMare 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_idand 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.officetoggle. - 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_totalin a response SHALL be the sum of the storedtokens_totalcolumn 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_outandtokens_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 maketokens_totalexceed 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_completeSHALL betruein that case: no contributing row has an unrecorded output count, so the zero total is exact rather than a lower bound.estimated_event_countandunpriced_event_countare0and 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_subcentsis hundredths of a cent (int64). A caller renders dollars by dividing by 10,000. This matchesoffice_cost_events.cost_subcentsandtask_sessions.cost_subcents; no new unit is introduced. There is nocost_usdcolumn — 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_outtoday (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'sNULL + n = NULLwould 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-bitINTEGER. 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 PostgresINTEGERisint4(ceiling 2,147,483,647), so a long-running session would abort the insert and take the whole transaction with it. SQLite'sINTEGERis already 64-bit; the widening exists for Postgres parity. occurred_atis 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 letsfirst_event_at/last_event_atmove under contention rather than under real event arrival. It is deliberately NOT taken from the payload'stimestampfield: that value is producer-clock-derived and would let a skewed or replayed producer reorder the ledger.created_atequalsoccurred_atfor 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_versionstarts at1for every row written by this feature. It is bumped only when a column's meaning changes, not when a column is added.- The
rate_*andpricing_catalog_versioncolumns are populated only on themodels_dev_listpath. They are recorded so a computed cost can be re-derived and audited later; without them anunpriced-to-priced catalog change is indistinguishable from a pricing bug. tokens_cached_read/tokens_cached_writeare 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_idcascades fromtasks. Deleting a task deletes its spend record, so task deletion remains a complete deletion.session_idisON DELETE SET NULL, matchingtask_step_transitions.session_idand 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_idcarries 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_completeis false when any contributing row has an unrecorded output count, markingtokens_outas a lower bound (AC-12, AC-19). It istruefor a scope with no rows at all (AC-20).tokens_totalis the sum of the stored per-rowtokens_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_thoughtis reported because AC-23 includes it intokens_total. It is zero for most models and non-zero for reasoning-heavy ones; it is never priced (AC-7).estimated_event_countandunpriced_event_countlet a caller distinguish a confident total from a partial one without reading raw rows.- Timestamps are null when
event_countis 0. - Response codes:
200with zeroed totals for a known scope with no usage (AC-20);404when the task or session does not exist;404when 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 reassignstokens_cached_infromoffice_cost_eventson 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_usageintask_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_idconstraint 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 identicaloccurred_atare ordered by the monotonicid, so the total order is deterministic and stable across repeated reads.
Scenarios
Golden path
- GIVEN a Kanban-only installation with
features.officedisabled, WHEN an agent turn completes and reports usage, THEN atask_usage_eventsrow exists for that turn and the session'scost_subcentsis greater than zero. - GIVEN a recorded usage event with a provider-reported cost, WHEN the
row is read, THEN
cost_sourceisprovider_reportedandcost_subcentsequals the provider's amount, and no pricing lookup was performed. - GIVEN a task with three recorded usage events, WHEN
GET /api/v1/tasks/:id/usageis called, THEN the responseevent_countis 3 andcost_subcentsequals 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, THENtokens_cached_readandtokens_cached_writeare stored separately and the session rollup'stokens_cached_inincreases 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
INTEGERbefore 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_subcentsexceeds 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_idor noprompt_generation, sousageEventIDForminted 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, notduplicate(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
duplicateand noterror(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 a23505naming a different constraint, on SQLite aUNIQUE constraint failed:message naming a different column - WHEN it is classified, THEN it is NOT countedduplicateand takes AC-32's "anything else" branch. A detector matching a bare23505, or the bare substringUNIQUE constraint failed, passes the scenario above and fails this one, which is why the prior contract row indocs/specs/tasks/external-id-idempotency/spec.mdexists. - GIVEN an event that carries no
task_idand a negative token count, WHEN it is validated, THEN the drop is countedunattributable, notinvalid(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_sourceisunpriced,cost_subcentsis 0, and the token counts are still recorded. - GIVEN a task whose events include one
unpricedrow, WHEN task totals are read, THENunpriced_event_countis 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_completeis false. - GIVEN a provider that explicitly reports a cost of zero with its presence
flag set, WHEN the row is recorded, THEN
cost_sourceisprovider_reportedwithcost_subcents0, 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_idis 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.officedisabled, WHEN the backend restarts twice, THEN every row and every rollup value is unchanged after each boot,tokens_cached_inincluded (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 callsBackfillSessionTokensCachedInand it MUST fail withtokens_cached_inat 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
unpricedand 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_idintact 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=errordrop is counted, and no row was ever written with a nulledsession_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=errordrop 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
200with all totals zero,event_count0, 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_idnames a session row that does not exist, WHEN it is observed, THEN a ledger row is recorded with a nullsession_id, no rollup increment is applied, and the row still counts toward the task total. - GIVEN a usage event whose
session_idnames a session belonging to a different task, WHEN it is observed, THEN a row is recorded under the payload'stask_idwith a nullsession_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_idand no rollup, rather than dropped as unattributable. Only a missing task id is unattributable.
Aggregation and observability
- GIVEN a row whose
tokens_outis 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, andoutput_tokens_completeis 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_readNULL - 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, throughtokens_out, in the scenario above. - GIVEN a usage event whose payload omits
cached_read_tokensentirely, WHEN it is observed, THEN the row records0, 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, THENtokens_totalequalstokens_in + tokens_cached_read + tokens_cached_write + tokens_out + tokens_thoughtas 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/varsis read, THENtask_usage_events_dropped_totalcarries areason=unattributableentry, and a successfully recorded event incrementstask_usage_events_written_totalunder itssourceandprovider(AC-27). - GIVEN a usage event whose
agent_typeisclaude-acpand whosemodelis the aliassonnet, WHEN it is observed, THEN the row recordsproviderasanthropic, and the written counter carriesprovider=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.officeenabled 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_millionvalues and itspricing_catalog_versioncome 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
Publishreturns 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_totalcarries areason=overflowentry (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_totalcarries areason=shutdownentry distinct fromreason=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
unpricedwith 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 isunpricedpasses against a writer that waits out the lookup's own 30 seconds. - GIVEN two usage events published in a known order, WHEN
ListTaskUsageEventsis 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
ListTaskUsageEventsis 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_atis 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_typeandagent_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
invalidwith 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.officeenabled, WHEN an agent turn reports usage, THEN the session rollup reflects that turn exactly once, not twice. - GIVEN an installation with
features.officeenabled, WHEN a turn reports usage, THEN both anoffice_cost_eventsrow and atask_usage_eventsrow exist for it, and thetask_sessionsrollup 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).
-
"Nothing persists token usage per task run outside Office" is only half true.
internal/orchestrator/event_handlers_streaming.go(persistPromptMetadataOnTurn) already writes aprompt_usageobject intotask_session_turns.metadataon 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. -
(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 isusage_event_id(827 rows, 827 distinct values);(session_id, turn_id)is an aggregation index (AC-13, AC-15). -
FK ON DELETE CASCADE from task_sessionswould 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_idisON DELETE SET NULL, explicitly because "the historical fact ... must survive that deletion". The liveoffice_cost_eventstable already holds 110 rows whose session no longer exists. Cascade is kept ontask_id, so task deletion is still complete. -
tokens_in/tokens_outalone would misreport real turns by orders of magnitude. A sampled turn reportsinput_tokens=80againstcached_read_tokens=8,203,943and a provider-reported cost of $7.91. Cache token classes are first-class ledger columns, stored unmerged. -
cost_usdis not stored. The established unit acrossoffice_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. -
internal/agent/usageis 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. -
Scope note, per the brief's instruction to flag growth. The single genuinely larger option is converging this ledger with Office's
office_cost_eventsinto 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_sessionsrollup are superseded by AC-10 and AC-21: the sentence thatcost_subcents,tokens_in,tokens_outandtokens_cached_inare incrementally updated as cost events arrive; therecordCostEventAndRollupatomicity paragraph; theTaskSession ... running totalsline; and the redelivery scenario that asserts a rollup increment. Everything else in that document - Office's ownoffice_cost_eventscontract, its budgets, and the two-layer cost model this spec reuses - is unchanged and remains frozen. -
Making generation-less transports deduplicable.
usageEventIDFormints a random identifier whenexecution_idorprompt_generationis 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.
publishPromptUsagereturns 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
promptUsageslice; this feature does not render it. The deliverable is the HTTP read surface. -
Backfilling historical usage from
task_session_turns.metadataor fromoffice_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.TaskSessionSummaryDTOor any projection built from it - theGET /tasks/:id/sessionslist, the boot payload'sSessionsarray, and the MCPlist_task_sessionstool. Only the fulldto.TaskSessionDTOgains 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.SetModelInfoLookupruns. AC-26 moves the models.dev construction out of thefeatures.officegate 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.