Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
157 KiB
status, created, updated, owner
| status | created | updated | owner |
|---|---|---|---|
| draft | 2026-08-12 | 2026-08-14 | nova28 |
Subagent context persistence
Current implementation boundary (2026-08-15). The execution-aware
task_session_subagentsshape is created directly by schema initialization. The predecessor two-column shape and thesubagent_context_execution_sinceactivation key were not shipped on the supported base branch, so the current implementation does not include a compatibility rebuild or shape-probing migration for them. Historical amendment text below records the earlier design discussion; the supported migration work is the historical-message backfill and its two activation keys. See ADR-2026-08-15-subagent-context-schema-boundary.
Amendment log
Amendment 1 (2026-08-14) — row identity gains an execution dimension.
The original contract keyed the upsert on (task_session_id, tool_call_id) and
justified that as "session-scoped by design". Code review of PR
#2617 by a repository maintainer
established that this key is wrong, not merely narrow: tool_call_id is
unique only within one agent execution, so a late frame from a finished
execution can overwrite a different, newer execution's row.
This is not hypothetical, and the amendment does not rest on the reviewer's authority. It was confirmed from the code:
allocateCodexEmittedToolCallIDLocked(internal/agentctl/server/adapter/transport/acp/adapter_tools.go:448-475) de-duplicates emitted tool-call IDs againsta.codexEmittedToolCallIDs, an in-memory map owned by one adapter instance. A new execution constructs a new adapter with an empty map, so a wiretool_call_idthe agent reuses is re-emitted verbatim with no disambiguating suffix. Uniqueness is therefore guaranteed per execution, never per session.- Each execution is a fresh
uuid.New().String()(internal/agent/runtime/lifecycle/manager_execution.go:572,manager_launch.go:526), so executions rotate on every launch and resume within one long-lived session. - The exposure widened when the PR's own fix made late frames from
already-completed executions recorded rather than dropped
(
internal/orchestrator/event_handlers_streaming.go:325-333). Before that fix such a frame was discarded and could not clobber anything; after it, it can. The root defect (no execution identity in the key) predates that fix.
What this amendment changes: agent_execution_id is added to the data model and
to the upsert key (AC-30 … AC-34), and the ACs that named the two-column key are
restated — AC-2a, AC-3, AC-13, AC-14, AC-22, AC-28. Sections carrying an
amended contract are marked (amended by Amendment 1). Everything else in this
document is unchanged and still binding.
Amendment 1, revision 1 (2026-08-14) — failure and detection semantics
Spec Review returned FIX FIRST on Amendment 1 with eleven findings, two independent outside-voice legs concurring on three of them. The key change itself was confirmed sound and is unaltered. What was missing was failure and detection semantics: under a migration runner that swallows errors, the spec did not say what "the migration succeeded" means, what happens when it does not, or what durable datum marks the point from which each guarantee holds.
Revision 1 adds only that, plus five smaller corrections. It changes no existing AC's intent:
- A third activation key,
subagent_context_execution_since(AC-24), becausesubagent_context_capture_sinceis write-once and was written by the pre-amendment build — it marks the start of capture, not the amendment boundary, so it cannot separate a legacy'unknown'row from a live frame that arrived without an execution id. The old claim that it could was false and is removed. - A failure contract for the backfill (AC-24a) — an activation key is never written after a failed backfill.
- A failure contract for the AC-33 rebuild (AC-33a), and AC-33 scoped to both
dialects, because
recreateTablereturns(false, nil)on PostgreSQL by construction; the precedent it cites covers only the SQLite half. - AC-5's exception list enumerated (it claimed AC-9 was its only exception; at
least six other criteria override it), and AC-1's
sourcerequirement reconciled withsource's write-once guarantee. - AC-7 given a detection rule for
total_tokensandduration_ms, which are plainint64and cannot express "not reported" the wayToolUseCount *intcan. - Corrections to AC-8, AC-11, AC-15, AC-21, AC-26, AC-28 and the index rationale.
Passages carrying revision 1 are marked (revision 1).
Amendment 1, revision 2 (2026-08-14) — activation semantics and the two unnamed backfill sources
Spec Review returned FIX FIRST on revision 1 with twelve findings, from a cross-vendor leg (codex) and a cross-model leg (fable) plus the reviewer's own pass. Three of them were found independently by all three voices. No existing AC's intent changes; every fix below is additive.
Revision 1 added failure semantics. What it did not do was say when each activation key gets written on each kind of installation, and it left two backfill column sources unnamed. Revision 2 fixes exactly that, plus six smaller corrections:
- The activation keys are now written off the OBSERVED END STATE, not off
whether a migration fired (AC-24, AC-33b). Revision 1 tied
subagent_context_execution_sinceto AC-33, whose trigger is a table that "predates Amendment 1" — so a fresh database, and any database that predates the feature entirely, never wrote it, and AC-24b then permanently declared those installations un-amended. That was the single worst defect in revision 1: it inverted the detection signal on the majority of future installations. - All four key states are now defined (AC-24c), not just one.
subagent_context_backfill_throughis derived from the source predicate, not from what one attempt inserted (AC-24). Revision 1's tightening was wrong on a retry, whereON CONFLICT DO NOTHINGinserts nothing and the literal reading published the empty string.- The keys carry nanosecond precision and comparisons are defined at equality (AC-24), because they were being compared against a microsecond-precision column.
- Migration ordering is stated (AC-33c): the AC-33 shape change runs before the AC-21 backfill, and the backfill does not run against a stale shape.
- AC-28 defines what to do when
capture_sinceis absent — the state AC-24a creates and AC-20 points at — and AC-28/AC-29 now compare an execution-agnostic distinct-key count, so the extra rows Amendment 1 deliberately creates can no longer bank headroom that hides a stopped writer. - The backfill's
tool_statusandturn_idsources are named (AC-21). Both were verified at the writer:metadata.statusis written frompayload.Data.ToolStatus, and the message'sturn_idcolumn is written from the orchestrator's resolved turn id. - Plus a duplicate-source tiebreak (AC-21),
turn_idNULL fill-forward (AC-2a), the later-reported-zero rule (AC-7a),task_idadded to AC-5's exception table, and a narrowed observability claim in the acceptance-criteria preamble.
Passages carrying revision 2 are marked (revision 2).
Amendment 1, revision 3 (2026-08-14) — the activation keys as instants, and the excess direction
Spec Review returned FIX FIRST on revision 2 with nine findings, from a cross-vendor leg (codex), a cross-model leg (fable) and the reviewer's own pass. No existing AC's intent changes; every fix below is additive.
Revisions 1 and 2 built the activation keys up as a detection story — when each is written, what each absence means. What neither reached is the layer underneath: the keys are strings in a text column, and nothing said how a string becomes a comparison against a timestamp column. That gap is not theoretical. It is live in the shipped pre-amendment writer, and it was measured rather than argued:
- The comparison is broken today, and the boundary snaps to midnight.
task_session_messages.created_atstores2026-07-30 01:39:37.271235+00:00(space separator, microseconds,+00:00offset — sampled from the reference store).subagent_context_capture_sinceis writtentime.Now().UTC().Format(time.RFC3339)andsubagent_context_backfill_throughviarfc3339Timestamp, both producing2026-07-30T01:39:37Z. Compared as text,' '(0x20) sorts before'T'(0x54), so every row on the key's own calendar day reads as "before" the key regardless of its actual time. Measured in SQLite with the real formats: same instant →0; a row 23h59m after the key, same day →0; a key from the previous day →1. Only the date part is doing any work. AC-24 now requires a timestamp comparison, not a string comparison (AC-24e). - Second precision, not nanosecond. Both shipped key writes emit seconds —
time.RFC3339andstrftime('%Y-%m-%dT%H:%M:%SZ', …).rfc3339Timestamp(base_migrations.go) is the helper a builder naturally reaches for and it cannot produce the precision AC-24 mandates. Named so it is not reached for. backfill_through = ''is the fresh-install default and had no defined meaning (AC-24f). The shipped statement alreadyCOALESCE(…, '')s it, AC-24 mandates it, and AC-24's own RFC3339Nano requirement contradicted it.- The
MAXruns as a separate statement after the INSERT — three independentr.migrate.Applycalls, no transaction — so "at that same instant" was asserted and not mechanised. AC-24d now fixes the evaluation order (SR30). - AC-28's excess direction was undefined, and closing it naively would have reintroduced the banking hole revision 2 removed: if excess and shortfall net against each other in one signed difference, banked excess still absorbs missed writes. AC-28 now measures the two directions as separate anti-joins, so neither can cancel the other and AC-29 holds unconditionally.
- Plus: a negative metric no longer blanks a learned measurement (AC-9, the same rule revision 2 gave the reported zero), AC-12 defers to AC-5 after settlement, AC-1a is scoped to commit order, AC-33b's key write is gated on the clauses that are actually schema-observable, and a stale revision-1 row is removed from Failure modes.
Passages carrying revision 3 are marked (revision 3).
Amendment 1, revision 4 (2026-08-14) — the keys as written values, and two queries that did not run
Spec Review returned FIX FIRST on revision 3 with ten findings, from a cross-vendor leg (codex), a cross-model leg (fable) and the reviewer's own pass. No existing AC's intent changes, and revision 4 adds no new acceptance criterion — every fix is a clause inside a criterion that already existed.
Revisions 1–3 built the activation keys up as a detection story (when each is written), then as comparable instants (AC-24e). What none of them reached is the layer underneath both: the keys are values that something has to produce, on installations that already exist. Four of the ten findings are that one theme:
capture_sincewas defined as an instant nothing can observe. AC-24 called it "the instant the backfill statement committed successfully" while AC-24d requires the key write to be atomic with that insert — and the commit instant is knowable only after commit, when an atomic write is no longer possible. A builder had to invent transaction-start, pre-commit sample, or a database clock, and those publish measurably different boundaries. AC-24 point 4 now fixes the sample point and states which direction of error is safe.- Keys the shipped build already wrote had no disposition. Revision 3 measured them at second precision and mandated RFC3339Nano, while AC-24 makes the keys write-once — two requirements that cannot both hold on any already-activated installation. AC-24 point 5 settles it: write-once wins, the key stands, and the residual second is disclosed.
- A partial key pair is already reachable on the installed base. The pre-amendment build
applies the insert and both key writes as three independent
r.migrate.Applycalls and guards re-runs oncapture_sincealone, so a database can holdcapture_sincewithoutbackfill_through— permanently, because the guard then suppresses every retry. AC-24d now requires the guard to be both keys, which repairs the state with machinery the spec already had. - AC-24e mandated a precision it named no mechanism for. The repository's own
timestampColumnrenders SQLite's side asjulianday(), a double whose ULP here is roughly 40 µs — coarser than the microsecond column — and, worse, revision 3's mandated same-day test passes against it. AC-24e now sets the floor at the column's own precision, forbidsjulianday, and Verification adds the one-microsecond boundary test that actually discriminates.
The other six: AC-28's two normative anti-joins did not parse on PostgreSQL (no
derived-table alias) and bypassed the JSON normalization AC-23b exists to mandate, so a
single legacy '' metadata row would disable the health check on either dialect; AC-25's
"unknown execution identity by construction" is falsified by the self-heal state
revision 3 itself introduced, where real UUIDs precede the key; AC-29's MAX(observed_at)
had no window and no empty-result rule; AC-28's excess anti-join and its prose described
different sets; and the observability taxonomy's AC-25 exclusion was never extended to the
consumer clauses revisions 1–3 added.
Passages carrying revision 4 are marked (revision 4).
Why
When an agent fans out to subagents (Claude's Task tool, OpenCode's task,
Cursor's Task, Auggie's sub-agent-<type>:), Kandev already recognises the
fan-out and already receives the child's identity and usage on the completion
frame. It renders them, counts them in memory, and then keeps no queryable
record of them.
The consequence is a specific, measured distortion. In the reference store
(~/.kandev/data/kandev.db, snapshotted 2026-08-12), sessions per card is
exactly 1.0 for every step, while 253 subagent invocations exist across 38
sessions and 127 turns — including 42 turns that fanned out to exactly three
subagents. A step that spawned three parallel reviewers is, in the store,
indistinguishable from one long conversation. Every per-context cost figure is
therefore wrong in the same direction, and the Ops Cost analysis of the Review
step's cache cost had to infer a three-way fan-out by reading the step's
prompt, because nothing in the store recorded it.
What this feature is, and is not
It is persistence and a parent link. The data already arrives on a frame
Kandev parses (internal/agentctl/server/adapter/transport/acp/subagent.go) and
is already carried in a typed payload
(streams.SubagentTaskPayload). This spec adds a durable, queryable row per
subagent invocation, linked to the session and turn that spawned it. It adds no
new instrumentation, no new agent-side capability, and no new wire fields.
It is not a session. A subagent context is deliberately not a
task_sessions row. See The design question, below — that decision is part of
the contract, not an implementation detail.
It is not a UI feature. No frontend surface changes. The existing subagent card already renders from message metadata and continues to do so, unchanged.
It is not cost attribution. Token counts are stored exactly as the agent
reported them, with provenance. No pricing, no dollars, no rollup into
task_sessions.cost_subcents.
One correction to the framing
The originating analysis states the data is "never persisted". That is
imprecise, and the imprecision matters to the design. The normalized payload
is written today — as opaque JSON at
task_session_messages.metadata.normalized.subagent_task (253 such rows exist
in the reference store). What does not exist is a relational fact: no
stable row identity, no first-class parent link, no column a query can group by,
and no independent record — the JSON lives inside a row that is cascade-deleted
with its session.
This matters twice over:
- It makes a backfill possible, which this spec takes (see Backfill).
- It makes the message table an independent expected-count source for writer health (see Writer health).
The design question: a row in task_sessions, or its own table?
Answer: its own table, task_session_subagents. The parent link is a
foreign key from that table to task_sessions, not a parent_session_id column
added to task_sessions.
The reasoning, from the code as it stands:
A subagent has none of the things a task_sessions row asserts. It has no
workspace and no workspace_path; no executor, executor_profile_id, or
task_environment_id; no worktree or base_branch; no agent_profile_id; no
executors_running row; no ACP session Kandev can session/load or resume; no
state machine (CREATED → RUNNING → …); no launch, stop, or recover path.
A row in task_sessions implies all of those, and every one would be a lie.
task_sessions is read by paths that would be actively harmed. Session
rows feed lifecycle recovery (RecoverInstances), the orchestrator, session-tab
listings, WebSocket subscription scoping, the auth scoper
(AuthorizeSessionAccess resolves session → task → workspace → owner), the boot
payload, and cost rollups. Inserting non-launchable rows there forces a new
"is this real" predicate into every one of them, and any path that forgets it
becomes a defect.
It would silently break the very series this feature exists to fix. The
downstream extract's dim_session is SELECT … FROM task_sessions. Adding
subagent rows to that table would change what "sessions per card" means
mid-series, with no schema versioning to signal it — which is precisely the
discontinuity the cross-cutting rule for this work forbids. A separate table is
purely additive: the existing series stays comparable, and the new fact is
opt-in for any consumer that wants it.
The name follows the existing sibling convention: task_session_messages,
task_session_turns, task_session_commits, task_session_subagents.
Sampled input shapes
Every shape below was read from the live store on 2026-08-12, not assumed.
Population by terminal status (n = 253)
| status | n | agent_id | child_session_id | model | total_tokens | tool_use_count | duration_ms |
|---|---|---|---|---|---|---|---|
async_launched |
190 | 190 | 0 | 190 | 0 | 0 | 0 |
completed |
57 | 57 | 0 | 49 | 57 | 57 | 57 |
started |
5 | 0 | 5 | 0 | 0 | 0 | 0 |
| (absent) | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
Three facts drive the whole contract:
- 75% of subagent invocations report no usage at all.
async_launchedis Claude'srun_in_backgrounddispatch; the dispatch is terminal for theTasktool, the subagent runs out-of-band and writes to anoutputFileKandev never reads. These rows will never gain tokens, duration, or a tool count. Storing0for them would fabricate 190 measurements. child_session_idis empty for every Claude row and is populated only by OpenCode (5 rows, all non-terminal). It cannot be the identity.tool_call_idis present on all 253 rows and unique across all 253.agent_idis unique among the 247 rows that carry one, but is absent for three of the four supported agents.tool_call_idis the identity — (amended by Amendment 1) paired with the execution that emitted it. The observed uniqueness across 253 rows is a property of this sample, not a guarantee: see Execution identity, as sampled below for why it does not hold in general.
Other measured facts
- Fan-out distribution per turn: 1→65 turns, 2→12, 3→42, 4→5, 5→2, 8→1.
- Nesting: exactly 1 of 253 rows carries a
parent_tool_call_id— a subagent that spawned a subagent. Rare, real, and must be specified. turn_idandtask_session_idare non-empty on all 253 rows.subagent_typeis missing on 3 rows.- No two subagent rows within a turn share a
created_at(microsecond precision), so ties are not observed — but they are not prevented either. - Observed window: 2026-08-03 15:32:20Z to 2026-08-12 13:24:58Z.
The payload as parsed
streams.SubagentTaskPayload carries Description, Prompt, SubagentType,
Status, AgentID, Model, ChildSessionID, DurationMs, TotalTokens,
ToolUseCount *int, ResultText, IsAsync, OutputFile,
CanReadOutputFile. ToolUseCount is already a pointer specifically so a
reported 0 is distinguishable from "not reported" — that distinction is
load-bearing here and must survive into the column.
Execution identity, as sampled (added by Amendment 1)
Read from the code on 2026-08-14, because the amendment adds a column and a spec may not assume a field exists.
It is already on the frame. lifecycle.AgentStreamEventPayload carries
ExecutionID string \json:"execution_id"` — documented in place as "Lifecycle execution ID; stable across the payload's lifetime" (internal/agent/runtime/lifecycle/event_types.go:231). Both publish sites set it from execution.ID (lifecycle/events.go:226, lifecycle/manager_streaming.go:287), and the orchestrator already reads it on this exact path: recordSubagentContextFromFrameis called from handlers that pass the samepayload, and shouldDropCompletedExecutionStreamEventlogs it asagent_execution_id (internal/orchestrator/event_handlers_streaming.go:855-867`). No new wire
field, no new instrumentation — this amendment stays inside the original
scope note.
Its shape. execution.ID is a uuid.New().String()
(lifecycle/manager_execution.go:572, manager_launch.go:526). A reserved
sentinel that is not a UUID therefore cannot collide with a real value.
It can be empty. shouldDropCompletedExecutionStreamEvent returns early
when payload.ExecutionID == "", so the empty case is reachable in code rather
than merely conceivable. AC-31 defines it rather than leaving it to a builder.
Historical messages do not carry it. task_session_messages
(internal/task/repository/sqlite/base_schema.go:679-694) has columns
id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at, updated_at — and no execution
identity, in the row or in the JSON the backfill reads. The backfill therefore
cannot derive a real value; AC-31's sentinel is a necessity, not a
convenience.
Resume does not split a subagent across executions. A subagent's frames
reaching two different executions would turn execution-scoping into a
row-splitting defect. It cannot happen on the replay path: handleACPUpdate
suppresses ToolCall and ToolCallUpdate (among others) while
isLoadingSession is set during session/load
(adapter/transport/acp/adapter_updates.go:136-160), so replayed history never
reaches the orchestrator. A live execution's own Task tool call dies with its
process and emits nothing into its successor. One subagent invocation therefore
belongs to exactly one execution, which is what makes the three-column key safe.
The existing constraint is an inline table constraint, not an index.
UNIQUE (task_session_id, tool_call_id) is declared inside CREATE TABLE
(base_schema.go:650). SQLite cannot alter a table constraint in place, so
changing the key on a database that already has the table requires a table
rebuild; the repository already owns that pattern (recreateTableNamed,
base_migrations.go:857, :911). AC-33 states the required end state and
leaves the mechanism to Build.
Data model
A new table, created on fresh databases in the schema-init DDL and
introduced on existing databases by an idempotent migration in
runMigrations(), per the repository's schema rules.
| Column | Type | Null? | Meaning |
|---|---|---|---|
id |
TEXT PK | no | Kandev-generated UUID. Not an ordering key. |
task_session_id |
TEXT | no | Parent session. FK → task_sessions(id) ON DELETE CASCADE. |
task_id |
TEXT | no | Denormalised parent task, mirroring task_session_turns. |
turn_id |
TEXT | yes | Turn that spawned it. NULL when no turn was active. |
agent_execution_id |
TEXT | no | (added by Amendment 1) The lifecycle execution that emitted the frame (AgentStreamEventPayload.ExecutionID). Part of the upsert key. Never NULL: the reserved sentinel 'unknown' is stored when no execution identity is available (AC-31). |
tool_call_id |
TEXT | no | Agent-supplied invocation identity. Unique only within one execution — see Constraints and indexes. |
parent_tool_call_id |
TEXT | yes | Set when a subagent spawned this subagent. NULL for top-level. |
subagent_type |
TEXT | yes | e.g. security-reviewer. NULL when not reported. |
description |
TEXT | yes | Short label from rawInput.description. |
agent_id |
TEXT | yes | Provider-supplied child agent id. NULL for providers that omit it. |
child_session_id |
TEXT | yes | OpenCode child ACP session. NULL elsewhere. |
model |
TEXT | yes | Resolved child model, verbatim (e.g. claude-opus-5[1m]). |
agent_status |
TEXT | yes | The agent-reported subagent status, verbatim (completed, async_launched, started, …). NULL when never reported. |
tool_status |
TEXT | yes | The ACP tool-call status of the launching Task call, stored verbatim. Non-terminal values include pending and in_progress; the six terminal values are enumerated in AC-11. NULL when never reported. |
is_async |
INTEGER | no | 1 for a detached dispatch, else 0. Default 0. |
total_tokens |
INTEGER | yes | As reported. NULL when not reported. |
tool_use_count |
INTEGER | yes | As reported. NULL when not reported; 0 only when the agent reported 0. |
duration_ms |
INTEGER | yes | As reported. NULL when not reported. |
source |
TEXT | no | live or backfill. Provenance. Default live. |
observed_at |
TIMESTAMP | no | When Kandev first observed the launch frame. |
settled_at |
TIMESTAMP | yes | When a terminal status was first observed. NULL while outstanding. |
updated_at |
TIMESTAMP | no | Last write to this row. |
Constraints and indexes (amended by Amendment 1):
-
UNIQUE(task_session_id, agent_execution_id, tool_call_id)— the upsert key. Uniqueness is execution-scoped, because that is the widest scope in which atool_call_idis actually unique. The original two-column key assumed session scope; Execution identity, as sampled shows why that assumption is false, and the Amendment log records the review that caught it.The column order is the key's own contract, not incidental:
task_session_idleads so the constraint's backing index is usable as a leftmost-prefix scan for session-scoped reads (AC-13), rather than being usable only for full-key lookups. (revision 1) An earlier draft went further and said this meant the key needed no second index — which contradicted the index list two bullets below, whereINDEX(task_session_id)is still declared. The claim is narrowed to what is true: the key order means no NEW index is required for AC-13's reads. The pre-existing single-column index is retained, in both the fresh-database DDL and the AC-33 rebuild, so that a migrated database and a fresh one have byte-identical index sets (AC-33 clause 4) — dropping a now-redundant index is a separate, behaviour-neutral change with its own measurement, and silently doing it inside a correctness migration would make the AC-33 replay test encode an arbitrary choice.Two rows that differ only in
agent_execution_idare the intended outcome, not a duplicate. They record two genuinely different invocations that happened to reuse an ID, and collapsing them is precisely the data loss this key prevents. -
FK
task_session_id→task_sessions(id)ON DELETE CASCADE. -
agent_execution_idcarries no foreign key, for the same reasonturn_iddoes not. Execution rows live inexecutors_runningand are removed when the executor stops (agent_execution_idwas itself dropped fromtask_sessionsby an earlier migration,base_migrations.go:857), so an FK would delete the measurement when the thing it measured finished — the exact failure theturn_iddecision already rejected. It is a plain value column that happens to be part of the key. -
turn_idcarries no foreign key.task_session_messages.turn_idcascades fromtask_session_turns, which would mean deleting a turn silently deletes the fan-out record for that turn. The measurement must outlive the turn row, soturn_idis a plain nullable column. -
INDEX(task_session_id),INDEX(task_id),INDEX(turn_id).
Deliberately not stored: prompt, result_text, output_file. The prompt
and result are free text already retained on the message row; duplicating them
here doubles the store's largest text payload and adds a second copy of
prompt content to any extract. This table is structure, not content.
Two statuses, deliberately separate
agent_status and tool_status are different measurements from different
layers and are not merged into one column:
agent_statusis the child's own report, read from_meta.claudeCode.toolResponse.status(or the provider's equivalent). Its vocabulary is provider-defined and open —async_launchedis a value only Claude produces, and it means dispatched, will never report usage.tool_statusis the ACP status of the launchingTasktool call, which Kandev already classifies viaisTerminalToolStatus. Its vocabulary is closed and provider-neutral.
Terminality (AC-11) keys off tool_status, because that is the signal the
orchestrator already treats as authoritative everywhere else. agent_status is
stored verbatim and is never used to decide whether the row has settled.
Column-level normalization rules
These apply to every write path, live and backfill:
-
An empty string is stored as NULL, never as
'', for every nullable TEXT column.SubagentTaskPayloaduses""for "absent"; the column uses NULL, so thatCOUNT(model)counts models rather than blanks. -
is_asyncdefaults to0and is set to1only on a positive report; it is never set back to0by a later frame. -
sourcedefaults to'live'. -
(added by Amendment 1)
agent_execution_idis NOT NULL and never empty. It is the reserved literal'unknown'whenever no execution identity is available, and the frame'sExecutionIDotherwise. It is write-once: once a row exists, no later frame changes itsagent_execution_id, because doing so would move the row to a different identity.'unknown'cannot collide with a real value, which is always a UUID.There is exactly one sentinel, deliberately shared by all three sources of "no execution identity" — the backfill, rows written before this amendment, and a live frame whose
ExecutionIDwas empty. A second sentinel would look tidier and would be a bug: the backfill'sON CONFLICT DO NOTHING(AC-22) only suppresses a duplicate when the backfilled row lands on the same key as the row already there, so a backfill sentinel distinct from the pre-amendment live sentinel would insert a second row for a subagent already recorded.(revision 1) What sharing the sentinel does and does not preserve. An earlier draft claimed
observed_atagainstsubagent_context_capture_sinceseparates pre-amendment rows from a live frame that genuinely arrived without an execution id. That claim was false and is withdrawn.subagent_context_capture_sinceis write-once (AC-24) and was written by the build that shipped before this amendment, so it marks the start of capture, not the amendment boundary; after AC-33 re-sentinels every legacy row, a pre-amendment row and a post-amendment empty-ExecutionIDrow are indistinguishable — bothsource = 'live', bothobserved_ataftercapture_since, both'unknown'.What actually separates them is the third activation key
subagent_context_execution_since(AC-24), written by the AC-33 migration. A'unknown'row whoseobserved_atprecedes it is a legacy row that never had an execution dimension; a'unknown'row whoseobserved_atfollows it is a live frame that genuinely arrived without an execution id, and that case also increments theunknown_executioncounter (AC-31).sourcecontinues to separate backfilled from live. Without that third key the distinction is not recoverable from the database at all, and AC-31's whole purpose — making a regression that silently stops propagating execution identity observable — would hold only for the lifetime of one process's expvar counters. -
observed_at,settled_at,updated_atare UTC wall-clock instants from the backend process (time.Now().UTC()), at the same precision as the sibling session tables. -
task_idis required (NOT NULL). WHEN a frame carries no task id, the row is not written and AC-2'sskipped_no_identitypath applies — a session-scoped row with no card cannot be joined to anything the feature exists to answer. -
Reported metrics are stored verbatim within
int64; there is no upper clamp. The largest observedtotal_tokensis 275,717.
Acceptance criteria
EARS-style. (observability claim narrowed in revision 2.) Each criterion is verifiable, on one of three surfaces, named here so a builder does not hunt for a database-observable outcome that a given criterion does not have:
- Data — most criteria: the outcome is a query against
task_session_subagents,task_session_messages, orkandev_meta, or the value of an exported counter. - Process — AC-1a, AC-20, AC-27, AC-33a: the outcome is a
WARNlog line, boot completing rather than aborting, or the enclosing message write surviving. A test asserts these with a log observer or by completing a boot; they are not queries. - Structural — AC-14, AC-19, AC-23a, AC-23b, and (revision 3) AC-24d's
evaluation-order clause, AC-24e, and AC-33b's second paragraph: these constrain how
the implementation is built (a single atomic upsert with no read-then-write;
internal/dberror classification rather than string matching; one unboundedINSERT … SELECT; the dialect-aware JSON helpers; theMAXevaluated no later than the insert; a key parsed and bound as a timestamp rather than compared as a string; an end state observed from the live schema rather than inferred from afiredboolean) rather than what the resulting data looks like. Two implementations can produce identical rows and counters while only one satisfies them, which is the point: each rules out a mechanism that is correct today and fragile under change.
Revision 1 claimed every criterion was "observable from the database or from a logged/exported counter without reading source", which was not true of the eight above — four of them cannot be observed from data at all. AC-25 is deliberately excluded from this taxonomy: it is published consumer semantics, a rule for readers of this store rather than a behaviour of it, and the database can expose provenance but cannot prove a consumer disclosed it.
(revision 4) The same exclusion covers the consumer-facing clauses revisions 1–3
added, which were left inside the taxonomy by oversight. Specifically: AC-24b in its
entirety; the "Meaning, and what a consumer SHALL do" column of AC-24c's four-state
table; and AC-24f's consumer clauses ("a consumer SHALL NOT read it as 'no boundary is
known'", and SHALL NOT treat post-capture_since history as unknown fan-out). Each is a
rule for a reader of this store, and this spec ships no reader — § Out of scope excludes
every read surface — so none of them is verifiable on any of the three surfaces above, and
a builder hunting a database-observable outcome for them is hunting the thing the taxonomy
exists to prevent.
What IS observable, and what Verification accordingly asserts, is the state each
criterion classifies: that the key is present or absent, and that the value written is
the instant or the '' sentinel. The reading laid on that state is contract for
consumers, exactly as AC-25 is. AC-24's own SHALL-write clauses, AC-24d's atomicity and
AC-24f's "SHALL hold the empty string" remain squarely Data and are unaffected.
Capture
AC-1 (amended by Amendment 1) — WHEN the orchestrator observes a tool-call
frame whose normalized kind is subagent_task, and a non-empty session id, task
id and tool_call_id are all present, THEN a task_session_subagents row SHALL
exist for (task_session_id, agent_execution_id, tool_call_id) — where
agent_execution_id is the frame's execution id or 'unknown' per AC-31.
(revision 1) WHEN that frame creates the row, THEN source SHALL be
'live' and observed_at SHALL be Kandev's observation time for that frame.
WHEN that frame merges onto a row that already exists — including a row the
backfill wrote — THEN source and observed_at SHALL both be left unchanged,
because both are write-once (see Persistence guarantees) and record where and
when the row was first established, not what most recently touched it.
This is stated because the two readings are observably different and the earlier
wording implied the wrong one. A live frame merging onto a backfilled row is
reachable: both sides key on the shared 'unknown' sentinel, so they collide.
Relabelling that row 'live' would destroy the provenance split AC-25 requires
consumers to disclose, and moving its observed_at forward would silently
re-date a measurement. The row keeps source = 'backfill' and its original
observed_at; the live frame still fills every other column per AC-4 and AC-5.
AC-1a — observed_at SHALL be the instant Kandev first observed a frame it
could recognise as a subagent, not the instant the subagent began work. For
Claude and OpenCode the initial tool_call carries no rawInput, so
recognition occurs on a later tool_call_update; the column is therefore a
Kandev observation time and SHALL NOT be published as a subagent start time.
(revision 3) Precisely: observed_at is the observation time carried by the frame
that CREATED the row. Where two frames for one key are processed concurrently, that is
the first frame to commit, which under reordering is not necessarily the numerically
earliest observation. observed_at SHALL NOT be recomputed as the minimum of the
observations seen, because AC-1 makes it write-once and a merging frame leaves it
unchanged.
The distinction is worth one sentence because the alternative reading is a test that
fails a correct implementation: "first observed" and "first committed" diverge only under
the concurrency AC-14 and AC-15 already contemplate, and a builder trying to satisfy the
stricter reading would add a LEAST(observed_at, excluded.observed_at) to the conflict
clause — which AC-1 forbids in the same breath as it forbids relabelling source. The
skew is bounded by how far apart two frames for one tool call can commit, which is
irrelevant at the resolution AC-29's MAX(observed_at) and AC-28's :since window
actually use.
AC-2 — WHEN a subagent frame carries an empty tool_call_id, an empty
session id, or an empty task id, THEN the system SHALL NOT write a row, SHALL
increment the skipped_no_identity writer counter, and SHALL NOT fail the
enclosing message write or turn.
(clarified by Amendment 1) This skip set is exactly those three fields and
SHALL NOT be extended to agent_execution_id. An absent execution id is
handled by AC-31's sentinel, not by dropping the row: the other three fields
are what make a row joinable to anything, whereas a row with no execution
identity is still a correct, attributable record of a fan-out that occurred.
Skipping it would manufacture exactly the "absence of a row is not absence of a
subagent" hazard AC-25 exists to prevent.
AC-2a (amended by Amendment 1) — WHEN a (task_session_id, agent_execution_id, tool_call_id) that already has a row is observed again
under a different turn_id, THEN the existing row SHALL be updated per AC-3
and its original turn_id SHALL be preserved. A second row SHALL NOT be
created, and the fan-out SHALL NOT be re-attributed to the later turn. A frame
bearing a different agent_execution_id is a different key and is governed by
AC-32, not by this criterion.
(revision 2) "First observation wins" governs re-attribution between two
different NON-EMPTY turns. It does not freeze a NULL. WHEN the stored turn_id is
NULL and a later frame for the same key carries a non-empty turn id, THEN the stored
turn_id SHALL be set to that value.
The two readings differ observably and the case is ordinary production behaviour, not
a corner. A NULL turn_id records that Kandev could not resolve a turn when it first
recognised the subagent — the orchestrator passes a literal "" at two of its
recordSubagentContextFromFrame call sites, and its turn lookup returns "" whenever
no turn is active or the peek errors. That is an absence of information, not an
assertion that the fan-out belonged to no turn. Freezing it would leave a real
fan-out permanently unattributable to the turn that spawned it, which is the single
measurement this feature exists to produce. Filling it forward is also what AC-4's
rule implies in mirror image: an absent value never blanks a stored one, and a stored
absence never blocks a real value.
AC-3 (amended by Amendment 1) — WHEN a later frame for an
already-recorded (task_session_id, agent_execution_id, tool_call_id) arrives,
THEN the system SHALL update that same row and SHALL NOT create a second row.
AC-4 — WHEN a frame reports a field that the stored row already holds
non-NULL, and the frame's value for that field is absent or empty, THEN the
stored value SHALL be preserved unchanged. (This mirrors applySubagentResult,
which never blanks a value already learned from an earlier frame.)
AC-5 (revision 1) — WHEN a frame reports a non-empty value for a field, THEN that value SHALL replace the stored value, EXCEPT for the fields enumerated below, which are governed by their own criteria and SHALL NOT be replaced by this rule.
The exception list is exhaustive and normative. An earlier draft named AC-9
as the only exception, which was wrong — at least six other criteria already
override AC-5, and AC-5 is what a builder codes the upsert's SET list from. The
fields AC-5 does not govern:
| Field | Governed by | Behaviour |
|---|---|---|
total_tokens, tool_use_count, duration_ms when the reported value is negative (revision 3) |
AC-9 | Read as "not reported": stored NULL on creation, and SHALL NOT blank a stored non-NULL value; anomalous_value++ either way |
total_tokens, duration_ms when the reported value is exactly 0 (revision 2) |
AC-7a + AC-4 | Read as "not reported"; SHALL NOT blank a stored non-NULL value |
turn_id |
AC-2a | First observation wins between two non-empty turns; a stored NULL is filled forward (revision 2) |
tool_status once terminal |
AC-12 | Frozen at the terminal value |
settled_at |
AC-11 | Write-once at first terminal observation |
source |
AC-1, AC-22, Persistence guarantees | Write-once at row creation |
observed_at |
AC-1, AC-1a | Write-once at row creation |
task_id (revision 2) |
AC-1, AC-2 | Write-once at row creation; a denormalised parent identity, never rewritten by a later frame |
agent_execution_id |
AC-30, AC-31, AC-32 | Write-once; it is part of the row's identity |
is_async |
Column-level normalization rules | Sticky: set to 1 on a positive report, never back to 0 |
every other column, on a frame arriving after settled_at is set (revision 3) |
AC-12 | Not an exception: AC-5 continues to govern unchanged. Only tool_status and settled_at freeze at settlement |
Every other column — parent_tool_call_id, subagent_type, description,
agent_id, child_session_id, model, agent_status, a non-terminal
tool_status, and the three metric columns when the reported value is neither
negative nor (for total_tokens and duration_ms) exactly zero (revision 2) —
follows AC-5's replace rule, subject to AC-4 (an absent or empty reported value never
blanks a stored one).
AC-6 — WHEN a frame reports a subagent whose parent_tool_call_id is
non-empty, THEN the row SHALL be written with that parent_tool_call_id, and
SHALL NOT be suppressed for being nested.
Nulls, zeroes, and absence
AC-7 — WHEN an agent does not report total_tokens, tool_use_count, or
duration_ms, THEN the corresponding column SHALL be NULL. The system SHALL NOT
write 0 for an unreported value. (This is not hypothetical: 190 of 253
observed rows report none of the three.)
AC-7a (added by revision 1) — How "did not report" is detected, which differs by field and is NOT uniform:
tool_use_countisToolUseCount *intonstreams.SubagentTaskPayload. A nil pointer is "not reported" (NULL); a non-nil pointer to 0 is a reported zero (AC-8). The distinction is carried by the type.total_tokensandduration_msareTotalTokens int64andDurationMs int64on the same struct — plain values, not pointers, bothjson:"…,omitempty". They cannot express the distinction: an agent that reported 0 and an agent that reported nothing both arrive as0. THEREFORE, for these two fields specifically, a reported value of exactly0SHALL be stored as NULL.
The accepted cost is named rather than hidden: a subagent that genuinely consumed
zero tokens, or completed in under a millisecond, is recorded as unmeasured rather
than as a measured zero. That is the correct direction of error here. 190 of 253
observed invocations are async_launched dispatches that arrive as 0 for all
three fields and are known non-measurements; storing those zeroes would drag every
future token-per-subagent average down by roughly a factor of four (see § The one
shape a builder must not get wrong), whereas losing a true zero costs one row's
worth of precision on a quantity that is zero anyway. A builder SHALL NOT "fix"
this by inferring absence from a sibling field, by reading agent_status, or by
adding a pointer to the payload — the last is new instrumentation and out of scope.
(revision 2) A later frame reporting 0 SHALL NOT blank a stored non-NULL
total_tokens or duration_ms. Because this criterion defines 0 as "not
reported" for these two fields, such a frame is an absent value in AC-4's terms, and
AC-4's rule applies unchanged: an absent value never blanks a value already learned.
This is spelled out because the chain that establishes it runs through three criteria
and AC-5's exception table calls itself exhaustive — so a builder coding the upsert's
SET list from that table alone would write NULL over a real measurement, and AC-12
explicitly contemplates the later frames that would trigger it. The zero rule is now
a row in that table. The outcome: a completed subagent's token count, once learned,
survives every subsequent frame.
AC-8 (citation corrected in revision 1) — WHEN an agent reports
tool_use_count as exactly 0 — that is, SubagentTaskPayload.ToolUseCount is a
non-nil pointer whose value is 0 — THEN the column SHALL be 0, distinguishable
by query from an unreported count.
The earlier wording cited "the payload's ToolUseCountKnown". That field exists,
but on SubagentTaskResult in the ACP adapter, not on
streams.SubagentTaskPayload — which contradicted this document's own
§ The payload as parsed. The observable outcome is unchanged.
AC-9 — WHEN a reported numeric value for total_tokens, tool_use_count,
or duration_ms is negative, THEN the system SHALL store NULL for that field,
SHALL increment the anomalous_value writer counter, and SHALL leave every
other field of the frame unaffected. A negative count is not a measurement.
(revision 3) A negative reported value SHALL NOT blank a stored non-NULL
total_tokens, tool_use_count or duration_ms. Because this criterion's own
premise is that a negative is not a measurement, such a frame is an absent value in
AC-4's terms and AC-4's rule applies unchanged: an absent value never blanks a value
already learned. "Stored NULL" governs the column's value when nothing better is known —
it is not a licence to overwrite something better that is. The counter still increments,
because the frame really did carry a negative and that is what anomalous_value counts.
This is the same rule AC-7a gives the reported zero, and it is spelled out for the same
reason: AC-5's exception table calls itself exhaustive, so a builder coding the upsert's
SET list from the row above would write NULL over a real measurement. The two rows now
read alike. It matters because AC-12 explicitly contemplates frames arriving after
settlement, so the destructive frame is an ordinary late frame rather than a corner case:
without this clause one malformed -1 permanently erases the token count of a completed
subagent, which is the single measurement this feature exists to produce.
AC-10 — WHEN a subagent is never observed reaching a terminal tool_status,
THEN agent_status and tool_status SHALL hold their last observed values (or
NULL if none was ever reported) and settled_at SHALL be NULL. The row SHALL
still exist. (5 observed rows are in exactly this state.)
AC-10a — WHEN a frame reports an agent_status value Kandev does not
recognise, THEN it SHALL be stored verbatim and SHALL NOT affect settled_at.
agent_status has an open, provider-defined vocabulary.
Terminality and ordering
AC-11 (enumeration corrected in revision 1) — WHEN a frame's ACP
tool_status is first classified terminal by isTerminalToolStatus, THEN
settled_at SHALL be set to that observation time and SHALL NOT be modified by
any later frame.
The terminal set is exactly these six values: complete, completed,
success, error, failed, cancelled. An earlier draft enumerated only
completed, failed, cancelled, which was a strict subset of what the cited
function actually accepts — a builder coding that parenthetical would leave a
subagent whose tool status is success, complete, or error permanently
unsettled, with AC-10 then declaring that state correct and hiding the defect.
Where the function and any enumeration in this document disagree, the function
is authoritative; the list is reproduced here so the contract is readable, and
a test SHALL pin the two together so a future edit to one fails the other.
agent_status SHALL NOT be used to decide terminality — in particular,
async_launched is a Claude agent status meaning "dispatched, no usage will
follow", and its own tool call is terminal in the ACP sense, so those rows
settle via tool_status like any other.
AC-12 — WHEN a non-terminal frame arrives for a row whose settled_at is
already set, THEN settled_at SHALL remain set, tool_status SHALL remain the
terminal value, and the frame MAY still fill columns that are currently NULL.
(Out-of-order and duplicate frame delivery is an observed reality on this
stream.)
(revision 3) Settlement freezes exactly two columns — tool_status and
settled_at — and nothing else. Every other column continues to follow AC-5 and AC-4
after settlement, exactly as before it. The "MAY still fill columns that are currently
NULL" clause above is a permission, granted so a builder does not read the freeze as
covering the whole row; it is not an exhaustive statement of what a post-settlement frame
does, and it does not narrow AC-5.
Stated because the three criteria could be read two ways and the readings differ
observably on every settled subagent. AC-5's exception table is normative and lists no
post-settlement entry, so model, agent_status, description and the metric columns
replace as usual; AC-14 independently makes the last committed frame win for exactly
those columns. Read narrowly, though, AC-12 alone says a post-settlement frame may only
fill NULLs — under which a subagent that reports its model after its tool call settled
would keep a NULL model forever. AC-5 and AC-14 govern; this clause removes the
ambiguity rather than changing either.
AC-13 (amended by Amendment 1) — WHEN subagent contexts are read for a
session or a turn, THEN the order SHALL be
observed_at ASC, tool_call_id ASC, agent_execution_id ASC.
The amendment invalidated this criterion's original justification and the
replacement is not cosmetic. AC-13 previously ordered by
observed_at ASC, tool_call_id ASC and defended tool_call_id as a total
tiebreak because it was unique within the session by construction. Under the
three-column key that is no longer true: two rows in one session may now share a
tool_call_id and differ only by execution, so the old ordering is
non-deterministic for exactly the collision this amendment exists to record.
agent_execution_id is appended as the final tiebreak, restoring totality —
the triple (observed_at, tool_call_id, agent_execution_id) is unique within a
session by construction, since the last two are the non-session components of
the UNIQUE key.
id is a generated UUID and SHALL NOT be used as a tiebreak.
Concurrency
AC-14 (amended by Amendment 1) — WHEN two frames for the same
(task_session_id, agent_execution_id, tool_call_id) are processed
concurrently, THEN the write SHALL be a single atomic upsert statement whose
conflict target is those three columns, and exactly one row SHALL result. The
implementation SHALL NOT use a read-then-write sequence for field merging; the
fill-forward and terminality rules are expressed inside the upsert's conflict
clause.
(revision 1) What is and is not order-independent. The write-once and
monotonic guarantees hold regardless of commit order: settled_at (AC-11),
terminal tool_status (AC-12), turn_id (AC-2a), source and observed_at
(AC-1), agent_execution_id (AC-30), sticky is_async, and AC-4's rule that an
absent value never blanks a stored one. Those are the invariants this AC binds.
For the remaining columns — the ones AC-5 governs — the last committed frame
wins, and that outcome is by definition commit-order dependent. An earlier
draft said the result held "regardless of which frame commits first", which was
not achievable and not intended: two frames each reporting a different non-empty
model cannot both win. Last-write-wins is correct here because frames for one
tool call carry progressively more complete data, so the later frame is the
better observation. Stated explicitly so a builder does not attempt to invent an
ordering discipline — a max-of, an observation-time comparison, or a
terminal-frame priority — that this contract does not ask for.
Two callers on the same row remain a single-row outcome, as before. What
changed is which frames are "the same row": two frames sharing a
tool_call_id but differing in agent_execution_id are now two independent
inserts that do not conflict, do not merge, and do not order against each other
(AC-15 governs them).
AC-15 (amended by Amendment 1; corrected in revision 1) — WHEN two frames
whose keys differ in tool_call_id or in agent_execution_id are processed
concurrently (the observed 3-, 4-, 5- and 8-way fan-outs, and the cross-execution
collision of AC-32), THEN both rows SHALL be written, and neither SHALL be
merged into, overwritten by, or lost to the other.
(revision 1) The original wording said "neither SHALL block the other", which
is not satisfiable and is now removed. Kandev's SQLite writer runs on a single
connection (internal/db/sqlite.go, SetMaxOpenConns(1)), so concurrent writes
do serialize by design; blocking is the mechanism, not a defect. The
observable contract is the one stated above: two rows exist afterwards, each
carrying its own frame's data. Nothing in this criterion should be read as a
requirement about write parallelism.
AC-16 — WHEN the parent session row is deleted, THEN its
task_session_subagents rows SHALL be removed by the foreign key cascade,
consistent with task_session_messages and task_session_turns.
Migration
AC-17 — WHEN runMigrations() runs against a database that predates this
feature, THEN the table, its indexes and its constraints SHALL be created, and
existing data SHALL be unaffected.
AC-18 — WHEN runMigrations() is invoked twice in succession against the
same database, THEN the second invocation SHALL succeed and SHALL leave the
schema and the row set unchanged. (Mirrors
task_external_id_migration_test.go: seed a pre-migration row, call
runMigrations twice, assert idempotence.)
AC-19 — WHEN the migration runs against PostgreSQL, THEN it SHALL apply and
replay with the same result as SQLite, using internal/db error classification
rather than local error-string matching, per ADR 0027.
AC-20 — WHEN a statement in this migration fails, THEN — because the
repository's migration runner swallows errors
(internal/db/migratelog.go:33) — the failure SHALL be observable as a WARN
log carrying the migration's name, and the writer's health signals (AC-24,
AC-25) SHALL make the resulting absence of rows detectable rather than silent.
Backfill
AC-21 (amended by revision 1; sources named in revision 2) — WHEN the
migration runs, THEN it SHALL insert one row with source = 'backfill' for every
existing task_session_messages row whose metadata.normalized.kind is
subagent_task and whose task_session_id, task_id and
metadata.tool_call_id are all non-empty.
(revision 2) Every column's source is named below. Revision 1 said each column
derives "from metadata.normalized.subagent_task and metadata.tool_call_id", which
is true of most columns and false of two — and in both cases the two readings a
builder could pick produce observably different databases:
| Target column | Source on task_session_messages |
|---|---|
task_session_id, task_id |
the row's own columns of the same name |
turn_id |
(revision 2) the row's own turn_id column, empty string normalized to NULL |
tool_call_id |
metadata.tool_call_id |
parent_tool_call_id |
metadata.parent_tool_call_id |
agent_status |
metadata.normalized.subagent_task.status |
tool_status |
(revision 2) the top-level metadata.status |
subagent_type, description, agent_id, child_session_id, model, is_async, total_tokens, tool_use_count, duration_ms |
the same-named fields under metadata.normalized.subagent_task |
observed_at |
the row's created_at column |
settled_at |
the row's updated_at column, per AC-23a |
agent_execution_id |
not derivable; the 'unknown' sentinel per AC-31 |
turn_id comes from the message's own column, not from the JSON. There is no
turn_id under either JSON path, so a builder following revision 1 literally finds
nothing and writes NULL for every backfilled row — silently discarding the historical
fan-out-per-turn population, including the 42 three-way turns this document's Why
section uses as the entire justification for the feature. The column is populated
from the same turn id the orchestrator resolves when it writes the message, which is
the same value the live path records.
tool_status comes from the top-level metadata.status, and agent_status from
metadata.normalized.subagent_task.status. They are different fields and must not be
conflated. This is the distinction § Two statuses exists to defend, and on the
backfill path revision 1 left it to a coin flip: the only status the spec named lives
under normalized.subagent_task, so one builder maps that onto tool_status —
which AC-11 flatly forbids, and which would wrongly settle the ~57 completed rows —
while another finds no ACP tool status at all and leaves tool_status and
settled_at NULL for all 253 rows. The correct source is the top-level key, which
the message writer populates from the frame's ACP tool status: the identical value
isTerminalToolStatus classifies on the live path, in the same closed vocabulary
AC-11 enumerates. A builder SHALL NOT substitute agent_status for it under any
circumstances.
AC-21b (added by revision 2) — WHEN two or more qualifying messages share the
same (task_session_id, metadata.tool_call_id), THEN the backfill SHALL derive the
row from the message with the greatest created_at, tiebroken by the greatest
id, and SHALL insert exactly one row for that key.
Nothing constrains task_session_messages to be unique on that pair — tool_call_id
lives inside a JSON document, so no database constraint can express it, and this
document's own Sampled input shapes notes that the observed uniqueness "is a
property of this sample, not a guarantee." AC-22's ON CONFLICT DO NOTHING keeps the
statement from failing but names no winner, and AC-23a mandates a single
INSERT … SELECT with no ORDER BY. Without a named tiebreak, which message supplies
observed_at, settled_at and every derived column is non-deterministic between two
correct implementations and between two replays of the same one. Newest-first is the
right direction because a later message carries the more complete tool-call state;
id is named as the second key only to make the order total, and both are real
columns on the source table.
AC-21a (added by revision 1) — WHEN a qualifying message is missing any of those three identities, THEN the backfill SHALL skip it: no row, and the statement SHALL NOT fail.
The filter is stated explicitly because it is not implied by the column types and
the two readings differ observably. task_session_messages.task_id is declared
TEXT DEFAULT '' while task_session_subagents.task_id is NOT NULL, and ''
satisfies NOT NULL in both dialects — so an unfiltered INSERT … SELECT would
happily write a task_id = '' row rather than failing loudly. Such a row is
exactly what AC-2 refuses on the live path, and for the same reason: a
session-scoped row with no card cannot be joined to anything this feature exists
to answer. The backfill and the live path SHALL agree on which frames are
recordable. These skips are part of AC-28's named allowance on the shortfall
side; they do not increment skipped_no_identity, which is a live-path counter.
AC-22 (amended by Amendment 1) — WHEN the backfill encounters a
(task_session_id, agent_execution_id, tool_call_id) that already has a row,
THEN it SHALL leave the existing row untouched (ON CONFLICT DO NOTHING), so a
live write always wins over a backfilled one and a replay is a no-op. Because
the backfill always writes the 'unknown' sentinel (AC-31) and that sentinel is
shared with pre-amendment live rows, a backfill replay conflicts with its own
prior rows and a backfill never duplicates a subagent already captured live
without an execution id.
WHEN a subagent was captured live with a real execution id and the backfill
later derives a row for the same (task_session_id, tool_call_id), THEN the
keys differ and both rows SHALL exist. This is a named, accepted consequence of
execution-scoping rather than an oversight: the two rows are distinguishable by
source, AC-25 already forbids comparing backfill and live rows
like-for-like, and the alternative — matching a backfilled row onto a live row
by a two-column prefix — would reintroduce exactly the cross-execution
clobber this amendment removes. The backfill is a one-time statement, guarded
(revision 4) by the presence of both backfill keys (AC-24d), so this overlap is
bounded to installations that ran live capture before their backfill.
AC-23 — WHEN the backfill derives a value, THEN AC-7 through AC-9 and the
empty-string-to-NULL rule SHALL apply identically: an absent JSON field becomes
NULL, never 0 and never ''.
AC-23a (source clarified in revision 2) — The backfill SHALL be a single
unbounded INSERT … SELECT with no LIMIT and no batching, and SHALL set
settled_at from the message's updated_at when the derived tool_status is
terminal, else NULL. "The derived tool_status" is the value AC-21 names — the
top-level metadata.status — classified against AC-11's six terminal values. It is
never agent_status. It performs
one full scan of task_session_messages at first boot after upgrade — a real,
accepted one-time cost on a large store (the reference store is 328 MB), taken
once because a partial or resumable backfill would produce exactly the
mid-series discontinuity AC-25 exists to prevent.
AC-23b — WHEN the backfill runs on PostgreSQL, THEN it SHALL use the
dialect-aware JSON helpers already present in base_migrations.go
(jsonColumn, jsonText), which normalise empty and 'null' metadata
documents so a single malformed row cannot abort the statement.
Activation point and provenance
AC-24 (amended by revision 1) — WHEN a migration completes successfully
for the first time on an installation, THEN kandev_meta SHALL hold the
corresponding key below, written once and never overwritten.
"Successfully" means the statement executed without error — not that it was attempted. This has to be said because the repository's migration runner swallows errors (AC-20): "the migration ran" and "the migration worked" are different facts on this codebase, and only the second one may publish an activation point.
| Key | Written by | Value |
|---|---|---|
subagent_context_capture_since |
the AC-21 backfill migration | An instant sampled inside the backfill's transaction, at or after the AC-21 INSERT and before the key writes (point 4 below), from which live capture is authoritative. |
subagent_context_backfill_through |
the AC-21 backfill migration | The created_at of the newest message matching AC-21's predicate at that same instant, or the empty string when no message matched. |
subagent_context_execution_since (added by revision 1) |
the AC-33b end-state check | The instant at which the AC-33 end state was observed to hold, i.e. the point from which agent_execution_id carries a real execution identity on the live path. |
Five things about that table are load-bearing. Points 1–3 (revision 2) were wrong or absent in revision 1; points 4–5 (revision 4) concern the key as a written value — where its instant comes from, and what happens to one that already exists.
1. backfill_through is derived from the SOURCE PREDICATE, not from what one
attempt inserted. Revision 1 defined it as "the newest message the backfill
inserted a row for". That is wrong on a retry, and AC-24a makes retries a required
behaviour: the second run's ON CONFLICT DO NOTHING (AC-22) inserts nothing, so the
literal reading publishes the empty string — which AC-25 tells consumers means
nothing before this point has known fan-out, discarding a backfill that in fact
succeeded. Deriving it from the predicate instead is correct on the first run and on
every retry, and it is self-healing: whatever subset a prior attempt managed to
insert, the key still names the true high-water mark of what the backfill covers.
2. Both keys are written only if the INSERT succeeded, and neither is written if the other cannot be (AC-24d). AC-24a already forbids writing a key after a failed backfill; revision 1 did not say the three writes relate to each other at all.
3. The keys carry nanosecond precision, and the comparison is defined at
equality. All three values SHALL be RFC3339 with nanosecond precision
(time.RFC3339Nano), not second precision. They are compared against observed_at,
which is at the sibling session tables' sub-second precision, so a second-precision
key makes up to a second's worth of rows unclassifiable — and under AC-25 and AC-31
each such row is either a harmless legacy row or a live regression, which is not a
distinction to leave to rounding. The comparison is: a row is at or after a key
when observed_at >= key, and before it when observed_at < key. Equality
therefore lands on the after side, deterministically, in every criterion that
classifies rows by these instants.
(revision 3) Nanosecond precision is a requirement on the value written, and the
shipped pre-amendment writer does not meet it: subagent_context_capture_since is
written time.Now().UTC().Format(time.RFC3339) and subagent_context_backfill_through
via the repository's rfc3339Timestamp helper, whose SQLite form is
strftime('%Y-%m-%dT%H:%M:%SZ', …) — both second precision. rfc3339Timestamp is
the helper a builder reaches for and it cannot express this criterion; a builder SHALL
NOT use it for these keys. Precision alone is not sufficient, however — see AC-24e,
which governs how the value is compared, and without which the precision here is
decoration.
4. capture_since is SAMPLED; it is not read off the commit (revision 4). The
commit instant is unobtainable from inside the transaction AC-24d requires — it is known
only after commit, when an atomic key write is no longer possible — so this criterion
cannot mean the literal commit, and revision 3 left a builder to invent which nearby
instant it did mean. It means this: capture_since SHALL be an instant sampled by the
backend process inside the backfill's transaction, at or after the AC-21
INSERT … SELECT completes and before the key writes. It SHALL NOT be sampled at
transaction start.
The direction matters, and it is the opposite of backfill_through's. capture_since
publishes "from here on, absence of a row is absence of a subagent" (AC-25), so an instant
that is too EARLY over-claims completeness across a window nothing covered, while one that
is too LATE merely leaves a window disclaimed — which AC-25 already handles and keeps the
rows for. Sampling at transaction start therefore over-claims by the entire scan: AC-23a's
full scan of a 328 MB store, so seconds or minutes, not microseconds. Sampling after the
INSERT bounds the over-claim to the commit itself, and that residual is accepted and
disclosed here rather than engineered away, because closing it would mean writing the key
outside the transaction and giving up AC-24d.
5. A key already present is accepted at whatever precision it carries (revision 4).
Point 3's precision requirement binds the key writes this build performs. The
pre-amendment build already activated some installations, writing capture_since via
time.Now().UTC().Format(time.RFC3339) and backfill_through via rfc3339Timestamp —
both second precision, both measured. Those keys SHALL NOT be rewritten, repaired, or
"upgraded": they are write-once (§ Persistence guarantees), and moving a published
boundary is a worse failure than the precision it would fix, because a consumer that
already read the old value has no way to learn it changed. The residual on such an
installation is up to one second of boundary ambiguity, in the direction AC-25 already
governs, and it is a disclosed limitation, not a defect to close. Verification scopes
the precision assertion to keys this build writes, for the same reason.
The four combinations these keys can present in are enumerated in AC-24c. The empty
value backfill_through takes when nothing matched is governed by AC-24f.
AC-24a (added by revision 1) — WHEN the AC-21 backfill statement fails,
THEN NEITHER subagent_context_capture_since NOR
subagent_context_backfill_through SHALL be written, the failure SHALL be
logged at WARN per AC-20, and the backfill SHALL be attempted again on the
next boot.
Writing an activation key after a failed backfill is specifically forbidden, and
it is forbidden for two compounding reasons rather than one. First, the key
publishes a recovery boundary that never happened: AC-25 tells consumers that
activity after subagent_context_backfill_through has known fan-out, so the key
would assert coverage over exactly the window it exists to disclaim. Second, the
backfill keys are also the guard that makes the backfill one-time (AC-23a) — writing
one after a failure permanently suppresses the retry, so the gap can never close on its
own. The runner's swallow-plus-WARN contract means a failure does not abort boot; it
does not mean the failure is invisible to the key write.
(revision 4) This is why AC-24d makes the guard both keys rather than
capture_since alone. Under the single-key guard the hazard above is not merely
hypothetical: the pre-amendment build writes capture_since in its own statement, so a
backfill_through write that fails afterwards leaves the retry suppressed forever by a key
that was written honestly. The two-key guard closes that without weakening this criterion —
neither key is written after a failed insert, and a pair that is nonetheless incomplete is
completed on the next boot.
AC-24b (added by revision 1) — WHEN subagent_context_execution_since is
absent while subagent_context_capture_since is present, THEN a consumer SHALL
read that as "this installation captures subagent contexts but has not
successfully applied Amendment 1", and SHALL NOT interpret any
agent_execution_id value on that installation as an execution identity. This is
the durable, queryable signal that the amended shape is not in place (AC-33a).
(revision 2) Read precisely, the key's absence means the AC-33 end state was not observed to hold — not merely that a rebuild did not fire. Those were conflated in revision 1, and the conflation is what made this criterion fire on healthy installations: a fresh database reaches the end state with no rebuild at all, so under the old reading it was permanently and wrongly declared un-amended. AC-33b now writes the key off the observed end state, which is what makes this criterion's absence-reading true.
AC-24c (added by revision 2) — All four states of the two consumer-facing
keys are defined, because AC-24a and AC-33a can each produce a state revision 1 did
not describe. WHEN a consumer reads kandev_meta, THEN:
capture_since |
execution_since |
Meaning, and what a consumer SHALL do |
|---|---|---|
| present | present | Fully activated. AC-25's disclosure rules apply as written. |
| present | absent | Capture works; Amendment 1 has not successfully applied (AC-24b). No agent_execution_id on this installation may be read as an execution identity. |
| absent | present | The execution dimension is trustworthy, but the backfill has not succeeded (AC-24a) and no coverage boundary exists. The consumer SHALL treat all history as unknown fan-out, and the AC-28 health comparison SHALL NOT be run (see AC-28's absent-key branch). The backfill retries on the next boot. |
| absent | absent | The feature has not successfully activated on this installation. Rows MAY exist from live capture, and each one is a valid measurement, but no absence is evidence of anything and no boundary may be published. |
A consumer SHALL NOT infer any of these states from the presence or absence of rows. The keys are the signal precisely because rows can exist in all four states.
(revision 3) subagent_context_backfill_through is deliberately not a dimension of
this table. AC-24d binds it to capture_since — both are written or neither is — so it
adds no state beyond the two columns above. (revision 4) That binding holds for pairs
this build writes; a partial pair predating it is reachable on the installed base, and
AC-24d repairs it on the next boot rather than adding it here as a fifth state a
consumer would have to branch on. A consumer therefore still sees only these four. What it does add is a value dimension: when
present it is either an instant or the empty-string sentinel, and those two readings are
governed by AC-24f. A consumer that has resolved a row of this table still has to branch
on that.
AC-24d (added by revision 2) — WHEN the backfill migration runs, THEN the
AC-21 INSERT … SELECT and both of its kandev_meta key writes SHALL be atomic
with respect to each other: either the insert commits and both keys are written, or
neither key is written. A state in which rows were inserted but only one key exists
SHALL NOT be an allowed outcome.
This is stated because revision 1 required each key not to be written after a
failure (AC-24a) but never related the three writes to one another, and the partial
state is the one that corrupts a published boundary rather than merely delaying it.
Note that atomicity alone would not have been sufficient: even with all three writes
in one transaction, a retry recomputes backfill_through, which is why AC-24 also
derives it from the source predicate rather than from the rows one attempt inserted.
(revision 3) The MAX that derives backfill_through SHALL be evaluated over the
same snapshot as the AC-21 INSERT … SELECT, or before it — never after it. Atomicity
is not the same guarantee as a shared snapshot: on PostgreSQL's default READ COMMITTED
each statement in a transaction takes its own snapshot, so a message committed between
the insert and the MAX advances the published high-water mark past a row the backfill
never inserted. The key would then assert coverage over exactly the window it exists to
disclaim, and AC-25 tells consumers that activity after it has known fan-out. The
asymmetry is what makes the rule cheap: evaluating the MAX first can only
under-claim — the key names an instant the backfill definitely covered, and AC-25's
guarantee stays true — whereas evaluating it afterwards can over-claim, which is
unrecoverable because the key is write-once. The shipped pre-amendment writer takes it
afterwards, as a third independent r.migrate.Apply with no enclosing transaction, so
this is a change to real behaviour and not a restatement.
(revision 4) A partial key pair that predates this rule is REPAIRED by the activation
guard, not left standing. This criterion forbids the state going forward, but it says
nothing about the installed base — and the pre-amendment build makes the state reachable
there today. It applies the INSERT and the two key writes as three independent
r.migrate.Apply calls with no enclosing transaction, and it guards re-runs on
subagent_context_capture_since alone (subagentContextBackfillActivated,
base_migrations.go). So an installation can already hold exactly one of the two, and the
capture_since-only case is permanent: the guard suppresses the retry on every subsequent
boot and backfill_through is never written. THEREFORE the backfill's activation guard
SHALL be "both backfill keys present", never capture_since present.
That one change repairs both partial states and introduces no new mechanism, because every
part of the repair is already required elsewhere: the guard does not skip; the
INSERT … SELECT is a no-op via AC-22's ON CONFLICT DO NOTHING; backfill_through is
recomputed from AC-21's source predicate rather than from what this attempt inserted
(AC-24 point 1), so it lands on the true high-water mark; and whichever key is already
present is preserved by its own ON CONFLICT DO NOTHING, keeping AC-24's write-once. The
cost is one extra full scan on one boot per affected installation, after which both keys
are present and the guard skips exactly as before. Note this is the same self-healing
property AC-24 point 1 was introduced for — it is being applied to a second state rather
than added as a new behaviour.
AC-24e (added by revision 3) — Every comparison between an activation key and a timestamp column SHALL be a timestamp comparison, not a string comparison. The key SHALL be parsed and bound as a timestamp value of the column's type, or both sides SHALL be normalized to one lexical form before comparing. A raw key string SHALL NOT be compared against a timestamp column's stored form. This governs every criterion and query that classifies rows against these instants — AC-25, AC-28, AC-31, and the AC-28 health queries as written.
This is a measured defect in the shipped writer, not a hypothetical. The two sides are in different formats and always have been:
| Side | Real value | Produced by |
|---|---|---|
Column (observed_at, created_at) |
2026-07-30 01:39:37.271235+00:00 |
the driver's TIMESTAMP storage form — space separator, microseconds, +00:00 offset |
Key (kandev_meta.value) |
2026-07-30T01:39:37Z |
time.RFC3339 / strftime('%Y-%m-%dT%H:%M:%SZ', …) — T separator, Z suffix |
Compared as text, the two agree on the first ten characters and then diverge at the
separator, where ' ' (0x20) sorts before 'T' (0x54). The boundary therefore snaps
to midnight of the key's date: every row on that calendar day reads as before the key
whatever its actual time. Evaluated in SQLite against the real formats:
SELECT '2026-07-30 01:39:37.271235+00:00' >= '2026-07-30T01:39:37Z'; -- 0 (same instant)
SELECT '2026-07-30 23:59:59.999999+00:00' >= '2026-07-30T00:00:00Z'; -- 0 (23h59m AFTER)
SELECT '2026-07-30 01:39:37.271235+00:00' >= '2026-07-29T23:59:59Z'; -- 1 (previous day)
Only the date part does any work. That is up to a full day of rows landing on the wrong
side of a boundary whose entire purpose is to separate a harmless legacy row from a live
regression (AC-25, AC-31) — and it silently truncates AC-28's :since window by up to a
day, in the direction that hides missing rows. The nanosecond requirement in AC-24
addresses rounding at the boundary and is necessary, but it is not sufficient and does
not touch this: two values can both carry nanoseconds and still compare wrongly if one is
a string in the other's non-format.
(revision 4) What the comparison has to ACHIEVE, and the helper that does not achieve it. Revision 3 said what the comparison must not be — a raw string comparison — without saying what it must reach, which left the obvious mechanism looking compliant. The comparison SHALL resolve at no coarser than the compared column's own storage precision, which is microseconds, and SHALL be monotonic across the whole comparable range.
The repository's existing helper does not meet that bar, and it is precisely the one a
builder reaches for here, exactly as rfc3339Timestamp was on the write side:
timestampColumn (base_migrations.go) renders SQLite's side as julianday(), a
double-precision day count whose ULP at this epoch is roughly 40 µs, so two values tens of
microseconds apart compare equal. A builder SHALL NOT use julianday for these
comparisons. Normalizing both sides to one fixed-width lexical UTC form, or binding the key
as a native timestamp on PostgreSQL, both clear the bar; beyond that the mechanism is
Build's. Verification requires a one-microsecond boundary assertion specifically because
the same-day test above passes against a julianday implementation, and a criterion whose
own test cannot fail the mechanism it forbids is not yet a criterion.
AC-24's RFC3339Nano requirement is a FLOOR, not a claim that the extra digits are reachable. A key carrying sub-microsecond digits never compares exactly equal to a microsecond column value, so AC-24's "equality lands on the after side" convention applies vacuously to such a key. That is harmless — the boundary is still exact to the microsecond, which is the precision the data actually has — and it is not a licence to write a coarser key.
AC-24f (added by revision 3) — WHEN no message matched AC-21's predicate, THEN
subagent_context_backfill_through SHALL hold the empty string, which is a sentinel and
not a timestamp. It is explicitly exempt from AC-24's RFC3339Nano requirement, SHALL
NOT be parsed or compared as an instant under AC-24e, and SHALL be read by consumers as:
the backfill matched nothing, so no pre-capture history is claimed as covered and none
needs to be — there was none to recover. A consumer SHALL NOT read it as "no boundary is
known" and SHALL NOT, on the strength of it, treat post-capture_since history as
unknown fan-out.
This has to be stated because it is the state of every fresh installation — the
majority of installations this feature will ever run on — and revision 2 left it to a
coin flip. AC-24 mandates the empty string and the shipped statement already produces it
via COALESCE(…, ''), while AC-24's own next paragraph requires all three values to be
RFC3339Nano, which '' is not. AC-25's consumer rule is keyed on "activity that predates
subagent_context_backfill_through" and had no branch for it: compared lexically every
timestamp exceeds '', so one consumer concludes all history has known fan-out and
another concludes no boundary exists and distrusts everything. Those are opposite
readings of the same store. capture_since remains the boundary that matters on such an
installation, exactly as AC-25 already says.
AC-25 — The published contract for consumers SHALL be: a session whose
activity predates subagent_context_backfill_through has unknown fan-out,
not zero fan-out; rows with source = 'backfill' are limited to what survived
in message metadata and SHALL NOT be compared like-for-like against live
rows without disclosing the provenance split. Absence of a row is never
evidence of absence of a subagent before subagent_context_capture_since.
(extended by Amendment 1) The same rule governs the execution dimension: a row
whose agent_execution_id is 'unknown' asserts that its execution is
unrecorded, never that it is shared. Consumers SHALL NOT group or count
distinct executions by that column without excluding the sentinel, and SHALL NOT
read two 'unknown' rows as belonging to one execution.
(revision 1) The execution dimension has its own activation point, and it is
not subagent_context_capture_since. A row whose observed_at precedes
subagent_context_execution_since (AC-24) predates Amendment 1 and has unknown
execution identity by construction; a 'unknown' row after that instant is a
live frame that genuinely arrived without one. Consumers comparing execution
counts across that boundary SHALL disclose the split, exactly as AC-25 already
requires for the backfill/live split. Where
subagent_context_execution_since is absent entirely, AC-24b applies and no
agent_execution_id on that installation may be read as an execution identity.
(revision 4) "By construction" above governs the 'unknown' population only. A
NON-SENTINEL agent_execution_id is always a real execution identity, whichever side of
subagent_context_execution_since its row falls on, and a consumer SHALL NOT discard or
distrust one for preceding the key.
The qualification is required because revision 3's own AC-33b introduced a state that falsifies the unqualified reading. A boot can commit the AC-33 rebuild and then fail to write the key — the two are not bound the way AC-24d binds the backfill pair, and the runner swallows the error (AC-20) — so every row written across the remainder of that boot's uptime carries a genuine UUID while preceding the key the next boot writes. Under the unqualified reading a consumer would treat those real execution ids as unknown; under revision 2's rule two paragraphs above ("SHALL NOT discard, distrust, or exclude rows merely for preceding a key") it would keep them. Two competent consumers, opposite queries, over exactly the population the key exists to classify.
This is the same completeness-not-validity distinction revision 2 already drew, applied to
the execution dimension: subagent_context_execution_since bounds when the 'unknown'
sentinel stops meaning "this installation had no execution dimension", and nothing more.
It never bounds the validity of a value that is present. The self-heal window therefore
under-claims — some genuinely-identified rows sit before the key — in exactly the direction
AC-24d's MAX-first rule chooses for the same reason.
(revision 2) These keys bound COMPLETENESS, never VALIDITY. A row observed before an activation key is still a real, correct measurement of a fan-out that happened; what the key withholds is the guarantee that every such fan-out has a row. Consumers SHALL NOT discard, distrust, or exclude rows merely for preceding a key.
This has to be said because AC-24a makes a window of exactly that shape reachable
and the earlier wording invited the wrong reading. When a backfill fails and retries
on a later boot, subagent_context_capture_since is stamped at the instant the
retry succeeded, so every row the live path captured between the failed boot and
that retry precedes the key — correct rows, on the wrong side of a boundary that
means "coverage begins here." Under the withheld-completeness reading they are kept
and counted, and only completeness is disclaimed for that window. Under the
discard-them reading a working writer's output is thrown away because a migration
was slow to succeed. The first reading is the contract.
Writer health
AC-26 (amended by Amendment 1; unit defined in revision 1) — The system
SHALL expose counters, under the existing expvar convention used by routing_*
and subproc_*, for: attempted, persisted, skipped_no_identity,
anomalous_value, failed, and unknown_execution (AC-31).
The counting unit is one observed frame, not one row and not one SQL
statement. Every recognized subagent_task frame that reaches the writer
increments exactly one of attempted's outcomes, and a frame that merges onto an
existing row increments persisted exactly as a frame that creates one does.
Stated because the unit is otherwise ambiguous and the two readings differ by
roughly the fan-out's frame count:
attempted= frames that reached the writer carrying a recognized payload.skipped_no_identity= of those, the ones rejected by AC-2 before any SQL ran.persisted= of those, the ones whose upsert committed. This counts writes, not rows, sopersistedis expected to exceedCOUNT(*)on the table by a wide margin (each subagent is typically observed on several frames), and the two SHALL NOT be reconciled against each other. AC-28's row-count comparison is the reconciliation; the counters are not.failed= of those, the ones whose upsert returned an error (AC-27).anomalous_value= frames carrying at least one negative metric (AC-9). A qualifier, not an outcome. (revision 2) It is counted only for frames that reached the write, i.e. not for frames AC-2 skipped: AC-2 rejects on identity before any metric is examined, so a skipped frame incrementsskipped_no_identityand nothing else, whatever its payload contained.unknown_execution= live frames written with the'unknown'sentinel (AC-31). A qualifier, not an outcome.
So attempted = skipped_no_identity + persisted + failed, exactly, while
anomalous_value and unknown_execution overlap the others freely.
AC-27 — WHEN a persist attempt fails, THEN the system SHALL log at WARN
with the session id and tool-call id, SHALL increment the failed counter, and
SHALL NOT fail the enclosing message write, the turn, or the agent stream.
Telemetry never breaks the product path.
AC-28 — The expected-versus-observed health check SHALL be answerable from the database alone, with no counter access, as:
(queries corrected in revision 1 — they now carry the activation predicate the prose always required, and the PostgreSQL form)
(revision 3) Which formulation is normative. This criterion carries three renderings of the same idea, accumulated across revisions, and only one of them is the contract. The two directed anti-joins in Two directions, never netted are normative — those are the queries to implement, test and alarm on. The two
COUNTpairs immediately below establish what is being compared (distinct(session, tool_call_id)keys on each side, scoped by:since) and are retained as the explanation; the filtered expected count later in this criterion shows which messages qualify, and its predicate is carried verbatim into the shortfall anti-join. A builder implementing theCOUNTpair as the check has implemented a signed difference, which revision 3 exists to replace — see the banking argument below. Stated explicitly because a criterion that shows three queries and marks none of them authoritative is exactly the defect the stale Failure modes row was.(revision 4) Two consequences of that hierarchy. First, only the normative anti-joins carry the
msgCTE; the explanatory queries below are written unwrapped for readability. That is a presentation choice, not an exemption — any query from this criterion that is actually EXECUTED SHALL normalizemetadatathe way the anti-joins do, because a''metadata row raises on both dialects (see Two directions, never netted). Second, what Build ships for this criterion is the two anti-joins as executable queries exercised by tests on both dialects — that is the surface AC-28a's "SHALL NOT be run" branch and AC-24c's key-state assertions act on. No production endpoint, scheduled check or alerting integration is in scope; wiring a monitor to the shortfall query is a deployment concern, and § Out of scope already excludes a read API.
-- SQLite. :since is kandev_meta.subagent_context_capture_since.
-- Both sides count DISTINCT (session, tool_call_id) keys (revision 2).
SELECT COUNT(DISTINCT task_session_id || ':' || json_extract(metadata,'$.tool_call_id'))
FROM task_session_messages
WHERE json_extract(metadata,'$.normalized.kind') = 'subagent_task'
AND created_at >= :since;
-- versus (revision 2: DISTINCT on the execution-agnostic key, see below)
SELECT COUNT(DISTINCT task_session_id || ':' || tool_call_id)
FROM task_session_subagents
WHERE observed_at >= :since;
-- PostgreSQL: same two counts, dialect-appropriate JSON extraction.
SELECT COUNT(DISTINCT task_session_id || ':' || (metadata::jsonb #>> '{tool_call_id}'))
FROM task_session_messages
WHERE metadata::jsonb #>> '{normalized,kind}' = 'subagent_task'
AND created_at >= :since;
-- versus (revision 2: DISTINCT on the execution-agnostic key, see below)
SELECT COUNT(DISTINCT task_session_id || ':' || tool_call_id)
FROM task_session_subagents
WHERE observed_at >= :since;
The :since predicate is part of the contract, not decoration. Without it the
comparison spans history the backfill could not see — sessions deleted before the
upgrade left no message row and no subagent row, but sessions deleted between
the two counts, and messages whose sessions cascaded away, skew the sides
differently — so an unscoped comparison drifts permanently and reads as writer
failure. AC-19's dialect-parity expectation applies to this check as much as to
the migration: json_extract does not exist on PostgreSQL, so a SQLite-only
health query is a health query that cannot run on half the supported deployments.
(amended by Amendment 1; comparison corrected in revision 2; direction split in
revision 3) These two counts SHALL agree for activity at or after
subagent_context_capture_since, up to the named allowances below.
(revision 3) The two directions SHALL be measured separately, as directed set differences, and SHALL NOT be reduced to one signed number. The actionable signal is the SHORTFALL set — expected keys with no matching row — and it is the only one that alarms. The EXCESS set is attributed and does not alarm. See Two directions, never netted below for the queries and the reason.
Why the observed side counts DISTINCT keys rather than rows. Tool-call
messages are looked up by (session_id, tool_call_id) only
(getToolCallMessageWithRetry, internal/task/service/service_messages.go:483), so
a cross-execution collision updates one message row, while this table correctly holds
one row per execution (AC-32). Counting rows therefore makes the two sides
structurally incomparable, and Amendment 1 is what introduced the asymmetry.
Revision 1 handled that by relaxing the check to
COUNT(task_session_subagents) >= COUNT(messages) and treating only a shortfall as
evidence of a stopped writer. (revision 2) That relaxation was unsound and is
replaced. Every cross-execution collision banks one row of permanent headroom, and
headroom absorbs later missed writes: with N banked rows the writer can stop dead and
the comparison stays "healthy" for the next N subagent messages. AC-29 promises the
comparison diverges when the writer stops while message writes continue, and under
the >= rule that promise was false in exactly the configuration Amendment 1 exists
to create — the hole growing in proportion to how often the defect being recorded
actually occurs.
Collapsing the observed side to COUNT(DISTINCT task_session_id || ':' || tool_call_id) removes the excess from the comparison at the source: one subagent
counts once however many executions recorded it, which is exactly one per message
row. Equality is restored as the expectation, a shortfall regains its meaning, and
AC-29 becomes true again. The ':' separator is not required for correctness — both
operands are fixed-length UUIDs and cannot collide — but it costs nothing and removes
the question permanently.
Both sides count distinct keys, not rows, and the symmetry is required. The
message side can over-count for the mirror-image reason: nothing constrains
task_session_messages to be unique on (task_session_id, tool_call_id) (AC-21b),
so two messages can describe one subagent. If only the observed side were collapsed,
every such duplicate would present as a permanent one-row shortfall on a perfectly
healthy writer — the same false-alarm failure that got the >= relaxation introduced
in the first place. Counting distinct keys on both sides makes the comparison
symmetric: each side answers "how many distinct subagents does this table know
about", and those are the numbers that must agree.
Two directions, never netted (revision 3)
Revision 2 restored equality as the expectation but named allowances only on the
shortfall side, leaving what a persistent excess means undefined. It is not an
academic state: AC-27 deliberately keeps the subagent write independent of the message
write, so a failed message write with a successful upsert leaves a permanent +1; and
AC-1a puts recognition on a later tool_call_update, so a message created just before
capture_since whose recognizing frame lands just after it counts on one side only.
Under a bare "SHALL agree" such an installation reads unhealthy forever, and a check that
fires on correct behaviour gets muted — the exact failure the >= relaxation was
reaching for, arriving from the other direction.
The fix is not to tolerate excess. Tolerating it as slack in a single signed difference would reintroduce SR19's banking hole verbatim: with N banked excess keys the writer could stop dead and the difference would read zero for the next N subagents. Instead the comparison is split, so neither direction can cancel the other:
-- SQLite. :since is kandev_meta.subagent_context_capture_since, bound as a timestamp
-- (AC-24e). The `msg` CTE is AC-23b's normalization: it maps NULL / '' / 'null' metadata
-- to '{}' so one legacy row cannot abort the scan (revision 4). The CTE is byte-identical
-- on PostgreSQL; only the extraction changes (AC-19).
WITH msg AS (
SELECT m.task_session_id, m.task_id, m.created_at,
(CASE WHEN m.metadata IS NULL OR m.metadata = '' OR m.metadata = 'null'
THEN '{}' ELSE m.metadata END) AS meta
FROM task_session_messages m
)
-- SHORTFALL — expected keys with no subagent row. THIS is the alarm.
SELECT COUNT(*) FROM (
SELECT msg.task_session_id, json_extract(msg.meta,'$.tool_call_id') AS tool_call_id
FROM msg
WHERE json_extract(msg.meta,'$.normalized.kind') = 'subagent_task'
AND msg.created_at >= :since
AND COALESCE(msg.task_session_id,'') <> ''
AND COALESCE(msg.task_id,'') <> ''
AND COALESCE(json_extract(msg.meta,'$.tool_call_id'),'') <> ''
GROUP BY 1, 2
EXCEPT
SELECT s.task_session_id, s.tool_call_id
FROM task_session_subagents s
WHERE s.observed_at >= :since
) AS shortfall;
-- EXCESS — subagent keys with no accounting message. Attributed, does NOT alarm.
WITH msg AS (
SELECT m.task_session_id, m.created_at,
(CASE WHEN m.metadata IS NULL OR m.metadata = '' OR m.metadata = 'null'
THEN '{}' ELSE m.metadata END) AS meta
FROM task_session_messages m
)
SELECT COUNT(*) FROM (
SELECT s.task_session_id, s.tool_call_id
FROM task_session_subagents s
WHERE s.observed_at >= :since
GROUP BY 1, 2
EXCEPT
SELECT msg.task_session_id, json_extract(msg.meta,'$.tool_call_id')
FROM msg
WHERE json_extract(msg.meta,'$.normalized.kind') = 'subagent_task'
AND msg.created_at >= :since
) AS excess;
EXCEPT is set-based and deduplicates on both dialects, so this subsumes revision 2's
distinct-key collapse rather than replacing it: a cross-execution collision (AC-32)
contributes one key however many rows recorded it, and two messages describing one
subagent (AC-21b) contribute one key however many messages exist. The PostgreSQL form is
identical with msg.meta::jsonb #>> '{tool_call_id}' and #>> '{normalized,kind}'
substituted for json_extract, per AC-19.
(revision 4) Three things about the SQL above are load-bearing and were wrong or unstated before.
The derived tables are ALIASED, and they have to be. PostgreSQL rejects a subquery in
FROM without an alias, so revision 3's SELECT COUNT(*) FROM ( … EXCEPT … ); did not
parse on PostgreSQL at all — while the surrounding prose claimed the PostgreSQL form was
"identical with the extraction substituted", which was therefore false of the normative
queries themselves. AS shortfall / AS excess are accepted by both dialects, so the
identical-modulo-extraction claim above is now true rather than aspirational.
Every metadata reference goes through the msg CTE, for exactly AC-23b's reason. The
repository writes NULL, '' and 'null' for "no metadata" — that is what jsonColumn
(base_migrations.go) exists to absorb — and an unwrapped extraction raises on such a row:
json_extract('') is malformed JSON on SQLite and ''::jsonb errors on PostgreSQL. A
single legacy row would therefore disable the health check on either dialect. That is a
worse failure than any it detects, because the check whose job is noticing that this writer
stopped would itself stop, silently, in the same direction. The CTE is the same
normalization AC-23b already requires of the backfill, applied to the same rows.
The excess direction's message half deliberately does NOT carry the three identity
predicates the shortfall half carries. This asymmetry is intentional, not an oversight.
The shortfall side asks "which messages should have produced a row", so it must exclude
exactly what AC-2 and AC-21a exclude. The excess side asks the opposite question — "is
there any message that accounts for this row" — so it must subtract the widest message
set available. Filtering it would report excess for a subagent row whose message exists but
whose own task_id column is empty for an unrelated reason, which is a real row correctly
written from a frame that did carry a task id. A builder SHALL NOT mirror the filter onto
the excess side for symmetry's sake.
A non-zero SHORTFALL is evidence of a stopped or failing writer and is the check. A non-zero EXCESS is expected on any installation that has taken a message-write failure or straddled the activation boundary; it SHALL be reported separately for attribution and SHALL NOT be raised as writer failure, and it can never mask a shortfall because it is counted in the other direction. Cross-execution collisions no longer appear in either direction at all, which is what makes AC-29's guarantee unconditional.
Where a deployment wants the collision count itself — rows minus distinct subagents, the quantity revision 2 published — it remains available and is orthogonal to both directions above:
-- Cross-execution collisions (AC-32). Expected > 0 wherever a tool_call_id was reused.
SELECT COUNT(*) - COUNT(DISTINCT task_session_id || ':' || tool_call_id)
FROM task_session_subagents
WHERE observed_at >= :since;
AC-28a (added by revision 2) — WHEN subagent_context_capture_since is
absent, THEN the AC-28 comparison SHALL NOT be run, and the key's absence SHALL
itself be the health signal, read per AC-24c.
This branch has to exist because revision 1 created the state and left the query
undefined in it. AC-20 promises that a failed migration is made detectable by the
health signals; AC-24a requires capture_since to be absent after a failed
backfill; and AC-28's own prose disowns the unscoped comparison as one that "drifts
permanently and reads as writer failure." So in precisely the failure state AC-20
points at, the prescribed query had no value to bind to :since and either could not
run or had to fall back to the form the spec rejects. The resolution is that the
detection in that state is the missing key, not a shortfall — which is a stronger
signal anyway, since it is present from the first boot after the failure rather than
accumulating.
AC-28b (added by revision 2) — WHEN a shortfall is observed, THEN it SHALL be confirmed by a second evaluation before being treated as evidence of a failing writer, OR both counts SHALL be taken within a single read transaction.
AC-28's claim that a shortfall means a stopped writer "and nothing else" is not true of two independently-executed statements against a live store: rows and messages are written continuously and a session cascade-deleting between the two counts skews them differently, so a healthy writer can present a transient shortfall.
(revision 3) The anti-join form largely retires this hazard rather than merely mitigating it: each direction is now a single statement, so both of its sides are read under one snapshot and the two-statement skew this criterion was written against cannot arise within a direction. The confirm-on-second-evaluation rule is retained for the residual case — a session cascade-deleting mid-statement, and any deployment whose monitor still evaluates the two directions separately — but a builder using the queries as written satisfies it by construction with the single-statement reading. A check that fires on correct behaviour gets muted, and a muted check is the same as no check — which is the outcome this feature exists to prevent.
(revision 1) The shortfall side needs its own allowance, for the mirror-image reason. A frame carrying a session id but no task id writes its message and is then correctly skipped by AC-2, so it produces a permanent, benign shortfall that is indistinguishable from a dead writer if the expected count is taken naively. The expected count SHALL therefore exclude what AC-2 and AC-21a exclude, which is answerable from the database alone:
-- SQLite: expected count, AC-2 skips removed. Same shape on PostgreSQL with
-- metadata::jsonb #>> '{...}' substituted for json_extract.
SELECT COUNT(DISTINCT task_session_id || ':' || json_extract(metadata,'$.tool_call_id'))
FROM task_session_messages
WHERE json_extract(metadata,'$.normalized.kind') = 'subagent_task'
AND created_at >= :since
AND COALESCE(task_session_id,'') <> ''
AND COALESCE(task_id,'') <> ''
AND COALESCE(json_extract(metadata,'$.tool_call_id'),'') <> '';
With that expected count, a shortfall is evidence of a stopped or failing writer and nothing else, which is what makes the check actionable. A health check that fires on correct behaviour gets muted, and a muted check is the same as no check — which is the outcome this feature exists to prevent.
The message count remains a valid independent expectation because the message write is load-bearing for the UI: a broken message write is noticed by a human within one turn, whereas a broken context write is noticed by nobody. That asymmetry is what makes the comparison a real check rather than two readings of the same failure.
AC-29 (scope corrected in revision 2) — WHEN the writer has stopped while
message writes continue, THEN the AC-28 comparison SHALL diverge, and the divergence
SHALL be attributable in time via MAX(observed_at) on task_session_subagents. No
separate heartbeat row is written, precisely so that the health signal cannot itself
be the thing that silently stops.
(revision 2) This guarantee is made against AC-28's distinct-key count, and it
holds only because of that. Under revision 1's >= comparison it was false: banked
cross-execution excess absorbed the first N missed writes, so a stopped writer
produced no divergence at all until the backlog was consumed. The distinct-key count
does not accumulate excess, so the first missed subagent is the first divergence.
(revision 3) "Diverge" means the SHORTFALL direction becomes non-zero — the first anti-join in Two directions, never netted. Naming the direction is what makes this guarantee unconditional: because the shortfall is a directed set difference rather than a signed count, no amount of banked excess from any source (cross-execution collisions, a failed message write, an activation-boundary straddle) can offset it, so the first missed subagent is the first divergence on every installation rather than only on one with no excess. A monitor SHALL alarm on the shortfall query, not on the equality of two counts. This criterion is the reason AC-28's comparison changed, rather than the other way round — the requirement that a stopped writer be noticed is the fixed point, and the query is what had to move.
(revision 4) The attribution half is scoped, and its empty result has a defined
meaning. The MAX(observed_at) SHALL be taken over rows at or after
subagent_context_capture_since — the same :since and the same timestamp comparison the
shortfall query uses (AC-24e) — not over the whole table. Unscoped it returns the newest of
whatever exists, so on an installation whose only rows are backfilled or legacy it reports
an instant that never established live-writer health at all, and reads as a healthy writer.
WHEN that scoped MAX returns NULL — no row has been observed since activation — THEN
it SHALL be read as "the writer has produced nothing since this installation activated",
which is the strongest available divergence signal rather than an absence of information.
It SHALL NOT be reported as "no divergence", "unknown", or a healthy state. This is the
reachable case where the very first post-activation subagent is the one missed, and it is
exactly the case a naive MAX reports most reassuringly.
Execution identity (added by Amendment 1)
AC-30 — WHEN the orchestrator records a subagent context from a frame whose
ExecutionID is non-empty, THEN the row's agent_execution_id SHALL be that
value verbatim, and the row SHALL be keyed on (task_session_id, agent_execution_id, tool_call_id).
AC-31 — WHEN a subagent context is written and no execution identity is
available — the frame's ExecutionID is empty, or the write is the backfill,
whose source rows carry no execution identity — THEN agent_execution_id SHALL
be the reserved literal 'unknown'. It SHALL NOT be NULL, SHALL NOT be the
empty string, and SHALL NOT be omitted from the insert. WHEN this occurs on the
live path specifically, the system SHALL additionally increment an
unknown_execution writer counter alongside the AC-26 counters, so that a
regression which silently stops propagating execution identity is observable
rather than merely absorbed by the sentinel.
unknown_execution is additive, not exclusive: a live frame with an empty
ExecutionID still increments attempted and, on success, persisted, exactly
as any other write does. It is a qualifier on a write that happened, not an
alternative outcome like skipped_no_identity. Stated because the counter set
otherwise reads as mutually exclusive outcomes, and a builder choosing the other
reading would break the attempted = skipped_no_identity + persisted +
failed identity that AC-26 requires.
(revision 1) Because expvar counters reset with the process, the counter is not
the durable half of this signal. subagent_context_execution_since (AC-24) is:
a 'unknown' row observed after that instant is a live frame that arrived
without an execution id, and one observed before it is a legacy row that never
had the dimension. The counter tells an operator that it is happening now; the
key lets a consumer tell which population a row belongs to months later.
This mirrors AC-7's NULL-not-zero rigor for the metric columns: there, a
fabricated 0 would corrupt an average; here, a fabricated shared identity
would silently re-merge rows the key exists to keep apart. The sentinel is
honest about being unknown, the counter makes "unknown" countable, and AC-25's
disclosure rule extends to it — a 'unknown' row asserts that the execution is
unrecorded, never that the rows share an execution.
AC-32 — WHEN two executions in the same session emit the same
tool_call_id, and a frame from the earlier execution arrives after the
later execution's row exists, THEN the earlier frame SHALL create or update its
own row keyed on its own agent_execution_id, and the later execution's row
SHALL be unchanged in every column — including tool_status, settled_at, and
the three metric columns.
This is the defect Amendment 1 exists to close, so it is stated as an
observable outcome and not only as a key change. It is reachable in production:
late frames from completed executions are recorded rather than dropped
(event_handlers_streaming.go:325-333), and tool_call_id uniqueness is only
per-execution (Execution identity, as sampled). Verification requires a
regression test at the repository layer driving exactly this interleaving.
AC-33 (amended by revision 1) — WHEN runMigrations() runs against a
database whose task_session_subagents table predates Amendment 1, THEN after
the migration the table SHALL have:
- the
agent_execution_idcolumn, NOT NULL; - the three-column
UNIQUE(task_session_id, agent_execution_id, tool_call_id); - no remaining two-column
UNIQUE(task_session_id, tool_call_id); - all three secondary indexes — on
task_session_id,task_id,turn_id— present, matching the fresh-database DDL exactly (revision 1); - every pre-existing row surviving with all column values intact, timestamps
included, and
agent_execution_idset to'unknown'; subagent_context_execution_sincewritten per AC-24. (revision 2) The key is not conditional on this criterion's WHEN: AC-33b writes it on every installation where the key-bearing clauses are observed to hold, including those where no rebuild was ever needed. (revision 3) Those are clauses 1–3; clauses 4 and 5 remain required outcomes of this criterion but do not gate the key write.
This end state SHALL hold on PostgreSQL as well as SQLite (revision 1). The dialect scope is stated because the two mechanisms are genuinely different and the earlier draft's pointer covered only one of them:
- On SQLite, the two-column key is an inline table constraint
(
base_schema.go), which SQLite cannot alter in place, so the table must be rebuilt.recreateTableNamedis the repository's pattern for that. - On PostgreSQL,
recreateTablereturns(false, nil)immediately — it is SQLite-only by construction. A migration wired only through it therefore silently no-ops on PostgreSQL: no error, noWARN, not even a "migration applied" line, leaving the two-column key in place. PostgreSQL needs its own path, which drops the auto-named constraint by looking it up inpg_constraintby its column set (names truncate at 63 bytes and cannot be hardcoded) and adds the new one. The repository already owns this exact shape — add a column and widen a two-column UNIQUE to three columns, on both dialects — inmigrateTaskEnvironmentReposAllowMultiBranchand its…Postgrestwin (base_migrations.go). That is the precedent to follow, not the tworecreateTableNamedcall sites that merely drop a column.
The mechanism beyond that is Build's to choose. Fresh databases SHALL obtain the
same shape from the schema-init DDL. Per the repository's migration rules, the
column and the key change SHALL be introduced by an idempotent migration and
SHALL NOT rely on CREATE TABLE IF NOT EXISTS alone, which is a no-op on an
existing database.
AC-33a (added by revision 1) — WHEN the AC-33 migration fails or does not
apply, THEN boot SHALL NOT abort, the failure SHALL be logged at WARN,
subagent_context_execution_since SHALL NOT be written (AC-24b), the table SHALL
be left in its pre-amendment shape rather than a partial one, and the migration
SHALL be attempted again on the next boot.
This is the branch the earlier draft left to a coin flip, and the two guesses had opposite consequences, so it is settled here rather than in Build. Not aborting boot is the correct half: this is a telemetry table, and AC-27's principle — telemetry never breaks the product path — does not stop being true because the failure happened in a migration rather than a write. The rebuild runs inside a transaction, so a failure rolls back and the pre-amendment table survives intact; a half-migrated table is not an allowed outcome.
The resulting degraded state is fully specified and must not be silent: the
amended upsert's three-column conflict target cannot match the surviving
two-column constraint, so every live write fails, and each one increments
failed and logs at WARN per AC-27. That state is therefore detectable three
independent ways — the missing subagent_context_execution_since key (AC-24b),
a climbing failed counter, and the AC-28 shortfall — which is the standard this
feature is held to: if this writer stops, something notices.
AC-33b (added by revision 2; observation scope fixed in revision 3) — WHEN
runMigrations() completes a pass in which the key-bearing clauses of the AC-33 end
state hold, THEN subagent_context_execution_since SHALL be written per AC-24 if absent,
and left unchanged if already present. This applies regardless of how the end state came
to hold: whether a rebuild applied it in this pass, an earlier boot applied it, or the
schema-init DDL created the table already-amended and no rebuild was ever needed.
(revision 3) The key-bearing clauses are AC-33's clauses 1, 2 and 3, and only those:
the agent_execution_id column exists and is NOT NULL; the three-column
UNIQUE(task_session_id, agent_execution_id, tool_call_id) exists; and no two-column
UNIQUE(task_session_id, tool_call_id) remains. Revision 2 wrote "clauses 1–5", which is
not a decidable condition and left a builder to invent one: clause 5 is not observable
at all — it asserts that rows which existed before this pass survived intact, and a
migration holds no record of the pre-state to compare against — while clause 4 (the index
set matches the fresh-database DDL) is observable but is not what this key attests to.
Clauses 4 and 5 remain required outcomes of AC-33 with their own verification; they
are simply not preconditions of this key write. The split is principled rather than
convenient: subagent_context_execution_since publishes exactly one fact — that
agent_execution_id on this installation carries a real execution identity (AC-24b) —
and clauses 1–3 are precisely the schema facts that make that true. A missing secondary
index is a parity and performance defect, not a reason to tell every consumer to discard
the execution dimension.
The migration SHALL determine this by OBSERVING the live schema — querying for
the agent_execution_id column and the constraint set — and SHALL NOT infer it from
whether a rebuild helper reported that it fired. recreateTable returns
(false, nil) in three materially different situations: the table does not exist,
the trigger phrase is absent because the shape is already correct, and the dialect
is PostgreSQL. A fired boolean therefore cannot separate "already correct" from
"did nothing and is still wrong", and gating a durable published key on it is gating
it on a value that does not carry the fact.
This criterion exists because revision 1 attached the key to AC-33, whose WHEN is scoped to a table that "predates Amendment 1" — with the key write sitting inside that scope as clause 6. Two whole classes of installation never satisfy it:
- a brand-new database, which AC-33 itself says obtains the amended shape from the schema-init DDL;
- a database that predates the feature entirely, where
CREATE TABLE IF NOT EXISTScreates the table already-amended, after which it no longer "predates Amendment 1" either.
On both, the backfill still succeeded and wrote capture_since while
execution_since was never written by anything — and AC-24b then instructs every
consumer to read that combination as "has not successfully applied Amendment 1" and
to discard every agent_execution_id on the installation. On installations where
every row carries a genuine execution identity. The keys are write-once and no
rebuild will ever fire on them, so the mislabelling was permanent, and it applied to
the majority of installations this feature will ever run on. It inverted the durable
half of revision 1's own detection story.
(revision 3) There is a fourth state, and it is the one that makes schema observation
load-bearing rather than stylistic: the key write itself failing after a successful
rebuild. Nothing binds subagent_context_execution_since to the AC-33 rebuild the way
AC-24d binds the two backfill keys to their insert, and the runner swallows errors
(AC-20). So boot 1 can commit the rebuild and then fail to write the key, leaving an
installation whose schema is fully amended and whose key is absent — which AC-24b reads as
"has not successfully applied Amendment 1". Boot 2 SHALL self-heal it: the end state holds,
no rebuild fires, no CREATE TABLE fires, and the key SHALL be written anyway.
This is the state that separates a conforming implementation from one gated on "rebuild fired or the table was created this pass" — that gate satisfies every other case in this criterion and fails only this one. It is therefore the case a test has to construct, which is why Verification now requires it explicitly rather than leaving it implied by the SHALL-observe sentence.
AC-33c (added by revision 2) — WHEN runMigrations() runs, THEN the AC-33
shape change and the AC-33b key write SHALL be attempted before the AC-21
backfill within the same pass. AND WHEN the AC-33 end state does not hold, THEN the
backfill SHALL NOT run, SHALL NOT write either of its keys, and SHALL be attempted
again on the next boot (AC-24a).
The ordering is a contract, not an implementation detail, because the other order is
a permanent failure rather than a transient one. AC-31 requires the backfill's INSERT
to carry agent_execution_id and forbids omitting it. Consider an installation that
took the pre-amendment table but whose backfill failed — so capture_since is
absent and AC-24a requires a retry — and which then upgrades to the amended binary.
If the retried backfill ran first, its INSERT would reference a column AC-33 has not
yet added, the statement would fail, capture_since would stay unwritten, and the
identical sequence would repeat on every subsequent boot. AC-24a's retry contract is
precisely what would make that failure permanent instead of self-correcting.
AC-34 (amended by revision 1) — WHEN runMigrations() is invoked twice in
succession against a database migrated by AC-33, THEN the second invocation SHALL
succeed, SHALL leave the schema and the row set unchanged, and SHALL NOT
duplicate, drop, or re-sentinel any row.
This SHALL hold on PostgreSQL as well as SQLite, per AC-19 and ADR 0027 — and on both dialects the assertion SHALL be that AC-33's six-part end state is present after each invocation, not merely that the second invocation changed nothing since the first. (revision 1) An unchanged-only assertion is satisfied vacuously by a migration that no-ops on PostgreSQL and never applied at all, which is precisely the failure this criterion needs to catch.
Failure modes
| Condition | Behaviour |
|---|---|
Frame with no tool_call_id or no session id |
No row; skipped_no_identity++; product path unaffected (AC-2). |
| Upsert returns an error | WARN + failed++; product path unaffected (AC-27). |
| Negative reported metric | That field NULL; anomalous_value++; other fields written (AC-9). |
| Frames arrive out of order | Terminality is monotonic; NULLs may still fill (AC-11, AC-12). |
| Duplicate frames | Upsert; exactly one row (AC-3). |
| Parent session deleted mid-turn | FK cascade removes rows; no orphan (AC-16). |
| Migration statement fails | Swallowed by the runner; surfaced as WARN + detectable via AC-28 (AC-20). Boot never aborts. |
| Backfill statement fails | No activation key written; WARN; retried next boot (AC-24a). Never publishes a boundary it did not reach. |
| AC-33 rebuild fails or does not apply | Pre-amendment table survives whole (transactional rollback); WARN; subagent_context_execution_since absent; retried next boot (AC-33a). |
| AC-33 rebuild silently no-ops on PostgreSQL | Prevented by AC-33's dialect scope and AC-34's end-state assertion; if it still occurs it presents exactly as the row above (AC-24b). |
| Live writes rejected because the rebuild left the two-column key | Every write failed++ and WARN per AC-27; product path unaffected; detectable three ways (AC-33a). |
| Backfill re-runs | ON CONFLICT DO NOTHING; no duplicates, no clobber (AC-22). |
Agent reports an agent_status Kandev does not recognise |
Stored verbatim; never affects settled_at (AC-10a). |
| Frame carries no task id | No row; skipped_no_identity++ (AC-2). |
Same tool_call_id observed under a later turn |
Existing row updated; original turn_id preserved (AC-2a). |
| A turn row is deleted | Subagent rows survive; turn_id has no FK, by design. |
Same tool_call_id reused by a later execution |
Two rows, one per execution; neither clobbers the other (AC-32). |
Late frame from a completed execution after a newer execution reused its tool_call_id |
Updates only its own execution's row; the newer row is untouched (AC-32). |
Live frame carries an empty ExecutionID |
Row written with agent_execution_id = 'unknown'; unknown_execution++ (AC-31). Never skipped — absence would be indistinguishable from no fan-out. |
| Backfill derives a row (source rows have no execution identity) | agent_execution_id = 'unknown' (AC-31); replay is a no-op via the shared sentinel (AC-22). |
| Backfill overlaps a subagent already captured live with a real execution id | Both rows exist, distinguishable by source; named and accepted (AC-22). |
An execution row is deleted from executors_running |
Subagent rows survive; agent_execution_id has no FK, by design. |
| Cross-execution collision (revision 3) | Does not affect the AC-28 comparison at all: both directions are EXCEPT set differences over (session, tool_call_id), so one subagent contributes one key however many executions recorded it. Attributed by the separate collision query (AC-28, AC-32). |
| AC-2 skip deflates the AC-28 count | Expected; the expected-count query excludes the same rows, so the shortfall stays actionable (AC-28, revision 1). |
Two executions BOTH arrive with an empty ExecutionID and reuse one tool_call_id |
They share the 'unknown' key and merge into one row (AC-3) — the pre-amendment clobber, still reachable when execution identity is absent from both frames. Accepted residual, not a defect to design around: the sentinel cannot manufacture a distinction the frames did not carry, and unknown_execution plus subagent_context_execution_since make the population countable and datable (AC-31). |
| Backfill row later merged onto by a live frame | Row keeps source = 'backfill' and its original observed_at; all other columns fill forward (AC-1, revision 1). |
A message with an empty task_id is backfilled |
Skipped, matching AC-2's live-path rule (AC-21a). |
| Fresh database, or one predating the feature entirely | No rebuild fires — the schema-init DDL already produced the amended shape — but the end state is observed and subagent_context_execution_since is written anyway (AC-33b). The installation is never mislabelled un-amended. |
| Backfill retried after a partial failure | backfill_through is recomputed from the AC-21 predicate, not from the rows this attempt inserted, so a no-op replay still publishes the true high-water mark rather than '' (AC-24). |
capture_since absent |
The AC-28 comparison is not run; the missing key is itself the health signal, read per AC-24c (AC-28a). |
| Live rows captured between a failed backfill and its successful retry | Kept and counted. They precede capture_since, so completeness is disclaimed for that window, but validity is not (AC-25, revision 2). |
Two messages share one (task_session_id, tool_call_id) |
Greatest created_at, tiebroken by greatest id, supplies the row; exactly one row results (AC-21b). |
| Writer stops after cross-execution collisions banked excess rows | Divergence is immediate: the health comparison counts distinct (session, tool_call_id) keys, not rows, so excess cannot absorb a missed write (AC-28, AC-29). |
| Two messages describe one subagent (AC-21b), inflating the expected side | No false shortfall: both sides of the AC-28 comparison count distinct keys, so the duplicate collapses on the expected side exactly as it does on the observed side (AC-28). |
| Backfill would otherwise run before the AC-33 shape change | Prevented by AC-33c: the shape change goes first, and the backfill does not run against a stale shape. Without it, an install whose earlier backfill failed would fail identically on every boot forever. |
| First frame resolves no turn, a later frame carries one | turn_id is filled forward; "first observation wins" applies only between two non-empty turns (AC-2a, revision 2). |
Later frame reports total_tokens or duration_ms as 0 after a real value was learned |
The stored value is preserved — 0 means "not reported" for these two fields, so AC-4 applies (AC-7a, revision 2). |
| Later frame reports a NEGATIVE metric after a real value was learned (revision 3) | The stored value is preserved; anomalous_value++ still fires. A negative is not a measurement, so AC-4 applies exactly as it does to the reported zero (AC-9). |
| An activation key is compared against a timestamp column as a raw string (revision 3) | Forbidden. The boundary would snap to midnight of the key's date and misclassify up to a day of rows. The key is parsed and bound as a timestamp (AC-24e). |
backfill_through is the empty string (revision 3) |
The fresh-install default. A sentinel, not an instant: the backfill matched nothing, so no pre-capture history is claimed and none needs to be. Never parsed as a timestamp (AC-24f). |
| Persistent observed-side EXCESS on a healthy writer (revision 3) | Expected and reportable, never an alarm — reachable from a failed message write (AC-27 decouples the two) or an activation-boundary straddle (AC-1a). It cannot mask a shortfall because the two directions are separate anti-joins (AC-28). |
MAX(created_at) evaluated after the backfill INSERT (revision 3) |
Forbidden. A message committed between the two would publish coverage the backfill never reached, and the key is write-once. Evaluate it over the insert's snapshot or before it (AC-24d). |
Non-terminal frame arrives after settled_at is set (revision 3) |
tool_status and settled_at stay frozen; every other column follows AC-5 and AC-4 unchanged, so a late-reported model is still recorded (AC-12). |
| Two frames for one key commit in reverse observation order (revision 3) | observed_at is the creating (first-committed) frame's observation time. It is not recomputed to the earliest observation, because AC-1 makes it write-once (AC-1a). |
AC-33 rebuild commits but the execution_since key write fails (revision 3) |
Schema amended, key absent — an installation AC-24b would mislabel. The next boot observes the end state, fires no rebuild, and writes the key anyway (AC-33b). This is the state a fired-gated implementation never recovers from. |
capture_since sampled at transaction start (revision 4) |
Forbidden. It would over-claim completeness across the whole AC-23a scan — seconds or minutes on a large store. Sample inside the transaction, at or after the INSERT (AC-24 point 4). The commit instant itself is unobtainable there and is not what the key means. |
A row carrying a REAL UUID agent_execution_id observed BEFORE execution_since (revision 4) |
Its execution identity is real and is read as real. execution_since dates only the 'unknown' population; it never bounds the validity of a value that is present (AC-25). Reachable from AC-33b's fourth state, so this is ordinary, not exotic. |
| An activation key already present at second precision (revision 4) | Accepted as-is and never rewritten — write-once outranks the precision mandate, and moving a published boundary is worse than the second of ambiguity it would fix. A disclosed limitation of already-activated installations (AC-24 point 5). |
| Exactly one of the two backfill keys present (revision 4) | Repaired, not tolerated. The activation guard is "both keys present", so the backfill re-runs once: the INSERT no-ops via ON CONFLICT, the missing key is computed from AC-21's predicate, and the present key is preserved. Reachable today because the pre-amendment build wrote three independent statements and guarded on capture_since alone (AC-24d). |
julianday() used to compare a key against a timestamp column (revision 4) |
Forbidden. Its ULP at this epoch is roughly 40 µs, so values tens of microseconds apart compare equal — coarser than the column's own microseconds. timestampColumn is the helper that does this and SHALL NOT be used here (AC-24e). |
| An AC-28 anti-join run on PostgreSQL without a derived-table alias (revision 4) | It does not parse; PostgreSQL requires the alias. AS shortfall / AS excess are accepted by both dialects (AC-28, AC-19). |
A message row whose metadata is '' or 'null' (revision 4) |
Absorbed by the anti-joins' msg CTE, exactly as AC-23b absorbs it in the backfill. Unwrapped, one such row raises and disables the health check on either dialect — the check failing silently in the same direction as the writer it watches (AC-28). |
Scoped MAX(observed_at) returns NULL (revision 4) |
Read as "the writer has produced nothing since activation" — the strongest divergence signal, never as healthy or unknown. Reachable when the very first post-activation subagent is the one missed (AC-29). |
Persistence guarantees
- A row, once written, is durable until its parent session is deleted.
settled_atis write-once (AC-11).sourceis write-once: a backfilled row is never relabelledlive, and a live row is never overwritten by the backfill (AC-22). (revision 1) This holds on the merge path too — a live frame landing on an existing backfilled row leavessourcealone (AC-1).- (added by revision 1)
observed_atis write-once. It records when Kandev first observed this subagent (AC-1a), so a later frame never moves it forward; otherwise the column would drift toward "last seen" and AC-29'sMAX(observed_at)would stop locating when a writer failure began. - (added by Amendment 1)
agent_execution_idis write-once. It is part of the row's identity, so a later frame cannot change it — a frame with a different execution id addresses a different row (AC-30, AC-32). A row that was written with the'unknown'sentinel is never later "upgraded" to a real execution id, because that would silently move a row between identities and defeat the key. - The three
kandev_metaactivation keys are write-once (AC-24), and none is written unless the work it attests to actually succeeded (AC-24a, AC-33a). (revision 2) For the two backfill keys that means theINSERT … SELECTcommitted, and both are written or neither is (AC-24d). Forsubagent_context_execution_sinceit means the AC-33 end state was observed to hold — not that a rebuild fired, which is a different and much narrower fact (AC-33b). All three carry nanosecond precision, and every criterion that classifies a row against one of them usesobserved_at >= keyfor "at or after" (AC-24). (revision 3) That comparison is a timestamp comparison — the key is parsed and bound, never compared as a raw string against a column's storage form (AC-24e) — andsubagent_context_backfill_throughadditionally carries a non-timestamp sentinel state, the empty string, which is exempt from both the precision requirement and the comparison (AC-24f).subagent_context_execution_sinceis the one key with no atomicity partner, which is why AC-33b requires the next boot to write it when the end state already holds. (revision 4) Write-once is the stronger invariant wherever it collides with anything else in this document: a key already present is never rewritten, not to raise its precision (AC-24 point 5) and not for any other repair. Completing a partial pair (AC-24d) does not breach this — it writes the key that is missing and leaves the one that is present untouched, which is whatON CONFLICT DO NOTHINGalready guarantees. - No column in this table participates in any existing rollup
(
task_sessions.cost_subcents,tokens_in,tokens_outare untouched).
Permissions
The table is session-scoped and inherits the session's scope. Any read surface
added later MUST authorize via the parent session's task
(authorizeTaskID(session.TaskID)), per the backend's session-keyed entry-point
rule. This spec adds no read surface, so it adds no new authorization path.
Out of scope
Named exclusions. Each is a contract, not an omission.
- No
parent_session_idcolumn ontask_sessions. Rejected above. - No change to
dim_sessionor to "sessions per card". That series keeps its current meaning; the new table is a separate, additive fact. - No UI surface, no DTO field, no WebSocket event. The subagent card continues to render from message metadata. No E2E coverage is implied, because no user-visible surface changes.
- No REST/MCP read API. Consumers read the table directly via the extract. If a product surface is wanted later, it is a separate spec with its own authorization design.
- No merging of OpenCode's child ACP session.
child_session_idis stored; resolving it into the parent transcript is a separate feature. - No reading of Claude's async
outputFile. The 190async_launchedrows stay usage-less by design; inventing their usage is out of scope, and approximating it is forbidden by AC-7. - No cost or dollar attribution. Tokens are as-reported, unpriced, unaggregated into any existing cost column.
- No
promptorresult_textcolumn. Structure, not content. - No recursive subagent-tree API.
parent_tool_call_idis recorded (AC-6); walking it is a consumer concern. - No retention or pruning policy beyond the FK cascade. The table grows with message history and is bounded by the same session lifetime.
- No retroactive repair of rows the backfill could not see. Sessions already deleted are gone; AC-25 requires disclosing that, not fixing it.
(added by Amendment 1)
- No foreign key from
agent_execution_idto any execution table. Executions are removed fromexecutors_runningwhen they stop; an FK would delete the measurement when the measured thing ended. Same reasoning asturn_id. - No retroactive resolution of
'unknown'execution ids. A sentinel row is never later upgraded to a real execution id, not by a subsequent frame and not by a repair job. The value is part of the row's identity, and rewriting it would move a row between identities — which is the class of silent mutation this amendment removes. - No de-duplication of cross-execution
tool_call_idcollisions. Two rows differing only by execution are the correct outcome (AC-32), not a defect to collapse. Any consumer wanting an execution-agnostic count derives it withCOUNT(DISTINCT task_session_id || tool_call_id)rather than having it imposed on the store. - No
agent_execution_idontask_session_messages. The backfill's sentinel is accepted instead. Adding execution identity to the message table is a separate change with its own migration and its own backfill problem, and nothing in this feature needs it.
Verification
- Repository tests alongside
task_external_id_migration_test.go: fresh-DB create, pre-migration seeded row,runMigrationstwice, backfill idempotence, and the NULL-not-zero assertions for anasync_launched-shaped payload. - The same fresh-plus-replay matrix under
KANDEV_TEST_POSTGRES_DSN(AC-19). - Orchestrator tests for the frame paths: first-frame insert, later-frame merge,
out-of-order terminal, nested
parent_tool_call_id, empty-identity skip (each of the three missing ids), same-tool_call_id-later-turn, negative metric, reported-zero tool count, empty-string-to-NULL, and concurrent same-key upsert. - A query-level check of AC-28 against a seeded store.
(added by Amendment 1) Additionally:
- The cross-execution regression test (AC-32), which is the point of the
amendment. Seed a row for
(session S, execution B, tool_call_id T)carrying a terminaltool_status, settled timestamp, and metric values; then deliver a frame for(session S, execution A, tool_call_id T)where A is an earlier, already-completed execution. Assert two rows exist and that B's row is byte-identical to its seeded state. A test that only asserts "two rows exist" does not cover this — the clobber it replaces was an update, so the assertion must be on B's column values. - The AC-33 rebuild test in the shape
apps/backend/AGENTS.mdrequires for table-rebuild migrations: seed a pre-amendment row (including its timestamps and NULL metric columns), runrunMigrationstwice, and assert the row survives with values intact,agent_execution_id = 'unknown', the three-column key present, and the two-column key gone. - An AC-31 test that a live frame with an empty
ExecutionIDstill writes a row, stores'unknown', and incrementsunknown_execution— asserting the row is written, since the failure mode being guarded is a silent skip. - The same fresh-plus-replay matrix for all of the above under
KANDEV_TEST_POSTGRES_DSN(AC-19, AC-34).
(added by revision 1) And, because every finding below was a failure or detection path that no existing test would have caught:
- AC-24a — the failed-backfill test. Force the backfill statement to fail,
then assert that neither
subagent_context_capture_sincenorsubagent_context_backfill_throughexists inkandev_meta, and that a secondrunMigrations()re-attempts the backfill rather than skipping it. Asserting only "the boot survived" does not cover this — the defect is a key written after a failure, which is invisible unless the key is the assertion. - AC-33a — the failed-rebuild test. Force the rebuild to fail and assert:
boot did not abort; the pre-amendment table is intact (two-column key still
present, no partial table, no lost rows);
subagent_context_execution_sinceis absent; and the nextrunMigrations()retries. - AC-33 clause 4 — the index-set assertion, comparing a migrated database's index set against a fresh one's rather than checking the indexes exist.
- AC-34 on PostgreSQL — assert the end state, not just stability. A test that
only re-runs and diffs passes against a migration that never applied. This is
the specific check that would have caught
recreateTable's PostgreSQL no-op. - AC-7a — a reported-zero test for
total_tokensandduration_ms, asserting both store NULL, alongside the existingtool_use_countreported-zero test asserting it stores 0. The three columns deliberately do not behave alike, and a test suite that only covers the pointer field documents the wrong rule. - AC-1 on the merge path — seed a
source = 'backfill'row, deliver a live frame on the same key, and assertsourceandobserved_atare unchanged while other columns filled forward. - AC-11 — a test pinning the spec's six terminal values to
isTerminalToolStatus, so the enumeration in this document cannot drift from the function again. - AC-28 — the health queries as written, including the
:sincepredicate and the identity filter, executed on both dialects against a seeded store that contains an AC-2-skipped message, so the query is proven not to false-alarm.
(added by revision 2) And, because each finding this revision closes was a state no existing test constructs:
- AC-33b — the fresh-database activation test. Create a database from scratch,
run
runMigrations(), and assertsubagent_context_execution_sinceis present. Repeat on a database seeded to predate the feature entirely (notask_session_subagentstable at all). Both are the classes revision 1 silently excluded, and a test that only exercises the pre-amendment-table upgrade path passes against the broken contract — which is exactly what happened. - AC-33b — the negative half. Assert the key is written when the end state holds but no rebuild fired. A test that asserts "rebuild fired ⇒ key written" cannot distinguish the two and would have missed this.
- AC-33c — the ordering test. Seed a pre-amendment table with
capture_sinceabsent (an installation whose earlier backfill failed), runrunMigrations(), and assert the backfill completed and both its keys exist. Under the wrong order this fails on every invocation, so the test must runrunMigrations()at least twice and assert success both times. - AC-24 —
backfill_throughacross a retry. Run the backfill to success, clear onlycapture_since, run again, and assertbackfill_throughis unchanged and non-empty. The literal revision-1 reading publishes''here. - AC-24c — the four-state matrix, one case each, asserting (revision 4) which keys are present and absent in each state — never a row count, and never the consumer reading laid on that state, which is contract rather than a test target (§ Acceptance criteria preamble).
- AC-24d — atomicity. Force the second key write to fail and assert neither key is present.
- AC-28a — the absent-key branch. With
capture_sinceabsent, assert the health comparison is not run and the absence is reported as the signal. - AC-28/AC-29 — the banked-excess test, which is the point of the comparison
change. Seed a store containing a cross-execution collision (two rows, one
message), then add subagent messages with the writer disabled, and assert the
comparison diverges on the first missed write. Against revision 1's
>=rule it does not diverge at all until the excess is consumed, so a test that only checks "eventually diverges" passes the broken version. - AC-28 — the duplicate-message symmetry test. Seed two qualifying messages
describing one subagent (AC-21b) and assert the comparison shows no shortfall.
Collapsing only the observed side reintroduces a permanent false alarm on a healthy
writer, which is the failure the
>=relaxation was originally reaching for. - AC-21 — the two named sources. Assert a backfilled row's
turn_idequals the source message'sturn_idcolumn (not NULL), and that itstool_statuscomes from top-levelmetadata.statuswhileagent_statuscomes frommetadata.normalized.subagent_task.status— with a case where the two differ, so conflating them fails. - AC-21b — the duplicate-source test. Seed two qualifying messages sharing one
(task_session_id, tool_call_id)with different payloads, and assert exactly one row results and that it came from the newercreated_at. - AC-2a —
turn_idfill-forward. Deliver a first frame with an empty turn id and a later frame with a real one; assert the row ends attributed to that turn. - AC-7a — the later-zero test. Learn a non-zero
total_tokens, then deliver a frame reporting0; assert the stored value survives. Same forduration_ms.
(added by revision 3) And, because each of these is a state no listed test constructs and three of them are already wrong in the shipped writer:
- AC-24e — the cross-format comparison test, which is the one that would have caught
the live defect. Write an activation key, then insert a row whose timestamp column is
later in the same calendar day than that key, and assert the row is classified at
or after the key. A test using a key from a different day passes against the broken
string comparison — the date part alone decides it — so the same-day construction is
mandatory, on both dialects. Assert the same for the AC-28
:sincewindow. - AC-24e — the precision test. Assert each of the three keys round-trips at
nanosecond precision, and specifically that the value written is not the second-precision
form
time.RFC3339andrfc3339Timestampproduce. (revision 4) This asserts keys this build writes; a key already present from the pre-amendment build is out of its scope and SHALL NOT be rewritten to satisfy it (AC-24 point 5). - AC-24f — the empty-sentinel test. Run the backfill on a database with no qualifying
message; assert
backfill_throughis''and that nothing parses it as an instant. This is the fresh-install path, so it is also the most-executed one. (revision 4) The consumer-side reading is contract, not a test target (§ Acceptance criteria preamble). - AC-24d — the snapshot-order test. Assert
backfill_throughnever names an instant later than the newest message the insert actually covered. Constructing a concurrent commit is not required: asserting the evaluation order (theMAXis taken over the insert's snapshot or before it) is sufficient and is what the criterion constrains. - AC-9 — the later-negative test. Learn a non-zero
total_tokens, then deliver a frame reporting-1; assert the stored value survives andanomalous_valuestill incremented. Pair it with the existing later-zero test so the two rules read alike. - AC-28 — the two-direction test. Seed a store with a persistent excess (a subagent row whose message write failed) AND then stop the writer while messages continue. Assert the SHORTFALL query goes non-zero on the first missed subagent despite the standing excess. A test that asserts only "the counts differ" passes against a signed difference, which is the formulation this replaces.
- AC-28 — the excess-does-not-alarm test. With a standing excess and a healthy writer, assert the shortfall query is zero and the excess query is non-zero, so a monitor wired to the shortfall does not fire on correct behaviour.
- AC-33b — the self-heal test, which is the discriminating one. Seed a database whose
table is already amended (clauses 1–3 hold) but whose
subagent_context_execution_sinceis absent — the state a committed rebuild plus a failed key write leaves. RunrunMigrations()and assert the key appears, with no rebuild and no table creation having fired. An implementation gated on "rebuild fired or the table was created this pass" passes every other AC-33b test and fails only this one, which is precisely why it is required. - AC-12 / AC-5 — the post-settlement replace test. Settle a row, then deliver a
non-terminal frame carrying a different non-empty
model; assertmodelupdated whiletool_statusandsettled_atdid not.
(added by revision 4) And, because three of these construct states that exist on the INSTALLED BASE rather than on a fresh database, and two of them fail against queries revision 3 declared normative:
- AC-24e — the one-microsecond boundary test, which is the discriminating one. Write an
activation key, then insert two rows whose timestamp column is exactly one microsecond
before and exactly at that key; assert the first classifies before and the second
at or after. On both dialects. The same-day test above passes against a
juliandayimplementation — its ULP is roughly 40 µs, so it cannot see a 1 µs difference — which is why the same-day test alone does not establish AC-24e and this one does. - AC-24 point 4 — the sampling-order test. Assert
capture_sinceis not earlier than the instant the AC-21INSERT … SELECTcompleted. Constructing a slow scan is not required: asserting the sample is taken at or after the insert, and never at transaction start, is what the criterion constrains. A test that merely asserts the key exists passes against a transaction-start sample that over-claims by the whole scan. - AC-24 point 5 — the legacy-key test. Seed
kandev_metawith a second-precisioncapture_sinceandbackfill_through(the exact values the pre-amendment writer produces), runrunMigrations()twice, and assert both keys are byte-identical afterwards. This is the negative of the precision test and they must both hold: an implementation that "repairs" precision passes the precision test and fails this one. - AC-24d — the partial-pair repair test. Seed a database with
capture_sincepresent andbackfill_throughabsent — the state the pre-amendment build's three independentApplycalls leave — plus qualifying messages. RunrunMigrations()and assertbackfill_throughappears, names the true high-water mark of the source predicate rather than'', and thatcapture_sinceis unchanged. Repeat with the mirror state (backfill_throughpresent,capture_sinceabsent). An implementation guarding oncapture_sincealone never runs at all in the first case, so the assertion must be on the missing key appearing, not on the boot succeeding. - AC-28 — the malformed-metadata test. Seed a qualifying store that also contains a
message row with
metadata = ''and one withmetadata = 'null', then run both anti-joins on both dialects and assert they return counts rather than raising. Without themsgCTE this fails on SQLite (malformed JSON) and on PostgreSQL (''::jsonb), and it fails in the direction that silently disables the check. - AC-28 / AC-19 — the PostgreSQL parse test. Execute both anti-joins verbatim against PostgreSQL. Revision 3's form had no derived-table alias and did not parse there at all, so a SQLite-only execution of these queries is not evidence that they run.
- AC-29 — the attribution test. With
capture_sincewritten and no subagent row observed after it, assert the scopedMAX(observed_at)returns NULL and that NULL is reported as "nothing produced since activation", not as healthy. Seed backfilled and pre-activation rows in the same store so an unscopedMAXwould return a non-NULL instant — that is the implementation this test exists to fail. - AC-25 — the self-heal-window classification test. Seed a row carrying a real UUID
agent_execution_idwhoseobserved_atprecedessubagent_context_execution_since(the state AC-33b's fourth case produces), and assert it is classified as carrying a real execution identity — not folded into the'unknown'population by the date comparison.
The one shape a builder must not get wrong
A regression test SHALL assert, on an async_launched-shaped payload, that
total_tokens, tool_use_count and duration_ms are all IS NULL and none is
0. This is 75% of real traffic; a DEFAULT 0 on any of those three columns
would make every future token-per-subagent average wrong by roughly a factor of
four, silently and permanently, and would do so after the activation point,
where AC-25 offers consumers no protection.
Implementation notes carried by Amendment 1 (not contract)
The same PR review raised a second, smaller point. It is recorded here so the Build round that implements Amendment 1 picks it up in the same diff — it touches the identical files — but it is not an acceptance criterion, because it constrains no observable behaviour and the spec should not freeze an internal Go interface shape.
orchestrator.SubagentContextRecorder's repository-side dependency is declared
as repository.SubagentContextRepository
(internal/task/repository/interface.go:488-495), which requires three methods
while the service uses only UpsertSubagentContext;
ListSubagentContextsBySession and ListSubagentContextsByTurn have no
production caller (they exist for the read surface this spec places out of
scope). Narrowing the service's dependency to a write-only interface, and
dropping subagentContextAdapter
(internal/backendapp/adapters.go:780-788) — a pure pass-through, since
taskservice.Service already satisfies the recorder interface directly — is
in scope for that round at Build's discretion. Removing the two list methods
from the repository implementation is not implied: they are the read path a
future spec would use, and deleting them is a separate decision.
Related
- ADR 0027 — replayable schema migrations
apps/backend/AGENTS.md§ Schema & migrations (SQLite repository)apps/backend/internal/agentctl/AGENTS.md§ Subagent tool-call nesting: what each agent emits