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

2401 lines
157 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-<type>:`), Kandev already recognises the
fan-out and already receives the child's identity and usage on the completion
frame. It renders them, counts them in memory, and then keeps no queryable
record of them.
The consequence is a specific, measured distortion. In the reference store
(`~/.kandev/data/kandev.db`, snapshotted 2026-08-12), **sessions per card is
exactly 1.0 for every step**, while 253 subagent invocations exist across 38
sessions and 127 turns — including **42 turns that fanned out to exactly three
subagents**. A step that spawned three parallel reviewers is, in the store,
indistinguishable from one long conversation. Every per-context cost figure is
therefore wrong in the same direction, and the Ops Cost analysis of the Review
step's cache cost had to *infer* a three-way fan-out by reading the step's
prompt, because nothing in the store recorded it.
## What this feature is, and is not
**It is persistence and a parent link.** The data already arrives on a frame
Kandev parses (`internal/agentctl/server/adapter/transport/acp/subagent.go`) and
is already carried in a typed payload
(`streams.SubagentTaskPayload`). This spec adds a durable, queryable row per
subagent invocation, linked to the session and turn that spawned it. It adds no
new instrumentation, no new agent-side capability, and no new wire fields.
**It is not a session.** A subagent context is deliberately *not* a
`task_sessions` row. See *The design question*, below — that decision is part of
the contract, not an implementation detail.
**It is not a UI feature.** No frontend surface changes. The existing subagent
card already renders from message metadata and continues to do so, unchanged.
**It is not cost attribution.** Token counts are stored exactly as the agent
reported them, with provenance. No pricing, no dollars, no rollup into
`task_sessions.cost_subcents`.
### One correction to the framing
The originating analysis states the data is "never persisted". That is
imprecise, and the imprecision matters to the design. The normalized payload
*is* written today — as opaque JSON at
`task_session_messages.metadata.normalized.subagent_task` (253 such rows exist
in the reference store). What does not exist is a **relational fact**: no
stable row identity, no first-class parent link, no column a query can group by,
and no independent record — the JSON lives inside a row that is cascade-deleted
with its session.
This matters twice over:
1. It makes a **backfill possible**, which this spec takes (see *Backfill*).
2. It makes the message table an **independent expected-count source** for
writer health (see *Writer health*).
---
## The design question: a row in `task_sessions`, or its own table?
**Answer: its own table, `task_session_subagents`.** The parent link is a
foreign key from that table to `task_sessions`, not a `parent_session_id` column
added to `task_sessions`.
The reasoning, from the code as it stands:
**A subagent has none of the things a `task_sessions` row asserts.** It has no
workspace and no `workspace_path`; no executor, `executor_profile_id`, or
`task_environment_id`; no worktree or `base_branch`; no `agent_profile_id`; no
`executors_running` row; no ACP session Kandev can `session/load` or resume; no
`state` machine (`CREATED` → `RUNNING` → …); no launch, stop, or recover path.
A row in `task_sessions` implies all of those, and every one would be a lie.
**`task_sessions` is read by paths that would be actively harmed.** Session
rows feed lifecycle recovery (`RecoverInstances`), the orchestrator, session-tab
listings, WebSocket subscription scoping, the auth scoper
(`AuthorizeSessionAccess` resolves session → task → workspace → owner), the boot
payload, and cost rollups. Inserting non-launchable rows there forces a new
"is this real" predicate into every one of them, and any path that forgets it
becomes a defect.
**It would silently break the very series this feature exists to fix.** The
downstream extract's `dim_session` is `SELECT … FROM task_sessions`. Adding
subagent rows to that table would change what "sessions per card" *means*
mid-series, with no schema versioning to signal it — which is precisely the
discontinuity the cross-cutting rule for this work forbids. A separate table is
purely additive: the existing series stays comparable, and the new fact is
opt-in for any consumer that wants it.
The name follows the existing sibling convention: `task_session_messages`,
`task_session_turns`, `task_session_commits`, `task_session_subagents`.
---
## Sampled input shapes
Every shape below was read from the live store on 2026-08-12, not assumed.
### Population by terminal status (n = 253)
| status | n | agent_id | child_session_id | model | total_tokens | tool_use_count | duration_ms |
|---|---|---|---|---|---|---|---|
| `async_launched` | 190 | 190 | 0 | 190 | **0** | **0** | **0** |
| `completed` | 57 | 57 | 0 | 49 | 57 | 57 | 57 |
| `started` | 5 | 0 | 5 | 0 | 0 | 0 | 0 |
| *(absent)* | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
Three facts drive the whole contract:
1. **75% of subagent invocations report no usage at all.** `async_launched` is
Claude's `run_in_background` dispatch; the dispatch is terminal for the
`Task` tool, the subagent runs out-of-band and writes to an `outputFile`
Kandev never reads. These rows will never gain tokens, duration, or a tool
count. Storing `0` for them would fabricate 190 measurements.
2. **`child_session_id` is empty for every Claude row** and is populated only by
OpenCode (5 rows, all non-terminal). It cannot be the identity.
3. **`tool_call_id` is present on all 253 rows and unique across all 253.**
`agent_id` is unique among the 247 rows that carry one, but is absent for
three of the four supported agents. `tool_call_id` is the identity —
*(amended by Amendment 1)* **paired with the execution that emitted it**.
The observed uniqueness across 253 rows is a property of this sample, not a
guarantee: see *Execution identity, as sampled* below for why it does not
hold in general.
### Other measured facts
- **Fan-out distribution per turn:** 1→65 turns, 2→12, **3→42**, 4→5, 5→2, 8→1.
- **Nesting:** exactly 1 of 253 rows carries a `parent_tool_call_id` — a
subagent that spawned a subagent. Rare, real, and must be specified.
- `turn_id` and `task_session_id` are non-empty on all 253 rows.
- `subagent_type` is missing on 3 rows.
- No two subagent rows within a turn share a `created_at` (microsecond
precision), so ties are not observed — but they are not prevented either.
- Observed window: 2026-08-03 15:32:20Z to 2026-08-12 13:24:58Z.
### The payload as parsed
`streams.SubagentTaskPayload` carries `Description`, `Prompt`, `SubagentType`,
`Status`, `AgentID`, `Model`, `ChildSessionID`, `DurationMs`, `TotalTokens`,
`ToolUseCount *int`, `ResultText`, `IsAsync`, `OutputFile`,
`CanReadOutputFile`. `ToolUseCount` is already a pointer specifically so a
reported `0` is distinguishable from "not reported" — that distinction is
load-bearing here and must survive into the column.
### Execution identity, as sampled *(added by Amendment 1)*
Read from the code on 2026-08-14, because the amendment adds a column and a
spec may not assume a field exists.
**It is already on the frame.** `lifecycle.AgentStreamEventPayload` carries
`ExecutionID string \`json:"execution_id"\`` — documented in place as
"Lifecycle execution ID; stable across the payload's lifetime"
(`internal/agent/runtime/lifecycle/event_types.go:231`). Both publish sites set
it from `execution.ID`
(`lifecycle/events.go:226`, `lifecycle/manager_streaming.go:287`), and the
orchestrator already reads it on this exact path: `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*