events_raw (a pure normalizer / transactional projection pair over parser output exists since 2026-08-09 — see the 2026-09-22 note under the 2026-08-15 update); amended 2026-08-15, re-amended 2026-08-25 — the abort test is merge-blocking for anyone who is not the repository owner (main is branch-protected on the ci check; enforce_admins: false, deliberate for a single-maintainer repository), and the triggers that enforce immutability sit below CI either way (see the as-built updates below)concept-analysis-v2.md §3, row CD-2
(consolidates AD1, SD2); §4.1 (Senior Architect)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:
WP-IN11) deferrable / YAGNI-leaning and pulls it off the v1
critical path — replay-on-startup over events_raw, not a spool, is the proven outage hedge
(add the outbox only on a real trigger: a sub-second live-freshness need, or a hooks-only data
source not also present in JSONL).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:
events_raw is genuinely append-only, and not by convention: migration 1 creates
SQLite BEFORE UPDATE and BEFORE DELETE triggers
(events_raw_no_update / events_raw_no_delete) that RAISE(ABORT, 'events_raw is
append-only'). There is no UPDATE/DELETE path to find, because the engine refuses
one. A negative test asserts both aborts and is merge-blocking.UNIQUE constraint on idempotency_key, so re-ingesting the same
log is a no-op at the storage layer rather than a de-duplication pass in
application code.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.
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.)
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.
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.
From concept-analysis-v2.md §6 (“Data foundation & reconciliation”) and the CD-4 crosscut:
events_raw with a stable idempotency key; re-ingesting the
same log yields byte-identical events_raw and an identical projected DB state.orchestration_edges/token_usage; zero data loss.events_raw exposes no UPDATE/DELETE path, enforced by a test (shared acceptance
criterion with ADR-0006, CD-4, and ADR-0009, CD-7).events_raw —
no side-channel writes are permitted anywhere in the ingest path, which constrains adapter
design (see ADR-0008, CD-6) and requires discipline to keep enforced structurally, not just by
convention.development-plan.md WP-D4 (events_raw immutable substrate +
append-only enforcement + EventStore.append), WP-IN6 (pure Normalizer), WP-IN7
(Projection), WP-IN10 (replay-on-startup + deterministic full projection rebuild). See
the data model and
ingest & reconciliation.events table with no immutable substrate — v1’s implicit design, and the
reconciliation gap v1 left unresolved (concept-analysis-v2.md §8, “What changed vs v1”).
Rejected: without an immutable append-only layer underneath, there is no clean way to prove
“no data lost, no data mutated” by test.