agenthropic

ADR-0004: CD-2 — Single immutable substrate + deterministic projection

Empirical update — 2026-07-04 desktop probe

The Phase-0 corpus probe (8-agent read-only run over the real ~/.claude/projects/ corpus) pre-answers CD-1 as CONDITIONAL-GO → build, confidence 85/100. This de-risks but does not replace the formal Phase-0 spike — the WP-S7 GO gate still stands and no production code precedes it. Two points bear on this ADR:

As-built update — 2026-07-30

Verdict: amended in practice. This ADR has two halves. The immutability half shipped and is enforced in the database engine. The pipeline-shape half — the two-stage Normalizer → Projection pair drawn in the diagram below — was never built.

What holds:

What diverged: there is no pure Normalizer stage and no separate Projection stage. WP-IN6/WP-IN7 as drawn were collapsed. As built, JSONL is parsed by packages/core (a pure parser with no DB imports) and the result is written straight into sessions, agents, orchestration_edges and token_usage — one transaction per session, no intermediate normalized-event representation. Consequently events_raw in practice holds hook events only: the JSONL path does not round-trip through the substrate on its way to the projections.

Why: the substrate-then-project shape earns its keep when two drifting sources must be reconciled at projection time. Once CD-1 settled into “JSONL is the only structural source, hooks are liveness only” (ADR-0003), there was nothing to reconcile on the structural path, and the intermediate stage became a rewrite of the same facts with no reader. The per-session transaction preserves the property that actually mattered — a session is projected atomically or not at all, and replay is deterministic — without the second table.

What this costs, stated plainly: the replay guarantee is now “re-read the JSONL corpus and re-project,” not “re-run a pure function over rows already in the database.” That is weaker in one specific way: it depends on the corpus still being on disk. It is exactly as strong for the failure mode this project actually faces (process crash, restart, backfill), because Claude Code writes that corpus independently and does not truncate it — the property CD-1’s probe measured. If a hooks-only data source ever appears, this half of CD-2 has to be rebuilt as originally drawn.

As-built update — 2026-08-15

Verdict: unchanged. One process claim is narrower than written, and it does not reach the data. The negative test that asserts both RAISE(ABORT, 'events_raw is append-only') paths is called merge-blocking above. It runs in CI on every push and fails the run if either trigger stops firing. Since 2026-08-25 main is branch-protected on the ci check, so that failure does withhold a merge from a contributor — but not from the owner, who is exempt by design (enforce_admins: false); see the standing correction.

That correction is worth stating precisely, because it is easy to over-read. The immutability of events_raw does not depend on CI at all: it is enforced by SQLite BEFORE UPDATE / BEFORE DELETE triggers living inside the database file, which abort a write whether or not any test ever runs. The test proves the triggers are there; the triggers are what stops the write. A weakened claim about the test is therefore a claim about process discipline, not about whether the substrate can be edited. It cannot.

Thirteen migrations have now been applied (ADR-0006’s 2026-08-15 update) and none of them added an UPDATE or DELETE path to events_raw. Re-checked on 2026-09-18 at eighteen migrations (14–18 are listed in ADR-0006’s 2026-09-18 update): still none. The only UPDATE or DELETE that names events_raw anywhere in apps/server/src/db/migrations.ts is the BEFORE UPDATE / BEFORE DELETE trigger pair that forbids them.

(As built — 2026-08-09, recorded 2026-09-22: the 2026-07-30 sentence “there is no pure Normalizer stage and no separate Projection stage” no longer holds. apps/server/src/ingest/normalize-session.ts (WP-IN6, a pure function from parser output to a NormalizedSession value — no DB, clock or IO) and apps/server/src/ingest/project-session.ts (WP-IN7, one transaction per session) are the two stages, called in that order from ingest-session.ts. What is still not built is the shape in the diagram: the normalizer reads parser output, not events_raw, so events_raw still holds hook events only and the JSONL path still does not round-trip through the substrate.)

Context

agenthropic ingests the same underlying facts from two independent sources — hooks and the JSONL transcript log (ADR-0001, LB1). Left unresolved, this creates a classic reconciliation problem: does a read path merge two separate stores at query time, or does one substrate absorb both sources so reconciliation happens once, at write time? A two-store, merge-at-query design pushes reconciliation logic into every read path and makes idempotent replay much harder to reason about and test.

Decision

Both sources write into a single append-only, idempotency-keyed events_raw substrate. sessions/agents/orchestration_edges/token_usage are a pure, replayable projection over it. Reconciliation is per-field precedence at projection time (see ADR-0005, CD-3), never a two-store merge at query time.

   hook payload ──┐
                   ├──►  events_raw  (append-only, idempotency-keyed, CD-1/CD-2)
   JSONL line ────┘            │
                                ▼
                        pure Normalizer  ──►  events (normalized)
                                │
                                ▼
                        pure Projection  ──►  sessions / agents /
                                              orchestration_edges / token_usage

Replay-on-startup re-runs Normalizer + Projection over the same events_raw and must produce an identical result — this is what makes CD-1’s outage-recovery guarantee and the >90% coverage gate (ADR-0009, CD-7) tractable: tests can replay fixtures deterministically instead of mocking two independently-drifting stores.

Acceptance criteria

From concept-analysis-v2.md §6 (“Data foundation & reconciliation”) and the CD-4 crosscut:

Consequences

Alternatives considered