Files
kandev/docs/specs/subagent-context-persistence/spec.md
T

157 KiB
Raw Blame History

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_subagents shape is created directly by schema initialization. The predecessor two-column shape and the subagent_context_execution_since activation 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 against a.codexEmittedToolCallIDs, an in-memory map owned by one adapter instance. A new execution constructs a new adapter with an empty map, so a wire tool_call_id the 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), because subagent_context_capture_since is 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 recreateTable returns (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 source requirement reconciled with source's write-once guarantee.
  • AC-7 given a detection rule for total_tokens and duration_ms, which are plain int64 and cannot express "not reported" the way ToolUseCount *int can.
  • 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_since to 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_through is derived from the source predicate, not from what one attempt inserted (AC-24). Revision 1's tightening was wrong on a retry, where ON CONFLICT DO NOTHING inserts 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_since is 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_status and turn_id sources are named (AC-21). Both were verified at the writer: metadata.status is written from payload.Data.ToolStatus, and the message's turn_id column is written from the orchestrator's resolved turn id.
  • Plus a duplicate-source tiebreak (AC-21), turn_id NULL fill-forward (AC-2a), the later-reported-zero rule (AC-7a), task_id added 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_at stores 2026-07-30 01:39:37.271235+00:00 (space separator, microseconds, +00:00 offset — sampled from the reference store). subagent_context_capture_since is written time.Now().UTC().Format(time.RFC3339) and subagent_context_backfill_through via rfc3339Timestamp, both producing 2026-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.RFC3339 and strftime('%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 already COALESCE(…, '')s it, AC-24 mandates it, and AC-24's own RFC3339Nano requirement contradicted it.
  • The MAX runs as a separate statement after the INSERT — three independent r.migrate.Apply calls, 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_since was 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.Apply calls and guards re-runs on capture_since alone, so a database can hold capture_since without backfill_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 timestampColumn renders SQLite's side as julianday(), 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, forbids julianday, 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:

  1. It makes a backfill possible, which this spec takes (see Backfill).
  2. 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:

  1. 75% of subagent invocations report no usage at all. async_launched is Claude's run_in_background dispatch; the dispatch is terminal for the Task tool, the subagent runs out-of-band and writes to an outputFile Kandev never reads. These rows will never gain tokens, duration, or a tool count. Storing 0 for them would fabricate 190 measurements.
  2. child_session_id is empty for every Claude row and is populated only by OpenCode (5 rows, all non-terminal). It cannot be the identity.
  3. tool_call_id is present on all 253 rows and unique across all 253. agent_id is unique among the 247 rows that carry one, but is absent for three of the four supported agents. tool_call_id is 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_id and task_session_id are non-empty on all 253 rows.
  • subagent_type is 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 a tool_call_id is 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_id leads 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, where INDEX(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_id are 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_id carries no foreign key, for the same reason turn_id does not. Execution rows live in executors_running and are removed when the executor stops (agent_execution_id was itself dropped from task_sessions by an earlier migration, base_migrations.go:857), so an FK would delete the measurement when the thing it measured finished — the exact failure the turn_id decision already rejected. It is a plain value column that happens to be part of the key.

  • turn_id carries no foreign key. task_session_messages.turn_id cascades from task_session_turns, which would mean deleting a turn silently deletes the fan-out record for that turn. The measurement must outlive the turn row, so turn_id is 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_status is the child's own report, read from _meta.claudeCode.toolResponse.status (or the provider's equivalent). Its vocabulary is provider-defined and open — async_launched is a value only Claude produces, and it means dispatched, will never report usage.
  • tool_status is the ACP status of the launching Task tool call, which Kandev already classifies via isTerminalToolStatus. 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. SubagentTaskPayload uses "" for "absent"; the column uses NULL, so that COUNT(model) counts models rather than blanks.

  • is_async defaults to 0 and is set to 1 only on a positive report; it is never set back to 0 by a later frame.

  • source defaults to 'live'.

  • (added by Amendment 1) agent_execution_id is NOT NULL and never empty. It is the reserved literal 'unknown' whenever no execution identity is available, and the frame's ExecutionID otherwise. It is write-once: once a row exists, no later frame changes its agent_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 ExecutionID was empty. A second sentinel would look tidier and would be a bug: the backfill's ON 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_at against subagent_context_capture_since separates pre-amendment rows from a live frame that genuinely arrived without an execution id. That claim was false and is withdrawn. subagent_context_capture_since is 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-ExecutionID row are indistinguishable — both source = 'live', both observed_at after capture_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 whose observed_at precedes it is a legacy row that never had an execution dimension; a 'unknown' row whose observed_at follows it is a live frame that genuinely arrived without an execution id, and that case also increments the unknown_execution counter (AC-31). source continues 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_at are UTC wall-clock instants from the backend process (time.Now().UTC()), at the same precision as the sibling session tables.

  • task_id is required (NOT NULL). WHEN a frame carries no task id, the row is not written and AC-2's skipped_no_identity path 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 observed total_tokens is 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, or kandev_meta, or the value of an exported counter.
  • Process — AC-1a, AC-20, AC-27, AC-33a: the outcome is a WARN log 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/db error classification rather than string matching; one unbounded INSERT … SELECT; the dialect-aware JSON helpers; the MAX evaluated 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 a fired boolean) 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_count is ToolUseCount *int on streams.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_tokens and duration_ms are TotalTokens int64 and DurationMs int64 on the same struct — plain values, not pointers, both json:"…,omitempty". They cannot express the distinction: an agent that reported 0 and an agent that reported nothing both arrive as 0. THEREFORE, for these two fields specifically, a reported value of exactly 0 SHALL 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, so persisted is expected to exceed COUNT(*) 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 increments skipped_no_identity and 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 COUNT pairs 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 the COUNT pair 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 msg CTE; 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 normalize metadata the 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:

  1. the agent_execution_id column, NOT NULL;
  2. the three-column UNIQUE(task_session_id, agent_execution_id, tool_call_id);
  3. no remaining two-column UNIQUE(task_session_id, tool_call_id);
  4. all three secondary indexes — on task_session_id, task_id, turn_id — present, matching the fresh-database DDL exactly (revision 1);
  5. every pre-existing row surviving with all column values intact, timestamps included, and agent_execution_id set to 'unknown';
  6. subagent_context_execution_since written 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. recreateTableNamed is the repository's pattern for that.
  • On PostgreSQL, recreateTable returns (false, nil) immediately — it is SQLite-only by construction. A migration wired only through it therefore silently no-ops on PostgreSQL: no error, no WARN, 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 in pg_constraint by 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 — in migrateTaskEnvironmentReposAllowMultiBranch and its …Postgres twin (base_migrations.go). That is the precedent to follow, not the two recreateTableNamed call 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 EXISTS creates 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_at is write-once (AC-11).
  • source is write-once: a backfilled row is never relabelled live, 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 leaves source alone (AC-1).
  • (added by revision 1) observed_at is 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's MAX(observed_at) would stop locating when a writer failure began.
  • (added by Amendment 1) agent_execution_id is 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_meta activation 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 the INSERT … SELECT committed, and both are written or neither is (AC-24d). For subagent_context_execution_since it 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 uses observed_at >= key for "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) — and subagent_context_backfill_through additionally 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_since is 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 what ON CONFLICT DO NOTHING already guarantees.
  • No column in this table participates in any existing rollup (task_sessions.cost_subcents, tokens_in, tokens_out are 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_id column on task_sessions. Rejected above.
  • No change to dim_session or 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_id is stored; resolving it into the parent transcript is a separate feature.
  • No reading of Claude's async outputFile. The 190 async_launched rows 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 prompt or result_text column. Structure, not content.
  • No recursive subagent-tree API. parent_tool_call_id is 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_id to any execution table. Executions are removed from executors_running when they stop; an FK would delete the measurement when the measured thing ended. Same reasoning as turn_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_id collisions. 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 with COUNT(DISTINCT task_session_id || tool_call_id) rather than having it imposed on the store.
  • No agent_execution_id on task_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, runMigrations twice, backfill idempotence, and the NULL-not-zero assertions for an async_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 terminal tool_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.md requires for table-rebuild migrations: seed a pre-amendment row (including its timestamps and NULL metric columns), run runMigrations twice, 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 ExecutionID still writes a row, stores 'unknown', and increments unknown_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_since nor subagent_context_backfill_through exists in kandev_meta, and that a second runMigrations() 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_since is absent; and the next runMigrations() 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_tokens and duration_ms, asserting both store NULL, alongside the existing tool_use_count reported-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 assert source and observed_at are 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 :since predicate 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 assert subagent_context_execution_since is present. Repeat on a database seeded to predate the feature entirely (no task_session_subagents table 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_since absent (an installation whose earlier backfill failed), run runMigrations(), and assert the backfill completed and both its keys exist. Under the wrong order this fails on every invocation, so the test must run runMigrations() at least twice and assert success both times.
  • AC-24 — backfill_through across a retry. Run the backfill to success, clear only capture_since, run again, and assert backfill_through is 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_since absent, 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_id equals the source message's turn_id column (not NULL), and that its tool_status comes from top-level metadata.status while agent_status comes from metadata.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 newer created_at.
  • AC-2a — turn_id fill-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 reporting 0; assert the stored value survives. Same for duration_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 :since window.
  • 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.RFC3339 and rfc3339Timestamp produce. (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_through is '' 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_through never names an instant later than the newest message the insert actually covered. Constructing a concurrent commit is not required: asserting the evaluation order (the MAX is 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 and anomalous_value still 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_since is absent — the state a committed rebuild plus a failed key write leaves. Run runMigrations() 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; assert model updated while tool_status and settled_at did 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 julianday implementation — 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_since is not earlier than the instant the AC-21 INSERT … SELECT completed. 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_meta with a second-precision capture_since and backfill_through (the exact values the pre-amendment writer produces), run runMigrations() 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_since present and backfill_through absent — the state the pre-amendment build's three independent Apply calls leave — plus qualifying messages. Run runMigrations() and assert backfill_through appears, names the true high-water mark of the source predicate rather than '', and that capture_since is unchanged. Repeat with the mirror state (backfill_through present, capture_since absent). An implementation guarding on capture_since alone 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 with metadata = 'null', then run both anti-joins on both dialects and assert they return counts rather than raising. Without the msg CTE 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_since written and no subagent row observed after it, assert the scoped MAX(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 unscoped MAX would 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_id whose observed_at precedes subagent_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.

  • 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