--- status: draft created: 2026-08-12 updated: 2026-08-14 owner: 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](../../decisions/2026-08-15-subagent-context-schema-boundary.md). ## 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](https://github.com/kdlbs/kandev/pull/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-:`), 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: `recordSubagentContextFromFrame` is called from handlers that pass the same `payload`, and `shouldDropCompletedExecutionStreamEvent` logs it as `agent_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: ```sql 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. ```sql -- 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; ``` ```sql -- 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: ```sql -- 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: ```sql -- 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: ```sql -- 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. ## Related - ADR [0027 — replayable schema migrations](../../decisions/0027-replayable-schema-migrations.md) - `apps/backend/AGENTS.md` § *Schema & migrations (SQLite repository)* - `apps/backend/internal/agentctl/AGENTS.md` § *Subagent tool-call nesting: what each agent emits*