v0.1.0: the full feature set #1

Merged
john merged 209 commits from phase-2-ingest-at-scale into main 2026-08-23 03:28:06 +00:00
Owner

Eight phases, 209 commits: archive core, ingest at scale, recall, hooks and
install, context injection, the feedback loop, observations, and retention
and lifecycle.

All 64 requirements are Complete and verified. The traceability audit passes
64/64 requirement -> phase -> plan -> verified, and 57/57 acceptance criteria
are covered by a UAT item.

cargo test --workspace --all-features: 61 suites, 732 tests, 0 failures.

The cycle's planning artifacts are archived under .planning/_archive-v0.1.0/,
with the per-phase narrative in .planning/ARCHIVE.md.

Eight phases, 209 commits: archive core, ingest at scale, recall, hooks and install, context injection, the feedback loop, observations, and retention and lifecycle. All 64 requirements are Complete and verified. The traceability audit passes 64/64 requirement -> phase -> plan -> verified, and 57/57 acceptance criteria are covered by a UAT item. cargo test --workspace --all-features: 61 suites, 732 tests, 0 failures. The cycle's planning artifacts are archived under .planning/_archive-v0.1.0/, with the per-phase narrative in .planning/ARCHIVE.md.
john added 209 commits 2026-08-23 03:21:11 +00:00
Extend CREATE_SQL with everything this phase fills, so the store shape
moves once rather than three times: session_meta gains agent_meta (D-04),
transcript_diverged (D-13) and project_pre_worktree (D-06/ING-05); runs
gains files_committed and files_failed, since files_seen alone cannot
carry walked/committed/failed once one row covers a whole pass (D-10);
and compaction_boundaries joins the derived set after turns, so reindex's
reverse drop order stays correct (D-08, D-21).

DERIVED_SCHEMA moves to 2; ARCHIVE_FORMAT stays at 1, because the bytes
in sessions do not change meaning and the archive table never migrates.

Every statement in CREATE_SQL is IF NOT EXISTS, so on an existing store
it creates the new table and adds no column. The additive bring-forward
is the other half, and it runs in Store::open rather than in reindex:
open executes CREATE_SQL only for a fresh store, and open_up_to_date is
the only caller that acts on rebuild_required, while status, verify and
every phase 3 reader open through Store::open alone - so a user who
upgrades the binary and runs status before any ingest would hit 'no such
column' on a healthy store. Adding a column is safe where rebuilding
derived tables is not, so the two split there: ALTER TABLE ADD COLUMN on
every open, the destructive rebuild left exactly where it is.

The cli.rs assertion that a refused open leaves meta.derived_schema at
'1' was a literal, not a property; it now compares against
DERIVED_SCHEMA.
Adds crates/verbatim-core/src/config.rs: verbatim.toml inside verbatim's
config directory, resolved the way data_dir resolves the store. D-15 is
binding on what it must NOT read - config.toml in that same directory is
the legacy tool's, holds base_dir = "/data/verbatim", and adopting it
would point the new store at the legacy data directory whose import
PROJECT.md defers - so the loader names one file and only that one. A
missing file yields the defaults; a file that exists and does not parse
is named with the parser's position rather than falling back, since
falling back walks a tree nobody asked for and honors no exclusion.

Roots are a list of Claude config directories, each of whose projects
subdirectory is the tree to walk, defaulting to ~/.claude. CLAUDE_CONFIG_DIR
replaces that default with the single directory it names; an explicit
roots list outranks it, because that list is verbatim's own configuration.

Exclusion is two entry points over one configured list, because the two
callers know two different things. The pre-open test has only the encoded
project directory name - cwd needs a parsed record and reading a record
means opening the file, which is what ING-08 forbids - so it is exact
equality on the encoded name plus the one fixed literal
--claude-worktrees- clause. It is not a prefix match: D-07 proved the
encoding lossy in exactly that spot, so an extension-tolerant rule cannot
tell a child directory from a hyphenated sibling and would silently
exclude a project the user never named. The worktree clause is not
optional either - D-06 folds a worktree cwd into its parent repo, 15 of
69 real project directories have that shape, and without it excluding a
repo walks and archives every worktree session and leaves the read path
filtering them out afterwards. The path-side test keeps full subtree
semantics on path components, where a real path makes the boundary exact.

toml is the first dependency added since phase 1: default features off,
parse and serde only, so the writer half stays out of a binary whose cold
start is the product. It pulls toml_parser, winnow, serde_core,
toml_datetime and serde_spanned - no async runtime, no HTTP client, no
thread pool.
Adds crates/verbatim-core/src/discover.rs. Filename decides, never record
shape (D-16): a <uuid>.jsonl directly inside a project directory and an
agent-*.jsonl anywhere below one are transcripts, and nothing else is
opened to find out - which is what keeps the four journal.jsonl files,
the 816 agent-*.meta.json files, the workflows/wf_*.json files and the
131 tool-results/ directories out of the archive at zero reads.

The walk is unbounded, because sidecars sit at depth 4 (781 real files)
AND at depth 6 under subagents/workflows/wf_*/ (41 real files), and a
depth limit drops those 41 in silence. It is sequential and single
threaded with no walkdir, glob or rayon (D-18). Each root is
canonicalized once and every entry joined onto it (D-17): the tree holds
zero symlinks while the root itself is a two-hop symlink chain, so root
canonicalization alone yields the session keys ingest::run already
produces, at one syscall instead of two thousand. Entries are sorted, so
two passes over one tree walk it identically; an unreadable directory is
named and skipped rather than fatal.

The config's encoded exclusion test runs on the project directory name
before it is descended into, so an excluded project is never listed
(D-22) - all 822 real sidecars sit beneath <project>/<sessionId>/, so
that one skip covers them too.

open_transcript is the one counted way to open a transcript, and the log
records PATHS rather than a count: AC5 asks for zero opens under a named
directory, and a scalar total could reach the expected number by opening
every excluded file and skipping an equal number elsewhere. It is
testkit-gated, so the shipped binary takes no branch.

Adds tests/fixtures/subagents/workflows/wf_demo/agent-deep.jsonl for the
depth-6 case, so a bounded walk fails on the synthetic corpus rather than
only against the real one.
Adds crates/verbatim-core/src/ingest/pass.rs. The lock is taken once and
the store opened once for the whole walk; the transactions stay per-file
(D-11), because a write transaction held across the walk stops MCP
readers - the reason redb was rejected - and per-file commits are the
granularity phase 1's crash harness already proved atomic, bounding what
a kill can lose to one file.

A per-file failure is recorded against its path and skipped, never
propagated (D-12). Both of phase 1's hard-error paths reach this loop:
Existing::read refusing a session archived with no session_meta row, and
read_tail refusing a file shorter than its watermark. Either one stopping
the pass would let a single damaged session wedge every future ingest of
every other transcript in a two-thousand-file tree, which is the failure
gate fix 3e5d9ff closed for reindex. ingest::run's single-file behaviour
and its refusals are untouched - a caller that named one file deserves
the error it asked about.

read_tail now opens through discover::open_transcript, so the pass's
opens are the number AC5 measures.

Bare `verbatim ingest` stops being misuse and becomes the tree pass. That
is an intended change to a shipped contract, and it obliges the test
isolation in the same commit: once bare ingest resolves configured roots,
a spawn helper that sets only VERBATIM_DATA_DIR walks the developer's
real ~/.claude - 2,000+ private transcripts, ~988 MB, on every cargo
test. Bench::run now sets VERBATIM_CONFIG_DIR at a temporary directory
holding no verbatim.toml and CLAUDE_CONFIG_DIR at a temporary tree, and
bare_ingest_walks_only_the_configured_temporary_root asserts every
archived session_key sits inside that tree.
Adds crates/verbatim-core/src/recover.rs as the one callable ING-03's
"recovery at the top of every run" names. It is two things and only two
(D-24). First reindex::open_up_to_date, which already ran on the ingest
path and brings a store older than this build forward. Second a bounded
consistency sweep over watermarks against what each session's committed
blob actually holds: a watermark past session_meta.uncompressed_len is
lowered to it so the next pass re-reads the gap, and a non-zero watermark
naming no archived session is removed because it claims bytes nothing
archived. Both repairs commit in one transaction and are named in the
returned report, so the pass can put them in its runs row.

The sweep reads uncompressed_len rather than decompressing, which is what
makes it one indexed join over ~2,000 rows instead of 988 MB of zstd -
cheap enough to run on every entry point, which is what "every run"
requires. A healthy store leaves it having read two queries and changed
no row.

There is no stale-lock handling and there must not be: the ingest lock is
an OS lock that dies with the process, so there is no stale lock to
recover from. A session archived with no session_meta row is deliberately
in neither query - that is the damaged state phase 1 refuses to guess at
and verify reports, and touching its watermark here would be the same
guess made somewhere quieter.

Called from BOTH entry points, under the lock, before any transcript is
read. ingest::run is a run: wiring only the pass would leave
`verbatim ingest <path.jsonl>` resuming from an offset the archive never
reached, and the gap would be lost from the blob in silence.
D-10: the runs row moves from per-file to per-pass. record_run inserts a
literal 1 for files_seen from inside the per-file transaction, which is
right for a caller that named one file and wrong for a tree walk - reused
unchanged it would write roughly 2,071 rows per hook-triggered pass, and
ING-09's "the last ingest run" would name one file rather than the pass.
So ingest_locked now takes who owns the row: ingest::run keeps writing it
inside the pass transaction (STOR-02, and phase 1's assertion that a
single-file pass moves sessions, turns, watermarks and runs together),
and the tree pass writes one row of its own after the walk carrying files
walked, committed and failed, bytes, turns and duration.

A pass that dies mid-walk still writes that row, in a transaction of its
own after the per-file transaction rolled back (D-14) - with no log file
by design, the row is the only place the failure can be seen. A pass that
walked a tree and found nothing new writes one too, because "the last
ingest run" has to move; only the single-file Outcome::UpToDate path
still writes nothing.

runs.error is this product's only textual channel, so it carries every
skipped file with its reason, every unreadable directory, every watermark
recovery repaired, and the failure that ended the pass. Null when there
is genuinely nothing to say.

The pass-level fault that makes D-14 testable is a marker FILE inside the
data directory rather than an environment variable: every test owns its
own data directory, so a fault aimed at one pass cannot reach a test
running beside it, and an env var is process-global and would.
Adds the fourth subcommand (ING-09). Every number comes out of the store
and none of it out of a log file, because there is no log file by design:
runs is where a pass's failures live, so status prints the store path and
its on-disk footprint, session and turn counts, how many transcripts
carry a watermark and how many bytes those cover, and the last runs row
with its start time, duration, file counts, bytes, turns and its error in
full when non-null.

It takes no ingest lock. Reading while a pass runs must work - that is
what WAL is on for - and a status command that blocked behind an ingest
would be useless exactly when it is wanted. An empty store is exit 0 with
zeroes rather than an error: that is what a machine looks like before the
first hook has ever fired. No --json flag, because RCL-06's stable shapes
are phase 3 and an unadvertised flag accepted now becomes a shape to
keep; any argument at all is misuse, exit 2, empty stdout.

rusqlite is deliberately not named in the command: the binary crate does
not depend on it, and a read command is not a reason to put a second SQL
dependency in the hook path's build.

Also drops a duplicated path from the one channel a user reads. Error::Io
already renders as "{path}: {source}", so prefixing every reason with its
path printed the path twice in runs.error and on the pass's stderr.
Selecting every blob into one Vec held the whole archive in memory - 895 MB
today, growing with every session ever archived - inside the ingest lock, on
the first hook-spawned pass after an upgrade. An allocation failure there
rolls the rebuild back, leaves derived_schema behind, and takes the pass down
before record_pass can write the runs row that would have said so, so the
next hook repeats it identically and silently.

Keys come back in one query, each blob is fetched by its indexed session_no
inside the loop, and the resident set is now the largest single session.
Project identity resolves from the record's cwd and never from the encoded
project directory name (D-07). Git runs only when the cwd directory still
exists (D-05); every other case - no directory, no repository, no git, a
non-zero exit, a spawn slower than the two-second budget - degrades to the
cwd normalized as a string, because degrading is the common case: git
answers for 17 of the 63 distinct cwd values in the real corpus.

Answers are memoized per cwd for the lifetime of a resolver, which is one
pass: 1,253 transcripts carry 63 distinct values.
A cwd of the form <repo>/.claude/worktrees/<name> keys to <repo> (D-06), a
path-shaped rule rather than git rev-parse --git-common-dir because all 13
real worktree cwd values name deleted directories. The rule runs before the
on-disk git branch, and the repository then goes through the same resolver,
so a repo cwd and a worktree cwd beneath it land on one key.

session_meta.project holds the folded key and project_pre_worktree the path
it came from, so deleting the worktree directory cannot un-key an archived
session. The resolver is threaded through ingest_locked from both callers -
one per pass - and the project coalesces onto what is stored, so a tail pass
never revises the project the first cwd established (D-20).
continues_from took the first foreign session_id in the scan with no
comparison against the file's own sessionId. Measured over 1,253 real
top-level transcripts, 433 carry a session_id of which 253 carry only their
own - so one in five archived sessions claimed to continue from itself, and
every phase 3 or phase 5 thread walk would have cycled or special-cased the
self-edge at each read site.

The rules move into crates/verbatim-core/src/lineage.rs, which also records
which namespace the column is in: continues_from holds a session id while
sessions is keyed on file identity, so a resolver crossing between them must
expect zero, one or many rows. Nothing constrains continuation to a single
successor (D-02) and the column keeps no foreign key (D-19). The parentUuid
fallback is unchanged apart from the same self-link test.
session_meta.parent_session_key was created by phase 1 and left null. It is
filled from the sidecar's path (D-03): the directory above the nearest
subagents ancestor is named for a top-level transcript's session id, so the
parent is <project>/<sessionId>.jsonl, at either real sidecar depth.

Records cannot answer this - 818 of 818 agent-*.jsonl files report their
parent's sessionId as their own, so keying on the record would link a
sidecar to itself or to whichever session shares the id. The key is stored
whether or not the parent has been ingested, for the same reason
continues_from carries no foreign key. Without this column phase 3 has no
way to filter subagent turns out of a brief.
Ingesting a sidecar reads the agent-*.meta.json beside it - same directory,
same stem - and stores its bytes in session_meta.agent_meta with no parsing
into typed columns (D-04). The format is undocumented and may drift, and
bytes keep D-13 intact while letting phase 3 extract description for ranking
without a reingest.

The read goes through the counted open, like every other read of the
transcript tree, and happens once per session rather than once per pass. A
missing meta file is normal - 816 exist against 818 sidecars - so the column
stays null and nothing is recorded. The file is not added to discovery: the
filename filter excludes it on the extension and must keep doing so.
config::visible is the one session-listing read entry point, and every
future read path is required to go through it rather than querying
session_meta directly. It re-applies the configured exclusions on every read
against both project keys - the folded parent repo and the pre-worktree path
- because either one alone leaks and a user may reasonably exclude either.

No per-session flag is written at ingest (D-23): a flag says what was true
when the session was archived, and the case that matters is the session
archived before its project was excluded. A session with a null project
stays visible, since nothing can say it is excluded.

verbatim status counts sessions, turns and watermarks through the boundary,
so the phase's only read command is also the proof the boundary is real, and
names the exclusions when a count drops because of them.
Folding a cwd up to its git toplevel discarded the unfolded path, so a
session whose cwd was a subdirectory of a repo was keyed only under the repo.
The read-side exclusion test compares against project and project_pre_worktree,
and neither equalled the path the user excluded - the only spelling that names
that project directory is the unfolded one. Excluding it hid nothing, and
status went on listing its sessions while printing the exclusion as active.

The real corpus has this case: sessions with cwd /data/code/jcrenshaw.dev/.claude
under the repo /data/code/jcrenshaw.dev. Every subdirectory of a repository is
it, not just worktrees, which is why the pre-image belongs on both folds.
The pre-open test had only the encoded project directory name, where the
separator and a literal - or . are the same character. D-09 asked for a
segment-boundary match and for -data-projects-cadence-research to be spared
from -data-projects-cadence, which cannot both hold in the encoded space: a
child and a hyphenated sibling encode identically. The implementation resolved
that by matching one fixed worktree literal, so every other subdirectory of an
excluded repository was walked, opened and archived, then hidden on read. The
real corpus has such a directory, and ING-08 asks for zero opens rather than
zero rows.

The ambiguity is now resolved outside the encoded space: a name that extends an
excluded one is confirmed against the filesystem by searching for a real
directory under the excluded path that encodes to it, pruned to the one branch
that can match and bounded. Directory entries are read; no transcript is
opened, which is what AC5 and D-22 constrain.

Unresolvable means excluded - a gone or unreadable directory, or a tree past
the budget. A project that goes unarchived can be archived later; bytes the
user said never to read cannot be unread. Where a child and a sibling both
exist they are one name and the child wins, and excludes_path still tells the
two apart wherever a real path is available, so nothing archived is lost.
Resolving an ambiguous encoded name against the filesystem answered "not a
child" whenever the search completed without finding the leaf. Deleted
subdirectories are the common case, and worktrees are almost all of it: the
worktree directory goes away when the worktree does while the worktrees
directory survives empty, so every archived worktree session had a live prefix
and a dead leaf. All three .claude/worktrees directories on this machine are
empty, and nine of the ten -data-code-cadence* project directories are
worktrees - so the search reopened exactly what the fixed worktree literal it
replaced had covered.

Descending a branch now means the leading components are real and the leaf is
merely gone, which is unresolved and therefore excluded. Only a search that
descended nothing at all answers negative, which is what a genuinely different
path that encodes the same way looks like.

Unreadable entries and symlinked branches are unresolved for the same reason,
rather than a negative reached by not looking - and a symlink is still never
followed, so a link loop cannot stall a pass. Typing is deferred until an entry
could actually be the name, so an unreadable file elsewhere in the tree decides
nothing.
`Record::parse` now carries a record's `subtype` and the raw bytes of its
`compactMetadata` object. Raw bytes rather than a parsed structure (D-08): the
upstream semantics of `preservedMessages.uuids` versus `allUuids` versus
`preservedSegment` are unsettled, exactly one `compact_boundary` record exists
in 300,556 measured records, and its own token counts contradict the design
brief's reading of it - so storing the bytes is what makes a wrong reading
fixable in phase 5 without a reingest.

The bytes come out of the line through a structural walk of the top-level
object, never a substring search and never a re-serialization of the parsed
value: a round trip through serde_json renormalizes whitespace, escapes and
number formatting, which would quietly make "verbatim" mean "equivalent".

No new record class and no classification rule changed (D-21): `system` is
already a turn type and the real boundary record carries both `uuid` and
`timestamp`, so D-03 had already made it a turn.

`tests/fixtures/session-compacted.jsonl` is three turns followed by a
real-shaped boundary, in the same key order as the one real boundary record in
the corpus, with `preservedMessages.uuids` a proper subset of its `allUuids`.
TurnRow carries the record's subtype and its compactMetadata bytes, so
derive_turn writes the compaction_boundaries row without re-parsing the
line - one owner of those fields, one write site (STOR-04, D-21). Both
TurnRow construction sites pass them through, ingest and reindex, so a
rebuild reproduces every boundary row from the blob alone. The row is
cleared before it is written, like entities and paths, which is what
makes a re-derive idempotent by construction.

The metadata goes in verbatim and nothing reads it (D-08).

tests/reindex.rs dropped the derived tables in creation order, which
was harmless only while every child table was empty: with a boundary
row present, DROP TABLE turns and DELETE FROM turns both fail the
foreign key the bundled SQLite enforces. Drop in reverse, and delete
the child rows first.
D-13 gets its signal. read_tail still refuses a file shorter than the
bytes the archive holds - re-reading from offset 0 would overwrite them
on the strength of an append-only property observed over one corpus and
documented nowhere - but it now refuses with Error::TranscriptDiverged
rather than an io error carrying a message. That variant is the only
thing the pass matches on: path_key raises the same InvalidData kind for
a non-UTF-8 transcript path, so the kind discriminates nothing and the
message text would break the first time the wording moved.

The pass flags that session in a transaction of its own, the file's own
having already rolled back, and verify reports the divergence as a
per-session failure attributed by session_key like every other - saying
the archive was left untouched, because the reflex verify provokes is to
re-run ingest and that is the one thing that must not happen here.

The flag clears the moment read_tail succeeds again, which is before the
UpToDate early return: a file restored to its original length commits
nothing, and clearing inside the pass transaction would never run. It is
guarded on the flag being set, so a walk of two thousand healthy files
issues no write at all.
AC6. ingest::fault gains a pass-level point: the walk stalls once a
given number of transcripts have committed, so a kill lands between two
files deterministically instead of where a timed race happens to fall.
The per-file points still fire underneath it, in whichever file is in
flight, so a kill can now be aimed at a (file, moment) pair rather than
only at a moment. It is not in fault::POINTS - it needs a count as well
as a name - and it is inert without the testkit feature like the rest.

The harness runs that spread twice over a four-transcript tree with a
sidecar in it: once on a first pass, once on an append pass, twelve
kills each, every one resumed to completion and required to land on the
byte-identical store an uninterrupted pass reaches. The append half is
what phase 1 flagged and could not do - every kill it ran went into an
empty data directory, so blob::append had never been interrupted once.
An iteration starts from a copy of the pre-append store, because the
files have already grown by then and it cannot be rebuilt.

check_invariants now asserts the stronger of the two watermark
properties: a watermark must land at a record boundary, so the byte
before it in the committed blob is a newline. Containment alone accepts
a watermark one byte short of one, and a pass resuming from there would
archive the tail of a record as a record. The doc comment now says which
of the five properties each check is, and a second negative test moves a
watermark off a boundary and requires the check to object - and to
object about the boundary rather than about containment.

Every spawn now sets VERBATIM_CONFIG_DIR and CLAUDE_CONFIG_DIR as well
as VERBATIM_DATA_DIR. A bare ingest walks the configured roots, so a
spawn that set only the data directory would resolve the developer's
real config and walk the live tree.
AC1 and AC2 against the tree the product exists for, gated on
VERBATIM_TEST_CORPUS naming a real Claude config directory and skipping
loudly when it is unset, since the tree is private and cannot live in
the repo. The run writes into a temporary data directory and only reads
the corpus, and it points VERBATIM_CONFIG_DIR at an empty temporary
directory as well: the Claude-directory override replaces only the
default root, so an explicit roots list in a real verbatim.toml would
win over it and the run would silently walk a tree nobody asked it to
measure.

AC1 is a set relation over the intersection of a walk taken before the
pass and one taken after, because the corpus is live - the session
running the test appends to its own transcript and spawns sidecars while
the pass walks - so a before-only count races the pass and fails on
correct behaviour. The unarchived paths in that intersection are excused
by name and only when the file holds no complete line, which is the one
case ingest_locked writes no sessions row for.

That walk is deliberately not discover::discover. Reusing the product's
own filter to decide which files are transcripts would let a walk that
missed the sidecars at depth 6 miss them on both sides and pass.

Zero unparseable lines is checked by decompressing every archived blob
and handing every line to serde_json, which is a claim the product code
cannot make for the test: Record::parse keeps a non-JSON line as a
record rather than erroring, on purpose. Record types are counted and
printed, never asserted as a closed set (D-25). The pass wall time and
the derived-rebuild time a bumped DERIVED_SCHEMA triggers are printed,
since D-24 names that rebuild as the scale consequence to plan around.

testkit gains the variable, the gate helper and LineSurvey - in the
library because crates/verbatim has no serde_json dev-dependency and its
manifest is outside this plan's lease.
The forced-rebuild measurement called Store::open, which by design only
reports that a rebuild is due and never performs one - so it timed an open
that did nothing and printed the number as a rebuild of 2,129 sessions. It
read 577us, which is roughly what doing nothing costs.

open_up_to_date is the caller that acts on the outcome, and the store is now
asserted stamped up to date afterwards so a future regression to a no-op open
fails rather than prints. The same measurement over the real corpus reads
50.4s, which is the figure the ingest lock is actually held for on the first
hook-spawned pass after a DERIVED_SCHEMA bump.
read_tail returning proves the file is no shorter than its watermark and
nothing whatever about its contents, so the flag cleared for a transcript that
had been truncated and then written back past the old watermark. The pass then
appended at the stale offset: the archived stream ended up holding the first
half of one file, a hole where the bytes it was told about had gone, and a
fragment of a record spliced on at the seam - permanently, with verify
reporting the session clean because the blob still matched its own checksum.

The archived prefix is now compared against the file's before the flag comes
off, and a mismatch is refused and left flagged like the short case. The cost
is one decompression and one re-read, paid only by a session that is already
flagged; the steady state reads the flag, finds it clear, and does neither.

The refusal gets its own error variant. TranscriptDiverged's message is about
a length, and this file's length is right - reporting it that way would name a
number the user could check and find correct.
`verbatim ingest <path>` applied no exclusion test at all, and an
exclusion string spelled with a trailing separator, a `..` or a leading
`~` disabled the pre-open test while `excludes_path` still hid the rows.
Both are read-then-filter, which ING-08 forbids.

The two predicates now read one normalized string, so a spelling cannot
make them disagree; `ingest::run` loads the config and refuses a
transcript inside an excluded project before it takes the lock; and a
symlinked transcript is resolved before it is yielded, since the name the
pre-open test saw was the link's and `File::open` follows it.
D-08 measured the one real `compact_boundary` in 300,556 records: its
uuid lists describe the preserved segment against 38,064 dropped tokens,
so they cannot enumerate what fell out. The complement is a query, and
INJ-05 owns it. ING-06 and ROADMAP criterion 4 still read as though
phase 2 stores the dropped set.
Deep verifier auto-verified all seven acceptance criteria plus the
cold-start smoke item and raised two gaps; both are fixed and retested.
Roadmap box and the six ING rows flip to Complete.
Four synthetic transcripts shaped to what this phase must prove, registered in
testkit::TRANSCRIPT_FIXTURES and documented in the fixtures README.

session-recall.jsonl carries AC1's two probes - a SearchManager turn and an
src/worker/S.ts turn - and AC3's structured-versus-prose pair: a Read tool_use
whose file_path is docs/RETRY.md against a separate assistant turn naming that
same path in prose and nothing else.

session-errors-a/b.jsonl are two sessions holding the two stderr pairs AC2 is
about. One pair differs only in a :line:col suffix, a 0x address, an ISO
timestamp and a UUID; the other only in a bare integer, which D-04 measured must
NOT be stripped. The a-file ends with an interrupted result carrying an empty
stderr and no error flag, so 'an interruption is not an error' has a control.

subagents/agent-echo.jsonl repeats session-recall.jsonl's last turn word for
word, which is what gives D-07's 'sorts below at equal score' two genuinely
equal BM25 scores rather than a tie that never happens.

All four carry {{ROOT}} where a real transcript carries an absolute cwd, over two
projects; testkit::copy_rooted_fixture_into rewrites it to a root the test owns
and creates the project directory. The phase 1 and 2 fixtures hardcode
/data/code/verbatim, which makes a project-scoped assertion true only on a
checkout at that path and true for the wrong reason on that one.
D-01. crates/verbatim-core/src/index/text.rs::project reads string leaves out of
exactly four subtrees of a record - message.content (text blocks, a tool_use's
name and input leaves, a tool_result's content), the top-level content when it
is a string, the top-level toolUseResult, and the top-level attachment - and
emits no key and no record-type literal. derive_turn parses the record once and
hands the value in; a record that will not parse projects to empty text, never
to the raw line, so the fallback cannot put scaffolding back.

Recursion depth and projected bytes are both bounded: input and toolUseResult
are tool-authored JSON of no fixed shape, and phase 1 measured records up to
205 KB.

DERIVED_SCHEMA 2 -> 3 (D-17): the body changed shape, so the first ingest after
the upgrade rebuilds every archived session from its blobs. Store::open is
untouched - the rebuild belongs to reindex::open_up_to_date and to nothing else
(D-18).

testkit::FIXED_QUERIES loses assistant, attachment and restart, which matched
"type":"assistant", the attachment key and gitBranch. Phase 1's AC4 comparison
had been running over hits nobody would ever search for. The five entries now
reach through five different arms of the projection.

New tests/index.rs: every JSON key and record-type literal returns zero hits
while the archived records demonstrably carry it, every projected subtree is
reachable, every fixed query matches, and an unparseable record leaves an empty
row rather than its bytes.
RCL-01. index::project now appends expansion tokens to the projected body, in
one FTS column: a second column would change the table declaration and the
contentless_delete=1 rebuild property with it.

Two pure functions. separator_components is the snake, kebab and path rule -
one function for three, because on unicode61 they ARE one rule and writing them
as three would be three spellings of is_alphanumeric pretending to be
independent. case_components is the camelCase/PascalCase rule, the only one that
changes recall (D-13, measured): MATCH 'manager' does not return a SearchManager
row under unicode61, while MATCH 'worker' returns src/worker/S.ts with no
expansion at all.

The separator rule seeds the dedup set and the case rule fills the output, so
nothing already reachable is emitted twice - a repeat would double a term's
frequency and buy nothing under BM25 but body bytes. Output order is text order
and the dedup is exact, so the same body always expands to the same tokens,
which is what a byte-identical rebuild rests on.

Digits stay attached to their piece: no bare-integer handling is specified
anywhere in this phase, and splitting them would index every version number
twice. Expansion carries its own byte bound on top of the projection's.

Two tests in tests/index.rs. The falsifying one is 'manager' finds
SearchManager; 'worker' finds src/worker/S.ts passes with no expansion written
at all, which is how a phase shipping only path expansion would look half
correct on AC1 and be none of it.
RCL-02, on D-02's three subtrees and nothing else: message.content's tool_use
and tool_result blocks and the top-level toolUseResult. No join between a call
and its result - that needs a tool_use_id index and a second pass, which breaks
the one-record-in / one-row-out shape ingest and rebuild share. Nothing from
prose, and nothing from attachment even though the projection reads it.

tool is the tool_use name, the same value parse::record::tool_name lifts into
turns.tool_name, so a kind='tool' filter and the column cannot disagree. path
comes from file_path/path/notebook_path inputs, toolUseResult.filePath and the
separator-carrying argv words of a Bash command, kept exactly as written minus
quotes and a trailing :line:col - never canonicalized, because 52% of the
corpus's cwd directories are gone and a path that resolves today would normalize
differently tomorrow. command is a Bash program token by basename, skipping
NAME=value prefixes. symbol is identifier-shaped tokens from a Grep pattern and
an Edit's old_string/new_string only, where identifier-shaped means an internal
case or underscore boundary - a rule that admits SearchManager and refuses every
ordinary English word, which is what keeps 'not from prose' true for a field
that can hold prose.

Entities are deduplicated on (kind, value) in record-walk order, so the list
depends on the record's bytes and on nothing about the store. Every path entity
also gets its paths row. Nothing is rejected for being common (RCL-04).

derive_turn writes them from the value it already parsed, after clearing both
tables at the turn id, so a re-derive is idempotent by construction.

tests/derive.rs's phase 1 assertion that neither table ever gains a row is
replaced by the claim that survives it: the seam owns those rows, the counts for
session-basic are exact, and re-deriving does not double them. tests/reindex.rs
needed two repairs the new rows force - a delete order that respects the two new
foreign keys, and an invented-row assertion that names the invented row rather
than requiring an empty table.
`session-basic.jsonl` carries a Bash tool_use, so task 4's extractor fills
`entities` on the same pass that moves the session, its turns and its
watermark. `paths` stays absent: that command line names no file.
D-03's signal and nothing else: a `tool_result` block with `is_error: true`,
or a non-empty `toolUseResult.stderr`. No exit code is looked for - the
transcript carries none - and `interrupted` is not a failure.

D-04's normalization: UUIDs, ISO-8601 timestamps, `0x` addresses and
`:line:col` suffixes each collapse to a fixed placeholder, whitespace runs
collapse to one space, and bare integers are deliberately kept, because
stripping them was measured to merge genuinely different failures (39 recurring
values against 46). Values are bounded: an entity value is an exact-match key,
and a whole suite's stderr is a key nobody looks up.
D-15 measured p50 3, p90 10, p99 38 and max 146 entities per turn over a
300-file sample, so 48 binds the tail and leaves the common turn whole. The cap
counts kept entities, is applied in record-walk order, and is never applied by
frequency or recency: a data-dependent cut would make the rebuild AC3 asserts
depend on what else happened to be in the store.

A bound on rows, not a stop-list - RCL-04 forbids rejecting a value at index
time for being common, and a value dropped by position is not one rejected for
what it is.
`query_set_json` covered `turns_fts` rowids, a turn range and a turn-id lookup,
so an extractor that rebuilt `entities` to different values - or to none at all
- left the captured JSON byte-identical and AC3 asserting nothing about the two
tables it names. Both are now in it, ordered by their own columns rather than by
rowid, so a re-derive that emitted the same set in a different sequence still
compares equal and a re-derive that emitted different VALUES does not.

The existing FTS and turn sections are untouched, so phase 1's AC4 comparison
keeps meaning what it meant. The rebuild test now states its premise for the new
sections: both non-empty, and every one of the five kinds present.
AC3's corpus-level half, on the store the existing single pass already
produced: every one of the five kinds emitted, no turn over the cap, and every
`paths` row backed by the `path` entity it came from.

Every number is printed and none is asserted - the tree is live and grows during
the run, which is why every assertion in this file is a set relation. Measured
on 2,163 transcripts: path 149,684, tool 65,247, command 39,074, symbol 22,239,
error 3,311, and p50 3 / p90 7 / p99 16 / max 48 entities per emitting turn
against a cap of 48, so the cap binds the tail exactly as D-15 intended.
D-02's key measurement counted `filePath` at the top level of
`toolUseResult` only, and the extractor followed it. `Read` nests its
path at `file.filePath` one level down, so the result-side turn of every
`Read` - the tool that names a file more often than any other - emitted
no path entity and no `paths` row.

Remeasured over a 400-file sample: 8,719 `toolUseResult` objects, with
`file.filePath` present 1,238 times against the top-level key's 1,171.
Reading one of the two dropped slightly more than half the result-side
paths in the archive, which is the table RCL-02 exists for.

Both keys are read now and neither is preferred; a record carrying the
same value in both is deduped by `push` on `(kind, value)` like any
other repeat. The test asserts the Read shape against the Write/Edit
control that already worked, and fails on the previous extractor.
`path_words` split a command line on whitespace only, so the punctuation
around a path was stored as part of the key: `cd /a/b; make` wrote
`/a/b;`, `cargo test 2>/dev/null` wrote `2>/dev/null`, and a lookup for
either real path missed the turn that named it. Measured over a 400-file
sample, 9,816 of 21,061 path-shaped words (46.6%) carried a shell
control character, so this was the common case rather than the tail -
and `paths` is the table RCL-02 exists for.

Every word is now split again on the shell's own control characters
(`;&|<>()`), and quotes are trimmed from either end whether or not the
partner survived the split. A surviving word is dropped when it carries
an expansion, a glob or a URL scheme: `${ROOT}/x`, `src/*.rs` and
`https://example.com/x` name something other than one file on disk. That
is not RCL-04's forbidden rejection-for-being-common - a glob is not a
path that happens to be popular, it is not a path.

A substitution opened in one word and closed in the next stays past what
a split can see (151 of the 21,061 measured words). The test states that
outcome rather than asserting it away; separating it needs a parser.
D-10: `search`, `show`, `sessions` and `mcp` may not create a store as a
side effect of a read, and a server advertising `readOnlyHint` may not run
DDL on connect. `Store::open_read_only` opens an existing store with
SQLITE_OPEN_READ_ONLY and does nothing else to it - no `create_dir_all`, no
`initialize`, no `bring_forward`, no pragma - while still running the version
gate, because `rebuild_required` is what lets a read command say the store
predates this build instead of silently querying an old-shape index (D-18).

Dropping the bring-forward means its columns are not guaranteed present, so
their absence is measured once at open and reported through
`missing_columns()` rather than surfacing as `no such column` from the middle
of a search. A missing store is `Error::StoreNotFound` and any other failure
to open is `Error::StoreUnreadable`: variants rather than formatted strings,
because RCL-10 renders both as an empty result with a reason and a caller
must not match on message text to tell them apart.

`gate` is split so the read-only path runs the same rule on the connection it
already holds instead of opening a second one. `Store::open` is untouched.
D-09: a raw query never reaches `MATCH`. Measured on sqlite 3.53.4,
`src/worker/S.ts` fails with `fts5: syntax error near "/"`, `foo AND (bar`
and `"unbalanced` fail the same way, `*` is an unknown special query and an
empty string is a syntax error - none of them returns an empty result. RCL-06
forbids a non-zero exit for a query that simply found nothing, and searching
for a path is this phase's headline case, so the most natural command in the
product is exactly the one that would have failed.

`recall::Query` splits the user's string on the separators `unicode61`
already tokenizes on, emits each surviving token as a quoted fts5 string and
conjoins them explicitly, so there is no operator left to be unbalanced. A
query that reduces to no tokens yields no expression at all, because `MATCH ''`
is itself an error. The token count is bounded so a pasted stack trace cannot
build a thousand-term expression; truncation is safe only because the terms
are conjoined, which makes a cut query broader rather than wrong.

Query tokens are deliberately not expanded: `index::expand` already ran over
the indexed text, so a query for `manager` matches a stored `SearchManager`
turn directly, and expanding here as well would widen a specific query into
its own components.

Tests assert the control first - each hostile string really is an fts5 error
as written - before asserting the query layer answers it.
The search this phase exists for: `turns_fts MATCH` joined to `turns` and
LEFT-joined to `session_meta`, returning per hit the turn id, session key,
timestamp, project and record type.

`bm25()` is negative and more negative is a better match, so the sign is
flipped exactly once - in the projection - and every ordering is written
against the negated value. A sign error here ranks the worst match first and
every test that only checks "hits came back" still passes, so the test asserts
the sign itself rather than the order alone.

The join to `session_meta` is a LEFT join because an inner one drops every
turn of a session with no meta row, the damage `config::visible::sessions`
deliberately keeps visible. The order is total - relevance, then D-07's rule
that a sidechain turn sorts last at equal score, then the newer turn, then the
lower id - so two runs over an unchanged store agree, which is what phase 5's
rank-1..3 threshold needs. Sidechain turns stay in by default: 39.4% of turns
are sidechain and excluding them would hide a research subagent's whole output.

`MAX_RESULTS` is the server-side budget RCL-10 needs; a caller may lower it
and may not raise it. Proving that needed a session larger than the 47-turn
fixture corpus, so the test writes one and ingests it through the ordinary
path.
Two rules over one projection. `config::visible::projects` returns the
distinct `(project, project_pre_worktree)` pairs `session_meta` carries and
what the config says about each, and every read path scopes and filters on
that rather than on `visible::sessions()`, whose per-session `count(*)` costs
6.2-6.8 ms against 0.56-0.58 ms for the same join projecting only the keys
(D-21). The whole phase 5 budget is single-digit milliseconds, so paying for a
turn count nothing reads would spend it before the FTS query started.

Exclusion is applied to both columns, because either alone leaks a worktree
session, and a session whose `project` is null stays visible because nothing
can say it is excluded - which is why the `NOT IN` clauses carry an explicit
null guard: SQL's `NULL NOT IN (...)` is NULL, not true, and would have
dropped exactly that session. An exclusion covering the filesystem root
short-circuits before any query runs.

Scoping is a longest-prefix match on path components of a caller-supplied
directory against those stored keys (D-12), never a git call and never a fresh
derivation, because a rule that disagrees with what ingest wrote returns zero
hits inside a worktree. A hit on `project_pre_worktree` resolves back to that
row's `project` first: phase 2 D-06 folded a repo and its worktree into one
key on purpose. `*` drops the project filter and keeps exclusion; a directory
no archived project covers returns empty with a `Reason`, a value rather than
message text, because the alternative is silently searching everything.

`Config::excludes_path` and the new scoping rule now share one `covers`
predicate, so "is this path inside that one" cannot come to mean two things.

`idx_session_meta_project` joins `CREATE_SQL`, where D-19 says it reaches an
existing store only because `reindex` runs the whole batch and this phase's
`DERIVED_SCHEMA` bump forces that reindex.
RCL-07's filters, each independently optional and combining conjunctively:
`tool` against `turns.tool_name` (D-16 measured exactly one `tool_use` block
per record, so the column is the whole filter), `kind` against
`turns.record_type`, one or more paths against the `paths` table, and a
`since` / `until` window against `turns.ts`.

The path filter reads the `paths` table, which makes it structural rather than
textual: the `Read` that opened `docs/RETRY.md` is a hit and the sentence about
what was in the file is not, though both match the query as text. It is an
EXISTS rather than a join, so a turn carrying two of the named paths is one hit
instead of two and costs one excerpt read instead of two.

The window is a lexicographic text comparison with no date parsing on the
stored side (D-23): one timestamp shape across 22,412 sampled turns, UTC only,
and `turns(ts)` already indexed. A bare `YYYY-MM-DD` is extended to the first
instant of the day for `since` and the last for `until` - unextended, `until
2026-08-12` sorts before every timestamp on that day and returns nothing from
it. Any other shape is `Error::InvalidTimeFilter` rather than a comparison:
every string orders against every other, so a malformed bound would return a
plausible wrong answer and nothing would say so.
RCL-04's query-time half. The match is between the query and a whole stored
`entities.value_norm`, never between one query token and a whole value: `path`
and `error` values are multi-token by construction, so a token-equality rule
could never fire for the two kinds this phase headlines. A value matches when
every one of its own tokens is in the query, and exact token-sequence equality
is the strongest case and counts double.

Each matched value is weighted by how rare it is - `ln(1 + turns / distinct
turns carrying it)`, off the index `idx_entities_lookup` already covers - and
the contribution is added to the relevance of every candidate turn carrying it.
Nothing is rejected and there is no stop-list: a value half the archive carries
still matches, it just moves a hit almost not at all, which is why RCL-04 puts
this at query time and forbids it at index time.

The weighting can only re-order rows it was given, so the SQL now returns a
candidate pool four times the maximum and the caller's limit is applied after
the re-rank; the same total order is written twice, in SQL for the candidates
and in Rust for the final sort, because SQL cannot see a score computed from a
second table's document frequencies. `Hit::entity_score` reports the
contribution, which is what lets a test assert the reordering rather than the
outcome agreeing with bm25: the test finds a pair where the text score ranks
the turn that merely says `cargo` above the turn that ran it, and the entity
weight puts them back in the right order.
D-05: never from FTS5. Measured on sqlite 3.53.4 against the contentless
table, `snippet()` returns an empty string with exit 0 while `bm25()` still
ranks, so an excerpt built from it would validate against the documented shape,
pass every schema test and tell the reader nothing.

The bytes come out of the session blob at the `stream_offset` and `byte_len`
already on the turn row and go through the same `index::text::project` ingest
indexed them with, so the excerpt is prose rather than a JSON line and there is
one definition of what a turn said. The window is bounded and cut around the
first query token with an explicit elision marker, in characters rather than
bytes, falling back to the head of the projection for an entity-only hit.

Hits are grouped by session and each session's blob is selected once (D-20):
rusqlite's incremental blob I/O is behind a feature this workspace does not
enable, so one turn read materializes a whole session, and phase 1 measured
sessions at 10.1 MB uncompressed. `Response::reads` reports the count, an
instrument in the normal build for the same reason `BlobReader`'s block counter
is - "read once per session" is a claim about a number.

Excerpts are attached after the truncation, never before, because the candidate
pool is four times what the caller asked for. A blob that will not open or a
record that is not JSON costs that one hit its excerpt and nothing else:
`verbatim verify` is what reports archive damage, and failing the search would
take every undamaged session's results down with it.
RCL-08: given a turn and a count before and after, the surrounding turns of the
same session, in `turn_seq` order and never in timestamp order (D-22). 31 of 62
sampled real transcripts carry a record whose timestamp goes backwards, so a
timestamp sort would show a reply before the prompt it answers in half of all
sessions; the test's fixture session runs its clock backwards on purpose, so
the two orders disagree and only one of them can pass.

The window stops at the session (D-06). It does not follow `continues_from` and
it does not follow `parent_session_key`: continuation is a fan-out with zero,
one or many successors, 2 of 173 real files name a predecessor never ingested,
and a lineage walk here could return turns from a conversation that never
happened. Instead it says which end it stopped at and hands back the
`continues_from` link unfollowed, so a caller can decide to make a second call.

Exclusion is applied on the same projection the search scopes through, because
a context call that answered for an excluded project would be the read-path
half of exclusion leaking through a second door. A turn the archive does not
hold is a reason as well, not a panic.

Each turn carries its own text, projected by the one rule the index and the
excerpts already use, out of a single decompressed copy of the session - which
a context window can always do, because it is one session by definition.
`longest_prefix` scored a hit on `project` and a hit on
`project_pre_worktree` identically and broke the tie by the projection's
`ORDER BY`. One directory is routinely both: a machine that ingested
from a worktree before phase 2 folded the two keys together and again
after carries a row keyed on the worktree path and a later row carrying
that same path as its pre-folding key.

Both hits then cover the directory at the same depth, so the tie fell to
whichever project string sorted first. A user standing in
`/home/u/proj` resolved to `/home/u/main` - handed another project's
turns while their own stayed invisible. Deterministic, and
deterministically the wrong project, which is a scoping failure rather
than a ranking one.

A direct `project` hit now outranks a `project_pre_worktree` hit at
equal depth. Ties at the same rank still fall to the projection's own
ordering, so two runs against an unchanged store still agree. The
control asserts the folded key keeps resolving to itself, which is the
case worktree folding exists for.
Two findings from the blocking risk_surface gate on plan 2, both of them
a read returning something other than what the caller asked for.

**A store predating `project_pre_worktree` errored on every read.** The
column arrived in phase 2, `bring_forward` adds it, and only
`Store::open` runs `bring_forward` - while every read command opens
read-only by D-10. Upgrading the binary and searching before the next
ingest was enough to reach that window, and there `visible::projects`
named a column the file did not have. Since `scope::resolve` is the
first statement of both `search::run` and `context::window`, every read
returned a raw `no such column`: exactly the failure `missing_columns`
exists to prevent, and the degraded read D-18 asks for. The projection
now selects `NULL` for the absent column, which degrades the way the
store already has - a file with no pre-folding key has none to scope or
exclude on - and `context::window` names it through the same helper.

**The context window enforced exclusion but not scoping.** It resolved
`Scope::Everything` unconditionally and took no scope argument, so
RCL-10's auto-scoping could not be satisfied by any caller, including
the `recall_context` tool PLAN-4 builds on it. Turn ids are densely
enumerable (`session_no << 24 | turn_seq`), so a client walking ids read
whole conversations out of projects it never stood in - the second door
the module doc already closes for exclusion. It now takes a `&Scope` and
answers an out-of-scope anchor as `NoSuchTurn`, which is true from the
caller's scope and does not confirm that another project holds the id.
RCL-06's envelope: {command, ok, reason, data}, built as serde_json values
and serialized, never assembled with format!. An excerpt cut out of a
transcript carries quotes, backslashes and control bytes, and a hand-rolled
writer fails on exactly the turns most worth reading while validating fine
on the ones that are not.

D-24 retrofits the flag onto the three commands phase 1 and 2 shipped, in
the phase that owns the contract rather than beside install in phase 4
where something is already scripted against a shape. `no_more_arguments`
gives way to `cmd::json_flag`: a subcommand still rejects every argument it
was not written for, and --json is now one it was. Plain output is
unchanged - verify and reindex still write nothing to stdout on a clean
store, status still prints its prose - and the commentary stays on stderr
in JSON mode so `verbatim verify --json | jq` works while a warning prints.

Exit codes do not move. verify --json still exits 1 with the failing
session ids in the document, and reindex --json reports a held lock as
ok:false rather than an empty stdout a caller could not tell from a crash.

The emitter and its first consumers land together: serde_json is named in
the binary crate here (one Cargo.lock line, no newly compiled crate, since
verbatim-core already links it), and a module nothing calls is dead code
the lint gate refuses.
RCL-09's core. A search returns an excerpt and a context window returns
projections of a hit's neighbours; this returns the record's own bytes,
read at the stream_offset and byte_len on the turn row. The blob is truth
and this is the one path that hands the truth back unaltered, so the body
is Vec<u8> rather than String: a lossy conversion here would make this read
the one place a byte could change between what was written and what is
shown.

D-20 groups the ids by session_key and selects each blob once. The counter
is what says so - BlobReader's block count reports one block decompressed
for a single-turn read whether the blob was materialized once or six times,
so the test takes two ids from one session precisely so "one read per
session" and "one read per id" give different numbers.

D-08: body_evicted comes off session_meta.is_evicted and nothing else, and
that arm reads no blob at all. Letting a failed decompress look like an
eviction would report silent data loss as retention working correctly.

Scoped the way search and context are (RCL-10 auto-scopes all three tools).
Ids are densely enumerable, so a get that scoped nothing would walk out of
the caller's project one id at a time; an id outside the scope is
NoSuchTurn rather than a scope violation, because a distinct reason would
confirm that another project holds it. An unknown id, an excluded project's
turn and an id out of scope are all absent with a reason rather than an
error: four ids of which one is unknown returns the three records.

8 tests.
`verbatim search`, RCL-05, over one shared read entry point.

cmd::read is the one place search, show and sessions open a store, because
three opens are three chances to drift on two behaviours nothing visible
would catch. It opens read-only (D-10): Store::open would create_dir_all,
initialize a database and set two pragmas, so a machine that has never
ingested would acquire a store by being asked a question. That case is an
empty result with a reason and exit 0 - it is the ordinary starting state,
not a broken command - while a store that IS there and cannot be read stays
operational, because a script told "no results" about an unreadable
database would carry on. A store older than this build prints one line to
stderr and is queried anyway (D-18): repairing would make a read rewrite
four tables, and saying nothing would make a pending rebuild look like a
search bug.

The command parses and validates; the query layer decides what relevance
is. Positional words are the query, --project (including the literal *),
--tool, --kind, repeatable --path, --since/--until, --limit and --json map
onto the filters PLAN-2 built. Human output leads with the turn id, because
that is the argument the next command a reader runs takes.

The user's string never reaches SQLite. `verbatim search src/worker/S.ts`
is both the most natural command in the product and the exact string a raw
MATCH answers with `fts5: syntax error near "/"` and exit 1.

10 process-level tests, spawned rather than called: the default scope is
the process's own working directory, and a chdir in a parallel test binary
is not a thing a test can do - so a child process with a chosen current_dir
is the only way that line is testable at all.
`verbatim show`, the terminal's recall_get plus recall_context. A search
hands back an excerpt - a projection, windowed around the match - and this
hands back the record's own archived bytes, optionally with the
conversation around it. Following a hit into its context is what a reader
does next, which is why the two are one command.

The bytes go to stdout through write_all rather than through a format
string: the archive is verbatim and a lossy UTF-8 conversion would make
this, the one command whose job is fidelity, the one place a byte could
change. --json has no such option and says so where it converts.

--before/--after are served by PLAN-2's context window, which stops at the
session (D-06) and says which end it stopped at rather than just returning
a shorter list: "there is nothing earlier" and "you asked for five and got
two" are different answers, and only the first tells a reader to look in
the session this one continues from - returned, unfollowed.

A turn whose session is evicted prints as evicted and exits 0, off
session_meta.is_evicted alone (D-08). A word where an id belongs is misuse
(exit 2), because answering "no such turn" would suggest the archive had
been consulted about it; an id that names no turn is an empty result with a
reason and exit 0.

A closed pipe is Silent, not operational: `verbatim show <id> | head` is
the ordinary way to read a long record.

`value()` moved to cmd/mod.rs so a missing flag value says the same thing
whichever command was asked. 5 tests.
`verbatim sessions`: key, project, branch, first and last turn timestamps,
turn count, sidecar and evicted, with --project, --since/--until, --limit
and --json.

It goes through config::visible::sessions rather than PLAN-2's projection,
and that is the decision rather than an oversight. D-21 keeps the hot query
paths off that query because its per-session count(*) costs ~7 ms on a real
store; here the counts ARE what the command is for, it runs when a person
asks, and using the module every read path is required to use is what keeps
the exclusion gate one gate rather than two that can disagree. The columns
`visible::Session` does not carry come from a second read keyed by session
key, so one command's display needs stay out of every other command's
query.

A session is a span, not an instant, so --since/--until is an overlap test:
still going after `since`, already started by `until`. Comparing one end
would hide the session that ran across the boundary, which is the one a
user asking about a week most wants.

cmd::time_bound validates a time in the CLI, ahead of the query layer.
recall::search::run resolves the scope before it validates its filters, so
a bad --since typed in a directory no archived project covers was answered
with the scope's reason and exit 0 rather than as misuse. Both search and
sessions now go through it.

Exclusion is proved retroactively - archived first, excluded second - which
is the case no per-session flag written at ingest could answer, and the
turns are asserted to still be in the store afterwards. 3 tests, and
READ_COMMANDS now covers all three read commands, which is what makes the
missing-store and stale-store sweep the shared-entry-point assertion it was
written to be.
docs/json-shapes.md documents the envelope, the exit codes, the stream
split and each command's own data fields; tests/cli.rs holds every command
to it as one test per property rather than one test per command, so a
seventh data command joins a table on one line and is held to the whole
contract by that alone. The shape lists in the test are the doc
transcribed: code that drifts fails there, and a doc updated without the
code fails there too.

The sweep found the inconsistency it was written to find. search, show and
sessions moved their commentary into the document while verify and reindex
also kept printing theirs to stderr, so a caller had two accounts of one
walk that could disagree. The rule is now uniform: with --json the document
IS the answer, routine commentary moves into it, and stderr is left for
warnings - which are still printed, so a store whose derived tables predate
this build says so on stderr while stdout stays one document. That second
half is what keeps "JSON only on stdout" from being vacuous: a command that
printed nothing anywhere would pass a stdout-only assertion having lost
everything a human reads.

search's document also stopped depending on whether a store was found:
`truncated` is emitted on both exits, since a shape that varies with the
outcome is a second shape for a caller to handle.

cmd/mod.rs's header said the contract was phase 3's to come. It states the
contract now.
`plan-2-risk.diff` is the flagged range handed to the risk_surface
reviewer, not a phase artifact. A broad `git add` swept it into the
docs commit. The review's findings live in
REVIEW-risk_surface-plan-2.md, which is the record worth keeping.
`day` guarded with `ts.len() >= 10` and then sliced `&ts[..10]`. `len`
counts bytes and the slice needs a char boundary, so any archived
timestamp with a multi-byte character across byte 10 panicked - exit
101, outside the 0/1/2 vocabulary `docs/json-shapes.md` documents, with
the panic text on stderr where a caller looks for a `verbatim: `
diagnostic.

The input is ordinary rather than adversarial. `parse::record` stores
`timestamp` as whatever JSON string the transcript carried and validates
neither its shape nor its encoding, which is the right call for an
archive whose whole premise is surviving the bytes it is handed - but it
means the read path cannot assume D-23's measured shape. One such row
took out `verbatim search` and `verbatim sessions` for its entire
project, and `sessions` panicked mid-loop, so stdout carried a
syntactically complete but silently truncated listing.

`ts.get(..10).unwrap_or(ts)` is total: it returns None for a short
string and for a non-boundary index alike, and falls back to the raw
string exactly as the short-timestamp arm already did. Both copies of
the helper are fixed; each carries a test that panics against the old
cut.
A hand-written JSON-RPC 2.0 loop over stdin/stdout, no SDK and no async
runtime (D-11), dispatched from main.rs like every other command (D-26).
initialize negotiates a protocol revision against the six Claude Code
2.1.229 accepts; tools/list reports exactly three tools, each with
readOnlyHint inside annotations; ping is an empty result rather than
-32601, because the bundled client probes with it on both sides.

A message with no id is a notification and is never answered. An
unparseable line, a bad envelope or an unknown method is a JSON-RPC
error with the standard code, and the loop answers the next request
afterwards. EOF on stdin is the whole lifecycle: the client closes it
first and only escalates two seconds later, so exiting there needs no
signal handling on any platform.
tools/call reaches PLAN-2's search: the same relevance, the same
filters and the same field names `verbatim search --json` prints, so a
turn described in a terminal and a turn described to the model are one
object rather than two definitions that drift.

Auto-scoped to the project the server was started in (D-12) - a
longest-prefix match against the keys session_meta holds, never a git
subprocess - with `project: "*"` opting out and exclusion in force
under both. The store is opened per call through the shared read-only
entry point (D-10) and no ingest lock is taken (D-27).

A caller's limit may only lower the server's budget, and the answer
says when it was lowered. Every argument this path reads is checked
without an unwrap: a panic here writes a backtrace onto the transport
and takes the session's tool with it.
A turn id and how many turns on each side, answered in turn_seq order
(D-22) out of the one session the anchor is in. The window stops at the
session and says which end it stopped at, and hands back continues_from
unfollowed (D-06): continuation is a fan-out with zero, one or many
successors, so a lineage walk here could return turns from a
conversation that never happened.

Both counts are bounded by the same server-side budget the other tools
keep, so one call cannot return a whole session as prose. The test
builds a 60-turn session for that assertion, because the checked-in
fixtures top out at 19 records and a budget of 25 a side cannot be
shown to bind by a session smaller than the budget.
Turn ids in, the records' own archived bytes out, over PLAN-3's by-id
read: each session's blob decompressed once per request (D-20), an
evicted body flagged off session_meta.is_evicted rather than off a
failed blob read (D-08), and an unknown or out-of-scope id reported as
a row in `absent` with a reason rather than as a throw.

This is also where RCL-10's server-side budget lands for a list nobody
typed. recall::get::records binds one SQL placeholder per id, so an
unbounded client-supplied list fails as an operational error instead of
answering; the list is truncated to the cap before it reaches the query
and the answer says how many ids were not read. The terminal path needs
no such bound, being limited by what a person types.
Closes RCL-10's second half. Every way a caller can malform a
tools/call for a tool that exists - a missing argument, an argument of
the wrong JSON type, a limit that is not a whole number, an id that is
not an integer, an unparseable date - comes back as a successful
JSON-RPC response whose content is that tool's own empty document
carrying a reason, marked isError. A JSON-RPC error stays reserved for
params that name no call at all, because a throw is what the client
surfaces to the user as a broken server.

The no-panic rule is now denied rather than reviewed for: unwrap_used,
expect_used, panic and indexing_slicing are denied across the mcp
module and both children for non-test builds. It found the one slice on
the path, in recall_get's id cap, which is now written totally.
RCL-11 and ROADMAP criterion 6 asserted where they are claims about a
process rather than about code. While the server is alive and past a
real tool call, its pid is absent from the system's listening-socket
table; when its stdin closes it exits 0 within a deadline with no
signal sent, which is what makes Claude Code's
stdin-close-then-SIGTERM shutdown clean rather than a kill. The socket
tool is probed for and its absence is announced rather than swallowed.

Pointed at a data directory with no store in it, every tool answers an
empty result with a reason, exits 0, and leaves the directory empty -
read back afterwards, because a server advertising readOnlyHint that
created a database on connect is the contradiction D-10 exists to
prevent. Both starting states are covered: an empty directory and a
path that is not there at all.
Two findings from the blocking risk_surface gate on plan 4, both driven
against the running server.

**An excluded project's name reached a client that never asked for it.**
`recall::get::records` and `recall::context::window` check exclusion
before scope. Both arms refuse the id; only one of them names a path, so
at the DEFAULT scope - no `project` argument, the shape a model normally
sends - an id inside an excluded project answered "<absolute path> is
excluded by config", confirming both that the id exists and what the
excluded project is called. Ids are `session_no << 24 | turn_seq`, so
sweeping them mapped every excluded project's name and live id ranges,
25 per call, with no argument a client had to pass to get there. The two
arms are reordered: an id outside the caller's scope is `NoSuchTurn`
first, which is what both functions already documented as the answer
that does not confirm another project holds it.

**`recall_search`'s `paths` filter was unbounded.** `Filters::push_onto`
binds one SQL placeholder per path - the same hazard `MAX_IDS` closes on
the other tool - so an array past SQLITE_MAX_VARIABLE_NUMBER became an
operational SQLite failure handed back as an ordinary, non-error empty
result carrying the whole generated statement as its reason. A model
reads that as "no matches" rather than "your filter was rejected", and a
1,000,000-element array returned a 2 MB tool result, defeating the
budget that exists so a client cannot pull the archive into its context
one call at a time. `MAX_PATHS = 25`, truncated before the query sees
it, reported in the reason, and declared as `maxItems` in the tool's own
input schema.
The ingest a hook starts has to leave two sets, not one. Claude Code kills
a timed-out hook with kill(-pgid) on its process group AND with a sweep of
every descendant pid it parses out of ps, so a new process group alone is
answered by the second mechanism. The helper spawns a marked intermediate,
which re-spawns itself and exits: the working process is reparented within
microseconds, in its own group and under no branch of a walk rooted at the
hook.

std alone - process_group on Unix, DETACHED_PROCESS | CREATE_NO_WINDOW on
Windows, all three stdio null - so the hook path links nothing new against
a 0.408 ms startup floor.
SessionStart, UserPromptSubmit, SessionEnd and PostCompact, named once in
EVENTS so install and doctor cannot drift from what the binary answers to.
PostCompact and not PreCompact: the compact_boundary record is appended
after compaction, so an ingest spawned before it never sees the boundary.

The command spawns the argument-free tree pass first and reads stdin
second - a harness that never closes stdin must not be able to stop the
ingest from starting - then drops the payload unread, because the fields
belong to phase 5. Nothing on stdout on any path, exit 0 once the event
name is known, and exit 2 for a name install never writes.
One fixture per event, each the single line Claude Code writes to a hook's
stdin, with fields read off the 2.1.231 payload schemas rather than
guessed: the base object its builder returns, then hook_event_name, then
that event's own fields.

The budget test feeds each fixture 100 times and prints the p99 beside the
p50, so a regression names a number. Measured in a debug build: 0.52 to
0.67 ms against a 10 ms budget. The handle test reads the hook's stdout to
EOF and requires an empty read back while the pass is still running -
inheriting that write end instead makes the read take 672 ms.
The hook is left blocked on a stdin nobody closes, which is the only state
a hook is ever killed in, and spawned into a group of its own the way a
harness that kills with kill(-pid) has to spawn. The pass is provably
mid-tree - one of four transcripts committed - before anything is killed.

The descendant set is taken while the hook is alive and again after the
group kill, and every pid in the union is SIGKILLed. That is a superset of
what Claude Code kills, so the ingest has to be outside both snapshots
rather than outside whichever one was luckier. It survives, finishes the
remaining three transcripts and writes its runs row. Dropping the
reparenting hand-off makes it fail at the sweep, not at the group kill.
The hook read its payload with an unbounded blocking read_until, which the
module doc had already promised it did not: a line that never ends grew a Vec
without limit, and a writer that opened stdin and never closed it held the hook
until Claude Code's own hook timeout killed it - on UserPromptSubmit, that
timeout is the user's prompt waiting.

The ingest is spawned before the read, so no byte of the payload is on a data
path; the read exists only so the writer does not see a closed pipe. Bound it
like the courtesy it is: at most 1 MiB, read on a thread the hook starts and
never joins, waited on for at most 500 ms. std alone, no new dependency (D-04).
VERBATIM_REPARENT was an ambient switch read before argument parsing, so any
process that inherited it - a shell export, a .envrc, a CI job - made every
invocation re-spawn itself and return SUCCESS with an empty stdout. verbatim
--version printed nothing, and an unknown hook event reported success where the
CLI contract says misuse (exit 2), which is the code a broken settings file is
caught by. The doc asserted that nothing outside this module may set it; the
code enforced nothing.

The marker is now the first argument, `--reparent`, dropped rather than passed
on when the hand-off re-spawns. An argument is inherited by nothing, which is a
guarantee rather than a convention, and the docs now say that much and no more:
typing the marker still re-spawns the rest of the command line, which grants a
caller nothing they could not do by running it themselves.
One command: put this build at a path that never moves, write the four
exec-form hook entries into settings.json, register the MCP server in
.claude.json, and change nothing else in either file.

The stable path is `~/.local/bin/verbatim` (D-08) and the source is
`current_exe` (D-09), so the npm shim's binary is the copy source and
install needs no knowledge of npm layout. The copy carries a fixed ASCII
sentinel (D-07): version cannot identify it, because the 12 MB unrelated
program already sitting at that path prints the same `0.1.0`. A file
there without the sentinel is refused, nothing anywhere is written, and
the exact command that overrides the refusal is printed.

`json_file` is the load-bearing part. Both settings files are edited
through an order-preserving reader and writer of its own rather than
`serde_json::Value`, whose `BTreeMap` would re-sort 29 top-level keys and
240 KB of `projects` entries into a diff of the whole file;
`preserve_order` is not the fix, being a workspace-wide feature that
would reorder every shipped `--json` document. Scalars are kept as the
source text they arrived as, so numbers, escapes and unicode round trip
byte for byte - proven against the real settings.json (3,697 bytes) and
the real .claude.json (241,563 bytes). Writing re-reads immediately
before the rename (D-06), resolves symlinks so `~/.claude.json` keeps
pointing where it did, and carries the target's permissions across.

Entries merge and are never replaced, keyed on the stable path: an
install that runs again, or after an upgrade, rewrites no byte of `hooks`
(INST-05). Nothing is written before the one confirmation, and a refusal
that had already copied a binary would not be a refusal. `--yes` accepts
every default; with no answer to read, install refuses and names it.
AC4 and AC5 spelled as assertions. Two runs leave exactly four hook
entries and exactly one `verbatim` key under `.mcpServers`; the user's
own `UserPromptSubmit` script is still first in its array with its
`timeout` intact; everything install added, taken back out, is the file
it was seeded with; and the top-level key order is unchanged, read out of
the raw text because parsing into a map is what would hide a reordering.
One backup of each file, on the first run only, holding the pre-install
bytes.

The upgrade case replaces the bytes at the stable path with a different
verbatim build and asserts settings.json comes back byte-identical - the
version-skew class the never-moving path exists to retire.

A program without the sentinel at the stable path leaves its own bytes,
both settings files and the backups untouched, and prints a command
naming the path. Verified by hand against the real 12 MB incumbent at
`~/.local/bin/verbatim`: refused, unchanged, and the command it printed
made the same install succeed.

Every spawn points `VERBATIM_BIN_DIR`, `CLAUDE_CONFIG_DIR`, `HOME` and
both verbatim directories at temporary paths, or a test would overwrite
the binary the developer is standing on and edit the settings this
machine is running.
`Document::apply` writes the whole document back through `render`, so a
settings file that is minified, four-space indented or CRLF is reformatted
in full on first install. The approval hid it: the diff's left-hand side
was `rendered()`, so the user was shown a four-line insertion and got a
whole-file rewrite (INST-03).

Take the diff against the bytes on disk instead. `Document::source` keeps
what was read, `would_write` is apply's own no-op test hoisted so a caller
can decide whether to back a file up, and `reformats` is what lets install
say why the diff is the whole file. A file whose formatting is not this
renderer's is now only rewritten when install also has something to add to
it, and the rewrite is shown before it is approved.

The two properties that were already right stay right: string scalars are
still raw source text and round-trip byte-identically, and a second install
still writes nothing because `apply` compares rendered against rendered.
`Json::entry` takes the first member with a key. Every real JSON consumer,
`JSON.parse` included and so Claude Code, takes the last. Given
`{"hooks":{}, "hooks":{...}}` install would have written its four entries
into the object Claude Code ignores, reported success, and left no hook
firing at all.

There is no reading of a duplicate under which rewriting the whole file is
safe, so a repeated key at any depth is an operational refusal that names
where it is - `.hooks`, `.hooks.SessionStart`, `.hooks.SessionStart[0].matcher`
- and says why. Nothing is written: the check runs inside `Document::read`,
which is before install places the binary or touches either file.
Both atomic writes built a temporary path out of the process id and opened
it with `File::create`, which follows symlinks and truncates the target. A
stale `.settings.json.verbatim-<pid>` or `.verbatim-install-<pid>` left by a
crashed run, or a pid that came round again, was enough to truncate an
unrelated file through the link - and the rename that follows then moved the
link instead of the file, so the write was not atomic either.

One `create_temporary` on the install module, `O_EXCL` through
`create_new`, answering a collision with another name rather than a reuse.
The binary copy also fsyncs before its rename, so a crash between the two
cannot leave an empty file at the path every hook entry names.
`std::fs::copy` wrote straight to the `.verbatim-backup` name, so a crash, a
full disk or a kill mid-copy left a truncated backup - and because an
existing backup is deliberately never refreshed, that truncation was
permanent and was the only restore point uninstall has (AC5, AC7). The
`exists()` test in front of it was also a race two concurrent installs both
win, and a lie in front of a dangling symlink: it followed the link, found
nothing, and the copy then created the link's target.

The copy now goes to a temporary file and takes the backup's name with
`hard_link`, which is atomic and the one rename-shaped operation that
refuses to clobber. Either there is no backup or there is a whole one. The
never-refreshed rule is unchanged - a name already taken is left exactly as
it is - and the copy carries the source's permissions, which `fs::copy` used
to do and a fresh 0644 temporary would not.
`occupant` ran before the confirmation and `place` renamed unconditionally
after it, so a file that appeared at the stable path while install waited
for an answer was silently clobbered - the one guard against overwriting
somebody else's executable, checked at the wrong end of a wait as long as
the user takes to type.

The check runs again immediately before the rename, with the same refusal
and the same command that makes the install succeed. What is left is the
microseconds between that look and the rename, which needs a rename that
refuses to replace - no platform offers one through `std`.
Idempotence was checked by presence alone: if any entry for an event
carried the stable path as its `command`, install wrote nothing for that
event. An entry with the right command and no `args`, or `args` naming
the wrong event - a hand edit, or an older verbatim's shape - therefore
made install skip the event and leave it with no working hook at all,
permanently, with every run reporting success.

Ownership and shape are now two questions. The command alone decides
whose an entry is, so somebody else's is still never read past that
field; a verbatim entry that is the wrong shape is repaired in place,
keeping its position and any key beside it the user added, such as a
`timeout`. Assigning a value the entry already holds is a no-op down to
the bytes, so an upgrade still rewrites none of `hooks` (AC3).

The same rot applied under `.mcpServers.verbatim`, where a registration
pointing at a path that had moved was left to sit, so it is corrected on
the same rule.
A read-only report: the copy at the stable path and whether it is this
build, one check per hook event so a hand-edited SessionStart says nothing
about PostCompact, the mcp registration in .claude.json, and the Claude Code
version against the 2.1.227 floor verbatim has actually checked for exec-form
args. Every problem carries a literally runnable command rather than a
sentence, and doctor creates nothing and repairs nothing on any path.
The archive half of the report: the config roots that resolved, the data
directory, the store's counts, and the last runs row, which is the only
account a detached ingest leaves because there is no log file. All of it
through the read-only open (D-12) and a permissions read rather than a probe
file, so a machine that has never ingested is reported as exactly that and
still has no data directory when doctor exits.
cleanupPeriodDays and autoCompactEnabled, each resolved through the scopes
Claude Code resolves them in - the user file, the project's settings.json,
its settings.local.json - with DISABLE_AUTO_COMPACT outranking all three, and
the finding naming which source won. Both are advisories rather than
problems, so neither reaches the exit code, and the fix each prints is the
edit the user makes: doctor reports, it never repairs.
data is the checks, keyed by name, each with its state, its finding and its
fix, and ok is the exit code's answer. The key set does not depend on what
doctor found: a check it could not run reports unknown rather than going
missing, so a caller reads a state instead of testing for a key.

Document::try_emit is added beside emit rather than replacing it: emit is
println!, which panics with exit 101 on a closed pipe, and doctor maps that
to the verdict its checks earned. The six shipped commands are untouched.
Every hook entry whose command is the stable path, wherever it sits, plus
the verbatim key under .mcpServers, and nothing else in either file. The
walk is over the event keys the file has rather than the four this build
writes, so an entry an older verbatim left under an event this one no
longer uses is still verbatim's and still goes.

A group left empty goes with the entry it held; an event key left empty
goes only when install's backup proves install created it, because a real
settings.json carries "SessionStart": [] written by Claude Code itself.

Then the restore: the edited file is compared with install's backup as
values, so formatting alone never decides it, and an unchanged file gets
its pre-install bytes back and loses the backup. A file the user has
edited since keeps the edit and keeps the backup, and uninstall says
which of the two happened.

A missing settings file, a missing backup, an entry already gone and a
stable path holding somebody else's program are each one reported line
with the rest of the work still done: a user reaching for uninstall is
often reaching for it because something is already wrong.

json_file gains the one thing a sibling module cannot do for itself -
dropping an object member, since Member's fields are private - plus the
backup's name without making one, and a byte-exact atomic write for the
restore.
The one irreversible thing in this phase, so the path and the size on
disk go on the screen before the question and never after it, and only
after everything else uninstall does has already run.

The question is install's own confirmation, reached by widening `confirm`
to `pub(super)`: same reading of the answer, same terminal test, and
above all the same refusal when there is nothing to read rather than an
assumed yes. `--yes` is the one thing that differs - install's takes
every default, this one takes the delete, because the default for
something that cannot be undone is no.

The delete is taken under the ingest lock, so a pass mid-transaction
never has its store unlinked out from under it, and it is refused
outright on a directory holding no verbatim.db: `remove_dir_all` on a
path verbatim cannot recognize is not a mistake worth the one time it
would be right.

The byte count is the same three files `status` measures by the same
rule, and is not always the same number - `status` counts the `-shm` and
`-wal` its own open connection makes. What is shown here is what is on
disk, which is what is about to go.
The thin package carries no scripts key at all - no postinstall, no
preinstall, no install, not an empty one. That single omission is what
sidesteps the blocked-postinstall failure class, and it is why install is a
command the user runs rather than something that happens to them.

The five platform packages declare os and cpu so npm downloads exactly the
one that matches and skips the other four. None of them declares a bin
field: the thin package already claims the verbatim link, and a second
claimant on the same name is a collision with no upside. None declares
exports either, because the shim resolves them by requiring their
package.json.

Staged binaries and pack tarballs are ignored - the manifests are the
tracked part, a 5 MB build artifact never is.
npm's bin field cannot point into an optionalDependency, so something has
to bridge the thin package to the binary npm actually downloaded. This is
that bridge and nothing more: resolve, exec, pass the exit code back.

Resolution goes through the platform package's own manifest rather than a
guessed node_modules path, because npm is free to hoist it, nest it or
park it at a workspace root and require.resolve is the only thing that
knows which it did.

Every failure here is one line on stderr, never a throw. A user who sees a
stack trace reports a crash in the tool; a user who sees the package name
and the platform-arch pair reports a platform we do not build yet, which
is the true thing. The two failures that can actually happen - no package,
and a package whose binary is missing - get separate lines, because
reinstalling fixes one of them and not the other.

Ignores node_modules under npm/: resolving the shim locally needs a link
there, and a stray tree is not something to commit.
AC9 as a script rather than as a paragraph. It builds, stages the binary,
packs both packages, installs the tarballs into a throwaway prefix and
asserts the installed --version is the one just built - and it exits
non-zero on any of those, so it is an acceptance check and not a demo.
Poisoning the thin manifest with a postinstall makes it fail at the
manifest step, before it installs anything, naming the package and the
script key.

It asks cargo where the target directory is instead of assuming ./target,
because CARGO_TARGET_DIR moves it and on this machine it does.

The install order is the interesting part and the README says why: npm
resolves optionalDependencies from a registry these packages are not on
yet, so the platform tarball goes in first and the thin one follows with
optional dependencies omitted. That proves the shim resolves what npm
placed; it does not prove npm's os/cpu selection, which only a registry
install can. The gap is written down rather than papered over, along with
the unchecked npm name.

Drops the word installed from the platform descriptions - it was the only
thing in those manifests that a grep for an install script could hit.
The drain loop waits for exactly as many Done messages as it sent, and its
disconnect arm only fires once every worker is gone. So one worker panicking
inside prepare hung the whole backfill: its Done never arrived, the surviving
workers held done_rx open so the disconnect arm never fired, and the scope
could not join to re-raise the panic because its own closure was what was
blocked. The pass stopped with no error and no exit.

The worker now catches the unwind and returns it as that file's own failure,
which restores the invariant the drain loop is written against: every job sent
comes back exactly once. D-12 already says one damaged transcript is not a
reason to archive none of the tree, and a panic is only the loudest way for one
to be damaged.

The fault point rides the transcript's name because a worker holds the path it
was handed and nothing else, and it is spelled as a sidecar because discover
admits a file on its name alone (D-16) - anything more legible is never walked,
so the test would arm a fault that never fires.

Falsified: with the catch_unwind reverted and nothing else changed, the new
test times out after the full 60 s budget reporting that the backfill never
returned. With it, 3 of 3 in tests/backfill.rs, the workspace green under
--all-features, clippy -D warnings clean.
Plans 4 and 5 executed in parallel worktrees and merged. The blocking
risk_surface gate matched concurrency on plan 4 and destructive plus
untrusted_input on plan 5; both adjudications are recorded beside this summary.

D-21's evidence carried the release binary's size from before plans 1-3 landed,
corrected in CONTEXT from plan 5's measurement.
`binary::place` renames a new binary over the stable path, which unlinks the
inode the running process was exec'd from. Install then asks for a backfill,
and the spawn resolved `std::env::current_exe()` - by that point a deleted
file. So `~/.local/bin/verbatim install`, which is exactly what doctor's fix
line and a plain rerun both tell a user to run, printed its estimate and then
"the backfill could not be started: No such file or directory", and the store
did not grow.

`backfill::start_from` and `spawn::detached_from` take the executable to spawn.
Install passes the stable path it has just placed, which is also the build every
hook entry points at. Hooks and a plain `verbatim backfill` keep `current_exe()`,
and only the first hop needs the path: the intermediate is exec'd from it, so
the reparenting hand-off resolves on its own.

Falsified by reverting the one-line call site: the new test fails with the same
ENOENT and the store stays at one session.
`Store::open` creates the database file before it commits the schema. The
convergence test's mid-progress guard admitted any data directory whose DB file
merely existed, so a SIGKILL landing in that window handed `Snapshot::of` a
table-less database and it unwrapped its way to a panic - failing AC8's
convergence test before the convergence assertion it exists for ever ran.
Measured at 3 failures in 12 release runs.

`Snapshot::started` asks `sqlite_master` for the `sessions` table first and
returns `None` when there is no schema yet. The caller reads that as what it is:
a pass killed before its first commit, interrupted and trivially unlike the
reference. `Snapshot::of` stays strict, since a missing schema in the reference
store or in a completed run is a real failure.

0 failures in 12 release runs with the guard, 3 in 12 with it reverted and
nothing else changed.
INJ-01 says "configured", so the resume brief's and the prompt
injection's character budgets are keys in verbatim.toml's [injection]
table rather than constants. Characters and not tokens (D-16): none of
the workspace's eight dependencies is a tokenizer and every budget the
codebase already enforces is byte-shaped.

Config::default() spells the defaults itself rather than deriving them,
because a derived Default would give both budgets zero - a config that
silently injects nothing - and Config::from_parts keeps its signature so
no other test in the workspace has to change.
D-02: injection gets session_id, transcript_path, cwd, prompt and source
through stdin or not at all - the argv stays ["hook", "<event>"] and
gains no flag, because install writes those entries once and never
rewrites them, so a new argument would mean editing every user's
settings.json on upgrade.

Read off a serde_json::Value rather than a derived struct: this crate
depends on serde_json and not on serde's derive, and every field is
optional at the type level, so a payload that omits one - or is not JSON
at all, or was cut off at MAX_PAYLOAD, or never arrived before
DRAIN_DEADLINE - is an event that gets less injection and never an
error. Nothing else about the read moves: the ingest still spawns first,
the read still happens on a thread nobody joins, and both bounds stand.
D-01: SessionStart and UserPromptSubmit are the only two entries in the
harness's hookSpecificOutput union, so those two can carry
additionalContext and SessionEnd and PostCompact still write nothing on
every path. The object is built with serde_json and never with format!,
because injected text is arbitrary transcript bytes; text that is empty
or whitespace is nothing rather than an empty additionalContext.

verbatim-core gains an inject module holding every store query and every
rendered string of this phase, one arm per injecting event, each taking
the data directory, the Config and all five payload fields so PLAN-2 and
PLAN-3 can deepen one arm each without touching the seam. It opens with
Store::open_read_only and never Store::open - a hook must not leave a
store behind as the side effect of a question - and scopes from the
payload's cwd (D-12), never from this process's working directory, which
on a hook is whatever the harness happened to choose.

The brief renders INJ-01's index pointer only: the sessions and turns
archived for the scoped project, counted through the project-only
projection plus one targeted count(*) each (D-09), never through
config::visible::sessions, whose per-session count(*) over turns costs
6.2-6.8 ms against a single-digit-millisecond budget.
INJ-06 and D-03's two mechanisms, which cover different failures.

In the binary, the injection runs on a thread the hook starts and never
joins - the same shape and the same reason as drain, since a blocking
SQLite call cannot be cancelled with std alone - and the hook waits at
most INJECT_DEADLINE, 50 ms, before writing nothing and exiting 0 with
the work abandoned. That is five times the p99 the hook budget test
asserts and a hundredth of the five-second wait a default busy handler
imposes. A panic on that thread is caught and becomes one silent event
rather than a wedged hook.

In core, the read-only connection's busy timeout drops from five seconds
to three milliseconds, and a store older than this build is silence too:
a read command answers a stale store on stderr and queries it anyway,
and a hook has no channel to qualify a degraded brief with.

Measured while proving it, and written into both doc comments because it
contradicts what the plan assumed: the lowered busy timeout bounds the
queries and not the open. Store::open_read_only sets its own timeout and
queries sqlite_master before returning, so a store held under exclusive
locking mode by a connection in the SAME process waits the full 5,011
ms, while the same lock held by ANOTHER process - the real case, and
AC6's - is reported back in about 6 ms. The watchdog is what bounds the
wait in general, which is exactly why D-03 asks for two mechanisms.
The one fact PLAN-4 is built on that nothing in this repository had
observed. Captured live - a temporary matcher-less SessionStart entry
appending its stdin to a file, a fresh session, then /compact - and the
answer is the affirmative one: two lines under a single session_id,
source "startup" at session start and source "compact" once the
compaction finished. D-08's fallback (reading the boundary row at
UserPromptSubmit and accepting the ingest race) is not needed.

The fixture is those bytes with the identifying fields replaced, because
this repository is public: session_id, transcript_path, cwd and prompt_id
carry the synthetic values the other four hook fixtures use, and model
carries theirs rather than the build-specific name the live session
reported. hook_event_name and source are exactly as captured.

The capture also contradicts what tests/fixtures/README.md says about the
recorded payloads: prompt_id is on the "compact" line and not on the
"startup" one, so it is not an unconditional base field, and neither
live line carried permission_mode, agent_type or session_title at all.
Nothing reads those four, so the older fixtures stand - both the README
and Payload::source now say to re-observe before a phase starts reading
one.
The last non-subagent session of the scoped project by greatest
last_turn_at, its date at day resolution, the branch it ended on, and its
last prompt and last reply cut from one blob read through the projection
ingest indexed them with.

Ordered lexicographically over the stored timestamp with session_key as a
total tie-break, so an unchanged archive renders the same bytes; is_final
is declared and never written, so it cannot be the test (D-17). The
working-state delta is the branch alone (D-10): git costs 10-30 ms
against a single-digit-millisecond budget, and no process is spawned on
this path.
The configured brief_chars is enforced in characters over the whole
rendered brief (D-16: no tokenizer on the cold-start path), and the two
quoted turns are the only parts cut - they are the only parts whose size
the archive controls, while the date, the branch and the index pointer
are the cheapest text in the brief and the most useful. Cuts are marked
with recall::excerpt::ELISION and made on a character boundary.

MAX_BRIEF_CHARS clamps whatever the config says: bundle 2.1.237 persists
a hook stdout over 10,000 characters to disk and hands the model a file
reference, so a brief past the ceiling stops being context.
AC2: two SessionStart spawns against an unchanged store, compared byte
for byte, plus a scan for any NN:NN anywhere in the output - dates are
rounded to the day, so a time of day can only have come from a clock or
from a quoted turn.

The store is ingested to completion before either hook runs, so each
hook's own spawned pass finds nothing new and the two runs really are
against one archive. The no-clock rule and the total-ordering rule are
written into the module doc beside the code they constrain.
AC1: 100 SessionStart spawns against a store with indexed history, each
asserted to exit 0, write exactly one JSON object under its own event
name, and carry an additionalContext inside the brief_chars the test
wrote into verbatim.toml. p99 asserted under the same 10 ms
tests/hook.rs asserts for the events that write nothing, which D-11 said
would have to absorb the store open and the injection queries.

Measured here in a debug build: p50 1.27 ms, p99 2.35 ms. Both are
printed rather than only asserted.
The structural threshold INJ-03 asks for cannot be read off a Hit as it
stood: weight_by_entities collapsed the EntityMatch kind and the number of
matched values into the scalar entity_score, and approximating the two
conditions with a score cutoff is what DESIGN-BRIEF.md:245 forbids outright.

Hit now carries the strongest kind matched on the turn and the count of
distinct (kind, value_norm) pairs the query matched, collected in the pass
that was already reading those rows. Distinct is load-bearing: one path
named three times by one turn is one piece of evidence.

The ordering is untouched by construction - the score still accumulates per
row, entity_score keeps its name, its meaning and its --json shape, and
rank/TAIL/CANDIDATE_POOL/EXACT_WEIGHT are as they were.
Nothing in the corpus stored an absolute path: session-recall.jsonl's one
Read names docs/RETRY.md relative, which is the spelling D-05 measured at 2
occurrences against 1,029 absolute over 120 real transcripts - and it is the
spelling AC3 is not about.

session-edits.jsonl is an Edit call under project-alpha whose file_path is
absolute beneath the fixture's own root, carrying lanternFlicker and
lanternSteady across its two edit sides so the turn emits four distinct
entities and INJ-03's co-occurrence half has something to fire on. Its third
turn names that same path in prose and emits none, so structural and textual
stay two different answers about one file.
A stored path entity is almost always absolute and a user types the relative
spelling, and matches_entity needs every token of the stored value present in
the query - so /root/proj/crates/x.rs cannot be matched by a query of
crates/x.rs, and AC3 would be permanent silence that every absolute-path
fixture still passes.

The path-shaped words of a prompt are now joined onto the payload's cwd and
put AHEAD of the prose, because MAX_QUERY_TOKENS drops the thirty-third
distinct token and a pasted stack trace would otherwise drop exactly what the
resolution added. No filesystem is touched: 52% of the corpus's cwd
directories no longer exist, so canonicalizing would key one file two ways.
A hit is injected when it matched a stored entity AND sits in the top three
of the ranked order, or when two independent entities co-occur on it wherever
it ranks. A hit reached only through its text is never eligible, whatever its
relevance: a score cutoff is the approximation DESIGN-BRIEF.md:245 forbids.
At most three, and zero is the ordinary answer.

The search is a net rather than the decision. A Query is conjunctive, so the
whole prompt handed to MATCH asks for a turn that repeats the sentence -
measured: 'who edited docs/RETRY.md yesterday' returns nothing against a
store whose Read opened exactly that file. Request::candidates carries the
spellings a prompt names - resolved paths and identifier-shaped tokens - each
conjoined within itself and disjoined across, and the prose stays in the
query, where it still weighs and excerpts what comes back. Both front ends
are untouched: an empty list is the expression search always built.

Request::excerpts is D-13: one excerpt materializes a whole compressed
session (p90 1.03 MB, max 10.1 MB), the prompt path is silent most of the
time, so nothing decompresses until the threshold has fired.
Each fired turn renders as its id - the argument recall_get and verbatim show
take - its session's day, and its text, inside the configured prompt_chars
counted in characters and cut with the one elision in the product. The order
is the ranked one, every value is a stored one and no clock is read, so two
runs of one prompt against an unchanged store render the same bytes.

When nothing fired the arm returns nothing and the hook writes nothing, which
is the common case and the one the phase exists to protect.

crates/verbatim/tests/prompt.rs is AC3 and AC7 at the process boundary: the
relative path finds the edit that stored it absolute, the same prompt without
the path is silent, a prompt matching only prose is silent, and a configured
budget of 120 characters binds the spawned binary's own stdout.
One JSON file per session under the data directory, keyed on the payload's
session_id, carrying the turns injected so far, the turns the brief quoted,
the suppressions with their reasons and the compaction-owed flag.

A file rather than a table (D-06, D-14): a write on the prompt path is what
INJ-06 cannot tolerate, and a new table could not reach an existing store
without a DERIVED_SCHEMA bump forcing a measured ~49 s rebuild.

The session_id comes off an untrusted payload and becomes a file name, so it
is refused by an allow-list before a path is built. The write goes through a
temporary in the same directory and a rename, and every read fails open.
INJ-04's three suppressions, applied to the turns that passed the
structural threshold and before the cap of three, so a refused turn does
not spend a slot: a turn an earlier prompt of this session was already
given, a turn of the session the user is looking at, and a turn the
resume brief quoted. Each refusal lands in the session's state file with
its reason, which is the file AC4 asks to be able to read.

The session on screen is recognized by the archive's own key for the
payload's canonicalized `transcript_path` (D-15), never by reading the
live transcript - p90 1.03 MB per prompt to learn what a string
comparison answers. A path that will not canonicalize suppresses
nothing.

The brief now records the turn ids it quoted, after the budget had its
say, so that the first prompt of a resumed session is not answered with
the text sitting on screen above it. It writes only when it rendered
something, so a machine with no archive still acquires no files.

Two PLAN-3 assertions re-ran one prompt under one `session_id` to assert
determinism and the budget; each repeated call now takes a `session_id`
of its own, so both keep asserting what they were written for with the
dedupe out of the way.
D-07: the dropped set is the complement of `preservedMessages.uuids`
over the session's turns before its most recent boundary, parsed here
from the `compactMetadata` object phase 2 stored verbatim rather than
derived at ingest, where a wrong reading would cost a reingest.

The other three readings in the same object are all wrong and the
fixture separates them: `allUuids` names records that are not in the
transcript, `preservedSegment.anchorUuid` resolves after the boundary,
and "everything before headUuid" assumes the preserved set is a suffix,
which loses the recent dropped turn this exists to offer back. Over
`session-compacted.jsonl` the right reading returns one turn and both
wrong ones return nothing, so the test's assertion is about which list
was read.

Every unreadable boundary is an empty set: no metadata, bytes that are
not JSON, an object without the field, a `uuids` that is not an array
and an empty list all say "nothing is known to have been dropped". A
turn the transcript gave no uuid is left out for the same reason - it
cannot be shown to have been dropped, only to be absent from a list it
could never appear in.
D-08 and INJ-05. A `SessionStart` whose `source` is `compact` renders
the ordinary brief and leaves a flag in the session's state file; the
next `UserPromptSubmit` spends it, looks through a wider ranked window,
and keeps only candidates the compaction dropped. The wider window is
free where it matters: excerpts are already off until the threshold has
fired, so it buys small columns and touches no blob.

The threshold is not relaxed inside the pool and the pool is applied
AFTER it, not before - filtering first would renumber the ranks and let
a turn that placed twentieth arrive looking like a rank-1 hit. One
definition of relevance for both paths, and INJ-03's "never on a
free-text-only match" is not conditioned on a compaction having
happened.

"Visible in this session" now means in this session AND not known to
have fallen out of context. Without that, INJ-04 would suppress the
whole of INJ-05: every dropped turn belongs to the session the user is
looking at, which is exactly why it is worth offering back. On the
ordinary path no dropped set is derived, so nothing is exempt there.

`compaction::dropped` answers `None` for a session with no boundary row
and `Some` for one that has it, because the flag turns on that
difference: `Some` means the compaction is accounted for and the debt is
paid even if the set is empty, while no row may only mean the ingest is
still committing it - and there the debt carries to the following
prompt rather than being lost to the race.
A hit carried only `entity_count`, so a logged retrieval decision could say
"matched 2 entities" and nothing about which rule produced them - which is the
one question offline replay exists to answer (phase 6 D-05). The pairs
`Matched.values` already collects now survive onto `Hit` and into the
`search --json` contract.

The score summation and the ordering are untouched: a re-ranking hidden inside
a reporting change is the regression every existing test still passes.
Both join the archive group rather than the derived one (D-03): a decision is
prompt-time state - the spellings extracted, what the index answered, the
thresholds in force - that no blob replay can reconstruct, so a reindex that
dropped it would delete the history replay and stats are computed over.

They reach an already-initialized store through bring_forward's missing-table
arm on the next open (D-02), so an upgraded machine gains a decision log
without the measured ~50 s in-lock rebuild a DERIVED_SCHEMA bump would force.

labels.turn_id deliberately declares no foreign key: turns is dropped by
reindex, foreign keys really are enforced by the bundled SQLite, and a
declared reference would make the first reindex after the first label fail.
A prompt's decision is one JSON file under the data directory, written with the
tmp+rename discipline the injection state file already uses and drained into
SQLite by a later ingest pass (D-01). The hook never writes the store: an
ingest was measured holding it for 49 s, and a prompt-path write would put the
user behind exactly the machine state that provoked it.

The session id is untrusted payload text that becomes part of a file name, so
the same closed allow-list state.rs applies runs before any filesystem call -
a hostile id reaches no create_dir_all and no join. The name carries pid, the
clock and an attempt counter, and the target is reserved with create_new, so
two prompts of one session cannot collapse into one record.

The read side is fail-open and still reports the path of a file it could not
parse: the drain's job is to empty the directory, and a reader that returned
only good records would leave the rest there forever.
Every exit of the prompt arm now writes one record - the early returns for an
empty prompt or cwd, the empty candidate set before the store opens, the
store-open failure, the search error, and the ordinary fire and non-fire. The
record is completed in user_prompt_submit because what an injection cost is
known only after render clips it, and it is written after the state save on the
thread the hook abandons.

The threshold, pool and suppression logic is untouched. The suppressions
recorded are THIS prompt's, collected where surviving refuses them: the session
state file's list is cumulative and capped, so reading it back would attribute
another prompt's refusals to this one.

One bound on "every prompt": save never creates the data directory. A hook must
not leave one behind on a machine that has installed verbatim and never ingested
(INJ-06), and nothing analysable is lost - a decision is only read beside the
archive it was taken against, and the hook spawns the ingest that creates the
directory before it injects anything.
The drain runs after recovery and before the walk. The position is the point:
the watermark it stamps on a record whose prompt never opened the store is then
the archive as it stood when the pass began, not as this pass leaves it (D-10).

One transaction for the batch, files deleted only after it commits. A kill
before the commit leaves every file where it was; the reverse order would
delete the only copy of a record whose insert then rolled back. What remains -
killed between commit and unlink - is a duplicate row in a log rather than a
lost decision.

A file this build cannot read is deleted and named into runs.error: there is no
migration, so leaving it would mean re-reading it on every pass forever. A
drain that fails is a note and not a failed pass - the archive is the work.

PassOutcome keeps its unboxed Summary: the struct grew past clippy's enum-size
threshold, and boxing it would buy two hundred bytes once per process while
costing every caller a deref.
The seam under test is the one the product runs: a hook process that writes a
file on a thread it abandons, and a separate ingest process that moves it into
SQLite. Neither half exists inside a library call.

The drain is polled rather than run once. Every hook spawns a detached ingest
of its own, so one of those can hold the lock when the test's pass runs, and a
pass that lost the lock race exits 0 having drained nothing - waiting for the
count is what keeps the assertion about the drain rather than about which
process got there first.
`verbatim stats` and `verbatim replay` on a store this build has never
ingested into died with a raw `sqlite: no such table: decisions` on stderr,
and under `--json` wrote no envelope at all.

Two seams missed the case together. `predates_this_build` is
`rebuild.is_some() || !missing.is_empty()`, and `missing` is computed over
`BRING_FORWARD_COLUMNS` alone, so it cannot see a whole table: `decisions`
and `labels` arrived with no `DERIVED_SCHEMA` bump (D-02), which left a
phase-5 store reading as perfectly current right up until a query named
either one. The failure then surfaced from the query as a raw
`Failure::Operational` string, and `main` prints that on stderr without ever
learning whether `--json` was asked for.

`Store::missing_tables` is the table-level counterpart of `missing_columns`,
computed at read-only open over `schema::TABLES`. It stays out of
`predates_this_build` on purpose: that flag makes every read print the D-18
degraded-results line, and a store missing only the decision log answers
`search`, `show` and `sessions` perfectly well.

Both tables absent is age, and gets the empty answer with a reason that a
machine which has never ingested already gets. Exactly one absent is damage:
the two ship in one `CREATE_SQL`, so age cannot produce it, and reporting it
as a clean empty answer would let a half-deleted store say "injection has
recorded nothing". That exits 1 with the envelope written, never a second
account of it on stderr.

Nothing is repaired either way. A read never migrates (D-18); the next
write-mode ingest creates both tables through `bring_forward`.
`the_pipeline_lands_on_the_same_store_as_the_sequential_pass` compares every
table's row count from `schema::TABLES`, so declaring `observations` put it in
the comparison automatically - and it reads 0 after `backfill::run_with` and 36
after `pass::run_with`. That is a pre-existing deliberate asymmetry a row count
can now see: backfill IS the walk, while the sequential pass also runs
`feedback::drain` and `feedback::outcomes`, and `outcomes` sets
`session_meta.is_final`, which is the gate observations are written behind.

Exclude the post-walk set as a set - `observations`, `decisions` and `labels` -
rather than the one table that fired. The other two read 0 against this fixture
tree today, which is the only reason they never sprang the same trap; excluding
`observations` alone moves the trap to whichever of them a fixture change
populates first. An assertion holds every excluded name to `store::TABLES`, so
a stale name cannot exclude nothing and read as if it did.

Every other table, the checksums, the watermarks and the archive digest stay in
the comparison unchanged. The module doc's test count was stale at two; three
tests live here.
A read command in the shape of `stats`: opened through `cmd::read::open`, so a
machine that has never ingested gets an empty answer with a reason and exit 0
rather than a store created as the side effect of a question. It takes
`--project` and `--json` and nothing else, and it calls no model and opens no
network connection on any path - the row was written off the parser by the
ingest pass and this reads it back.

The visible listing is the OUTER loop and the table is looked up inside it.
Exclusion is retroactive (ING-08) and `observations` is keyed on a session, so a
read that selected from the table alone would list a session whose project was
excluded after it was archived - exactly the half claude-mem honors on write and
ignores on read.

`Store::missing_tables` is asked before the query, the way `read::decision_log`
asks it: left to the query, a store older than the table surfaces as
`no such table: observations` inside an operational failure that never learns
`--json` was asked for, so the envelope is never written at all. Now it is a
reason naming the table and exit 0.

`--json` carries the whole row, the judgment columns included and null until a
provider answers, so a consumer reads one shape either way. The stored JSON
columns are emitted as values rather than as strings of JSON; a column that will
not parse comes back as its own bytes rather than as a silent null. Human mode
prints a header line per session and one line per fact list that has anything in
it.

Documented in a new `### observations` section of docs/json-shapes.md.
The workspace's first two-word subcommand (D-18). `dispatch` matches a flat set
of single-word names, so `observations` consumes its own second word: bare is
the listing, `regenerate` is routed from inside its own parser, and any other
second word is misuse rather than a listing that ignored a word it was handed.
The verb comes first or not at all, because the two verbs take different flag
sets and a caller cannot be told which one it just used.

`--since` goes through `cmd::time_bound`, where every other command's date bound
is validated: every string orders cleanly against every stored timestamp, so an
unvalidated bound would rebuild a plausible wrong set and report success.
`--prompt-version` selects on the column. Both narrow conjunctively; neither
selects every visible session.

`observe::regenerate` rewrites `mechanical` and nothing else, on exactly the
selected rows. Not `generated_at`: it dates the row as a whole, judgment half
included, so a mechanical recompute that moved it would misdate the part it did
not touch. Selection runs through `config::visible::sessions` because this reads
a blob per row and exclusion is retroactive (ING-08). Unbounded, unlike the pass
step - a rebuild asked for explicitly must not silently do a hundred rows of the
set it was given.

It writes, so it opens for writing and takes the ingest lock reindex takes, and
reports contention the same way: `ok: false` with the lock named, exit 1.

Both verbs join `DATA_COMMANDS` rather than being exempted from the sweep;
`sweep_args` splits a two-word name into two command-line words, so the name
stays the one string `value["command"]` reports and `docs/json-shapes.md` heads
its section with.
`observe::net` is the only module in either crate that names an HTTP
client or opens a connection, and `tests/provider.rs` asserts that by
walking the source of both crates. Every call records its destination in
a testkit-gated attempt log before the socket is opened, including a call
that fails to connect, so PRIV-03's "zero connections" is a number a test
reads rather than a claim about the code.

ureq 3.4 with rustls, default features trimmed to that alone. Measured
before writing the justification: the release binary grows 5,823,728 ->
8,195,960 bytes with the client reachable from main, and the SessionStart
hook's cold start moves p50 0.794 -> 0.807 ms over 200 runs. The root
Cargo.toml's "no HTTP client" clause is rewritten to say what replaced it.

Redirects are off: a followed redirect is a connection the attempt log
never recorded, and a bearer credential replayed somewhere the user did
not configure.
A `[provider]` sub-table added the way `[injection]` was: every key
optional, unknown keys ignored, a missing table meaning the defaults -
which are judgment off, no credential, and `local` false, so a forgotten
destination declaration fails safe toward filtering (D-13). No URL is
parsed, no host inspected and no name resolved, because a DNS lookup is
itself a connection PRIV-03 bars.

`Secret` is the wrapper the key lives in: no Debug that shows the value,
no Display, no Deref, and one conspicuously named `expose` for the
request builder. `Config` keeps its derived Debug and that derive is now
safe.

Closes the other route a key can take out of a config file: a TOML parse
error renders the offending line back at the reader, and forgetting the
quotes around `api_key` would put it on stderr. When the parser's
rendering names that key, only the position survives.
Three tiers in D-14's order - the process environment, `verbatim.toml`'s
own `api_key`, then `~/.config/jcrenshaw/credentials.toml` namespaced by
provider - with the shared path resolved through an override first, so a
test never reads the developer's real file. An absent shared file is the
common case and not an error; it does not exist on this machine.

PRIV-02 is a mode-bit check on Unix (D-15): any group or world bit is
refused, not only the read bits, because a file somebody else can write
is a file somebody else can put their own key in. The refusal names the
file and its octal mode and carries nothing from inside it. The Windows
arm accepts with the ACL unchecked; task 6 surfaces the caveat.

The loader writes nothing, creates nothing, repairs nothing and migrates
nothing. A malformed shared file reports its position and quotes none of
itself - unconditionally, unlike `verbatim.toml`, because every line of
this file is a credential.
Two entry points over one rule set. `for_destination` is the request
body's gate and returns the bytes untouched only for a declared
`local = true`; absent or false is remote and therefore filtered, so a
forgotten declaration fails safe (D-13). `scrub` is the error boundary and
runs whatever the destination is, because `runs.error` is free text that
`verbatim status` prints and there is no log file (D-16).

The rules: the credential this run resolved wherever it appears, PEM
private key blocks whole, header-shaped lines, JSON string pairs and
NAME=value assignments whose name matches token|password|passwd|secret|
key|api|bearer|authorization. Each leaves a marker naming what went, so a
reader can tell filtering from a provider that returned less. The name
test over-matches on purpose: over-redaction costs a caller some detail,
under-redaction costs a key rotation.

Egress only, never ingest - PROJECT.md bars ingest-time redaction, and a
source-level test holds the ingest path shut against this module.
One code path for every OpenAI-compatible endpoint (OBS-05, D-05): a POST
to the configured base URL's chat/completions, a bearer header carrying
the resolved credential, the configured model and messages, and D-09's
`response_format`. Base URL, model and key are the only things that
change between local and remote - no Anthropic header, no second body.

The request goes out through `observe::net` and nothing else, and the
body goes through `observe::egress` on the declared destination first.
The response is read at `choices[0].message.content` specifically and
every other key on the message is ignored (D-08): the 2026-08-21 ollama
probe returned a kilobyte of `reasoning` beside the content, and a parser
that stringified the message would burn two calls a session on every
thinking model. `usage` is read back for PLAN-3's budget, as an Option so
a provider that reported none cannot look like zero spend.

`Error`'s text is private with one constructor, which scrubs. There is no
way to build one carrying an unscrubbed 401 body.

`testkit::HttpStub` is a real TcpListener serving one canned response per
connection and handing the requests back (D-20), with a prose-content arm
for PLAN-3's OBS-04 test.

Also tightens the assignment rule's unquoted-value terminator: inside a
JSON body an assignment sits in a string, and running to the next space
swallowed the closing quote and left the endpoint a broken document.
One check in the shape of the ones already there: where the shared
credentials file was looked for, whether it is there, and what its
permissions say. A group- or world-readable file on Unix is a problem
carrying the exact `chmod` that fixes it, and the test runs that command
rather than matching its text. On Windows it is `unknown` with D-15's
caveat in words, because an `ok` this build did not earn is worse than
saying it did not check.

An absent file is a `note`, not a problem: it does not exist on this
machine, most machines will never have one, and a key can also come from
`[provider] api_key` or the environment.

The check reads a path and a mode and never opens the file, so no part of
a credential can reach the report or the `--json` document - asserted
over stdout, stderr and the document in all three states. It creates
nothing, including the directory it looks in, which a test also holds.

The doctor fixture now pins `JCRENSHAW_CONFIG_DIR`: the loader falls back
to `XDG_CONFIG_HOME` before `HOME`, and the harness inherits the
developer's, so without it a test run would report on the real file.
The plan's "with the provider block absent, or present with `enabled`
unset, nothing in this plan resolves a credential or builds a request"
held only because no caller exists yet. That is a promise about PLAN-3
rather than a property of this code, and on a secrets surface the
property is what is worth having.

`credentials::resolve` now returns nothing while judgment is off: no
environment variable read, no shared file opened, no permission examined.
A 0644 file is therefore not even a refusal in that state, because there
is nothing to refuse when nothing was read - and `doctor` still reports
its mode, because that is a report and not a load.

`provider::complete` returns a new `Kind::Disabled` before any URL is
built, so the attempt log stays empty and a test reads that as a number.
Its own kind rather than `NotConfigured`: this is the user having said
no, which is the default and is silent, and that one is the user having
said yes and left something out, which is worth a note in `runs.error`.
Plans 1 and 2 are complete and their risk_surface gates are settled, 0
survivors of 2 raised each. Plan 3 stopped after task 1 so the session
could pause; nothing of it is uncommitted.

PLAN-1's files list gains tests/backfill.rs, the lease extension approved
at plan 1's structural checkpoint: observations is post-walk-derived, so
the backfill equality test now excludes the post-walk set rather than
comparing a step backfill deliberately does not take.
The daily token budget is the one knob facing money, so it stays a config
key and its spend lives in a `meta` row stamped with the date it belongs
to - every generation is a separate short-lived process, so an in-process
counter would bound nothing. The minimum turn count and the truncation
budget face quality and stay compile-time constants beside their
reasoning, and a session that already carries a judgment status is never
asked twice by a pass.

Truncation names what it cut. A session shown short and silently is a
session the model summarizes as if it were whole.
Exactly one retry: zero loses the transient case a retry exists for, and
more doubles the bill on a model that will never comply. Both requests
are charged against the day's budget.

The second unusable answer is stored under `parse_failed` with its raw
text rather than dropped - the incumbent's schema drift became permanent
invisible loss precisely because nothing kept the thing that would not
parse. The raw text is a provider response this build did not author and
`verbatim observations` prints the column, so it goes through the egress
scrubber first, and the claim columns are cleared so a failed status
never sits beside claims from another prompt version.
`run_with` splits in two. Everything the archive needs happens under the
ingest lock and ends with the `runs` row; the provider call happens after
that returns, when the guard and the store handle are already gone. A
slow provider held inside the lock would make every hook-spawned pass in
that window exit LockHeld and archive nothing, and the archive is the
work.

One session per pass. A pass is a detached background process and a
provider may take the whole network timeout to answer, so a pass that
judged a hundred sessions would be the long-lived process this design
rules out, arrived at sideways.

The lock property is asserted rather than argued: the stub now has a
stalling arm, and the test takes the lock while a call is provably in
flight - with a falsifier first, because a lock that could not see itself
inside one process would make that assertion vacuous.
This is the one path allowed to buy a second answer for a session that
already has one, because the reason to pay twice is that the prompt
changed and --prompt-version is how a user says which rows. Every other
cost control still applies, and a run that exhausts the daily budget
stops asking and says how many of the set it left unasked.

With no provider the command recomputes facts and leaves the stored
judgment columns exactly as they were, so a user who has never
configured a model can still rebuild.

The ingest lock now covers the mechanical rewrite and nothing more: the
provider calls happen after it is released, the same boundary the pass
itself keeps.
One new value on the kind filter, a second query branch, and no fourth
MCP tool. `turns_fts` is contentless with `rowid IS turns.id`, so an
observation has no natural row in it and no added clause could have
returned one.

A hit is one CLAIM, not one row: it carries that claim's own anchoring
turn id, which is the only thing that makes any of this auditable. Scope
and exclusion are the turn branch's, reused, because a branch that
queried the table directly is how an excluded project becomes visible
again. No excerpt is attached - the text is already in hand and
attaching would decompress a whole session for a turn the claim is not
quoting.

A store with no observations table says so. A filter that is accepted
and silently returns nothing is indistinguishable from "nothing was
found", which is the worst answer a recall tool can give.
D-07 puts the provider call outside the ingest lock so a request in
flight cannot stop the next pass from archiving, which means two passes
really do overlap. Both of them saw the same unjudged session, both paid
for it, and the slower write overwrote the judgment the faster one
bought. `cost::admits` could never have closed that: every gate it has
is a read, and two runs read the same null.

`judgment::reserve` compares and swaps `observations.status` from the
value the row was read at to `judging <timestamp>`, before the request
goes out. SQLite serializes the writers, so exactly one run changes a
row; the loser makes no request at all and returns a Skip::InFlight,
because nothing went wrong - the session is being judged, and the only
thing that must not happen twice is the charge. Every later write is
conditioned on that same token, so a run cannot overwrite a row it no
longer holds.

A reservation always ends. Usually the run ends it: a stored judgment, a
stored parse_failed, or a release that puts the column back to exactly
what it was when a transport failure, a non-2xx or an unreadable blob
means no answer arrived - a provider that was down leaves the session
unjudged, never unjudgeable. When a process is killed mid-request there
is nothing left to say so, so a token older than the 900s lease is
abandoned and the next pass takes the session; two exchanges can take
240s, so a live run cannot lose its own reservation.

regenerate still asks about a row that already carries a status: it
waives the terminal state and nothing else, and it may not take a row
another run is asking about right now. `unjudged` selects the null and
the abandoned, `admits` passes a reservation through rather than
reporting it as a judgment that exists, and json-shapes documents the
third shape `status` can hold.
Observations: mechanical facts with no model call, opt-in judgment through one
OpenAI-compatible provider path, every claim validated against a real turn.

The blocking risk_surface gate fired once on plan 3's range and settled over two
rounds: one high finding survived round 1 and was fixed at 89098d5 (the row is
claimed before the request), and round 2 on that fix downgraded both of its
findings, so nothing blocker/high stands.
AC4 was run against a real remote endpoint for the first time and DeepSeek
refused twice. Neither refusal was reachable from here: ollama and the
loopback stub both accept a request they are not going to enforce, so every
existing test passed against a body no strict endpoint would take.

The first refusal was shape. `json_schema` mode is two parts - `type` names
the mode and a sibling `json_schema` object carries the `name` and `schema`,
with `strict` inside it. The constant sent the half without the schema and
got a 400 naming the missing field. The schema now travels as a parameter
from the caller that already builds it, so provider still knows nothing
about observations.

The second refusal was the mode itself: `deepseek-chat` answers 400 to
`json_schema` however well-formed it is, while accepting `json_object`. That
is D-09's deferred fallback arriving as the non-speculative half - a
`response_format` key selecting one field's value, not a second request shape
and not a branch on who the provider is. OBS-05's one code path holds.

The instruction turn now also says to answer with an instance of the schema
rather than the schema itself, which a weaker model returned once; the prompt
version is bumped so those rows can be targeted.
AC4's two items are settled live rather than by proxy: a real run against
DeepSeek stores an observation whose every claim anchor resolves.

AC4 is amended. It read "only base URL, model and key ... with no other
configuration changed", and the first live remote run falsified it - a
narrower endpoint needs `response_format` as a fourth key. What OBS-05
actually buys is the single code path, and that survives, so the criterion
now says so and records what was measured.

OBS-05 and PRIV-02 carry Complete with a note. Both have a half that phase 7
deliberately did not deliver - Anthropic subscription OAuth for the first,
Windows ACL enforcement for the second - and a bare Complete would have read
as covering ground nothing here touched.
Retention is configured by hand-editing verbatim.toml and by nothing else:
the toml dependency is parse-only, so "no command writes this file" is a
property of what is linked rather than a convention. Off is the resolved
default - keep with no age selects nothing - so a store with no table cannot
act on anything, and a typo in `action` lands on keep rather than on delete.

Per-project keys go through the same normalizer the exclusions use and are
matched with `covers`, deepest key winning, because every stored project key
is a canonical git toplevel while the brief's own example writes a bare name.
Retention decides once and reports what it decided, so the pass step and the
dry-run report cannot disagree about a 'now'-relative predicate. The instant
is the caller's and travels back out on the result: two evaluations either
side of an age boundary legitimately name different sets, and reporting the
instant is what makes that explainable rather than invisible.

Only closed sessions with a known last turn are eligible, delete waits for
the transcript to be gone (D-02) and only on NotFound - a permission error is
not a missing file - and an excluded project is passed over and counted, not
silently skipped.
One transaction per session, so a kill loses one session and never leaves a
batch half done, and a failure on one is a note against that session while the
loop carries on - retention must not be able to wedge on one damaged row.

Eviction empties the blob and leaves checksum and uncompressed_len alone: they
describe the bytes the session had, and lowering the length would put every
evicted session permanently below its own watermark for recover to "repair"
on every pass forever. Deletion goes child first because the bundled SQLite
really does enforce the declared references, and it spares decisions and
labels because replay history is not rebuildable from any blob. An observation
that goes with a deletion is counted and said out loud - that row was a model
call and nothing reproduces it.
Between observe_new and record_pass, and both halves of that position are
load-bearing: observe reads one blob per newly finalized session, so an
eviction running first would destroy the bytes it was about to read, and
runs.error is the only textual channel this product has, so a step placed
after that row is written has nowhere to report.

Nothing here can fail the pass. The archive is the work and a retention step
that could not run is a note - a propagated error would be exactly what lets
one damaged row stop every future ingest of every other transcript. A pass
whose policy selects nothing adds no note and leaves runs.error null, and a
truncated pass says how many it left so the next prompt picking them up is
distinguishable from there being nothing left to do.
verify counts it and says nothing about it. Its blob was emptied on purpose,
so read_all finds no header in zero bytes and every evicted session would have
reported "does not decompress" - a store doing exactly what retention was told
to do, reading as wholesale corruption. The divergence check stays independent
of the arm: the file on disk being shorter than what was archived is a fact
about the file, and emptying the blob answers nothing about it.

The ingest path refuses the file before it opens it. blob::append parses a
header out of the stored bytes, so the ordinary path makes an evicted session's
grown transcript a per-file failure on every pass forever; writing the tail as
a fresh blob instead would resurrect a session retention deliberately emptied
and leave every stored turns.stream_offset pointing into bytes that are gone.
No work, no bytes read, no watermark movement, and the walk records nothing.
"Every table is dropped before a single row is read" is now false for exactly
one case, and the exception is the point. An evicted session has no blob, and
turns_fts is content='' - the projected body is not readable back out of it and
the record bytes index::project built it from are gone. Dropping the table
destroys the only remaining index for that session, and open_up_to_date runs at
the top of every pass, so it would happen unattended inside the ingest lock
with a Rebuilt::failed note to show for it.

So a store holding an evicted session deletes the derived rows of the sessions
it is about to rebuild, by turn id, children first, and rebuilds only those.
The two paths agree because derive_turn is already idempotent at a known rowid.
Every store with no evicted session - which is every store until someone writes
a [retention] table - drops and recreates exactly as before.

Preserved is counted separately from failed because they are opposite facts:
failed is a blob that is damaged and an index that is gone, preserved is a blob
emptied on purpose and an index that is intact and is the only copy. It is also
the honest place to record that a future change to a derived table's shape
reaches the rebuilt sessions and not the preserved ones.
One evaluation function, two callers. The pass acts and this reports, and they
share verbatim_core::retention::evaluate because two independently written
evaluations of a 'now'-relative predicate can legitimately disagree by one
session with no way to tell that from a bug. A second application path here
would race the pass's own, so --dry-run is the documented spelling of the only
behaviour this command has rather than a mode it can be talked out of.

The instant is in the document because sharing the function pins the rule and
not the clock. A pass a minute later reads its own 'now' and a session on the
age boundary can legitimately fall the other way; saying what was judged
against is what makes that explainable instead of invisible. It is the instant
and not now-minus-N-days, because the age is per project and there is no single
such number.

Opened through cmd::read, so a question does not create a store as its side
effect, and off is distinguished from configured-and-nothing-due: both are an
empty result with a reason and exit 0, but they are different answers.
The table's own comment said a later-phase data command would be held to the
whole contract by joining it, and that is all this is: one row and one doc
section, and the five properties - the envelope, the stream split, the exit
codes, the empty-result rule and the documented shape - now hold retention
without a test file of its own.

The doc section transcribes the field list, which is the point of the sweep:
a shape that drifts from the doc fails, and a doc updated without the code
fails too. It also states what the report is for - what the NEXT pass would do
under the current verbatim.toml, applied by nothing here - and what the two
verbs leave behind, because "evicted" and "deleted" answer differently to every
other command in this file and a reader should not have to run them to find out.
`verbatim compact` is VACUUM followed by PRAGMA wal_checkpoint(TRUNCATE),
under the ingest lock, taken before anything is opened. Both halves are
load-bearing: measured here, a bare VACUUM leaves verbatim.db unchanged at
438,272 bytes and puts the whole rebuilt file into a 428,512-byte WAL, so
the footprint status reports goes UP by 428 KB immediately after a
compaction. The truncating checkpoint is what makes the number move.

It deletes nothing. Retention decides what is kept, inside the pass; this
reclaims the pages that decision already freed, which is why the archive
digest is identical on both sides of a compaction.

A held lock exits 1 rather than 0: a contended ingest is caught by the next
hook spawn, but a compaction was asked for explicitly and not doing it is a
failure that has to say so. A machine with no store answers and exits 0
without creating the data directory the lock would otherwise have made.
`verbatim usage` reports archived blob bytes by project and by month, and
the database file's pages by table. The two never mix: on the live store the
file is 1,114,308,608 bytes against 465,985,120 of archive, so one number
for both would be wrong by 2.4x, and attributing the derived tables pro-rata
would make every per-project figure an estimate rather than a measurement.

The nulls get buckets. One live session has no project and one has no
first_turn_at, and a GROUP BY that drops them still prints two tables that
look right over a total that no longer reconciles.

The footprint comes from dbstat's aggregate form - 0.144 s on the 1.1 GB
store against 272,048 per-page rows - plus a freelist row, which is the
number VACUUM is about to reclaim, plus the lock page. That last one is not
theoretical: past 1 GiB, SQLite skips the page holding byte 0x40000000, and
without a row for it the live store reconciles short by exactly 4,096 bytes
while every small store reconciles fine.

Exclusion is retroactive, so the project side goes through config::visible
like every other read. A project excluded after its sessions were archived
is in neither total.
`verbatim export <dir>` writes one .jsonl per session holding the
uncompressed stream exactly as the archive stores it, plus a manifest.json
over them. The transcript is the portable form because it is the form the
data arrived in and the form any importer would read; a copy of the store
file is a snapshot, which is a different command.

Nothing is redacted, so the manifest and the terminal both say so in words:
what this is, that verbatim redacts at egress and a directory you named is
not egress, and what is NOT here - the derived tables, the decision log, the
observations. Saying it is the mitigation.

It refuses a destination that already holds anything, not just one that
already holds an export. A .jsonl a user put there themselves is exactly the
file that must never be silently replaced, and every refusal lands before
the first byte is written.

An evicted session is a present, empty file with a manifest line explaining
it, never a missing one. Excluded projects are in neither the manifest nor
the directory: export is a read path and this is the read path where that
matters most, because the output is a directory someone may hand on.
compact, usage and export go into DATA_COMMANDS and get a section in
docs/json-shapes.md. The table's own comment is the whole argument: five
properties across every command, so a new one is a line rather than a test
file, and the documented field lists are transcribed so a shape that drifts
from the doc and a doc updated without the code both fail here.

`sweep_args` grows an export destination the way it already grew show's turn
id - it has to be inside the bench's temporary tree, and a &'static str in
the table could only be a fixed path on one machine.

The prose says what each number means, and says the parts that are easy to
get backwards: usage's archive totals reconcile to sum(length(blob)) and NOT
to the file size, its footprint rows including the freelist and lock-page
rows sum to the file size, compact's before and after are the same three
files status reports, and export's output is the unredacted transcripts.
VACUUM INTO on an ordinary connection, into a snapshots subdirectory of the
data directory, under a name carrying the instant so ordering by name is
ordering by time and the prune needs no filesystem mtime. Written to a
temporary name and renamed, so a killed process leaves nothing the prune
would count as one of the kept N. Neither function takes the ingest lock:
the whole point is a consistent copy without stopping an ingest.
A `[capture]` table on `verbatim.toml` naming `full`, `lean` or `minimal`,
resolved onto `Config` and carried down the ingest path to a new
`session_meta.capture_mode` column. Nothing elides yet - this is the column
and the wiring that says which mode a session's bytes were stored under.

The column is appended at the END of `session_meta` in both `CREATE_SQL` and
`BRING_FORWARD_COLUMNS`, so a fresh store and one brought forward from an
earlier binary carry identical column order, and an added column costs no
`DERIVED_SCHEMA` bump and forces no rebuild.

Its upsert rule is not a `coalesce`: the column reads `full` only while every
append to that session has been full. `blob::append` copies completed blocks
across untouched, so a later full pass must never move a `lean` or `minimal`
session back - those bytes are gone from that blob and nothing revisits them.

An unrecognized `mode` resolves to `full`, under the rule
`ResponseFormat::parse` states and for a sharper reason: a typo must not
silently start throwing away tool output.
A `capture` module holding the elision itself: one record's JSON line in, the
bytes to store out. Under `full` the line comes straight back and is never
parsed, which is what keeps the default byte-identical and free. Under `lean`
and `minimal` the top-level `toolUseResult` and `attachment` values are
replaced by `{"verbatimElided": <bytes>}` - the key names the elision, its
value is what stood there - and the line is re-serialized.

`minimal` elides both unconditionally; `lean` elides one only when its
serialized value passes 8 KB, the breakpoint the phase measured and the reading
of the brief's "elide LARGE bodies". Without a threshold the two modes would
store the same bytes.

This is elision, not redaction: a whole named subtree goes and a mark says so.
Nothing is rewritten in place and no value is partly kept. Nothing else in the
line moves either - `type`, `uuid`, `timestamp`, `sessionId`, `cwd`, `subtype`
and `compactMetadata` are what classifies a record, so an elided record is
still the same turn, of the same type, at the same ordinal.

A line carrying neither key, and a line that is not a JSON object at all, come
back untouched and are never re-serialized, so only the records that shrink pay
the round trip.

`tests/fixtures/session-capture.jsonl` covers every arm: a tool result over the
threshold and one under, an attachment over and one under, and a plain prompt
and assistant turn with neither. It stays out of `TRANSCRIPT_FIXTURES`, whose
members are ingested wholesale by tests that assert counts.
The elision goes in between them, so ingest now scans twice and the two scans
answer two different questions.

The FIRST scan is over the raw tail and says only where this pass stops in the
FILE: the watermark is a file offset, `read_tail` seeks by it, and it has to
keep meaning that. `bytes_read` stays file bytes past the watermark, which is
what `runs.bytes_read` and `status` report.

Then the mode acts on those bytes, line by line, framing preserved. The SECOND
scan is over the bytes actually being stored, based at the blob's own
uncompressed length, and it is the one the turn rows are derived from - so
`turns.stream_offset` and `byte_len` address the stored stream, which is what
`blob::BlobReader`, `recall::get` and `reindex` all read.

Under `full` the stored bytes are the raw bytes and the base is the watermark,
so every offset is arithmetically what it was and phase 1 and 2's tests are
untouched, `the_blob_reproduces_the_transcript_byte_for_byte` included.

`Existing` grows the blob's uncompressed length, read off the same
`session_meta` row as the three flags it already reads, so this costs no query.

The second-pass case is not here: an elided session's watermark is still ahead
of its `uncompressed_len`, so recovery lowers it on every run. That is the next
commit's.
Recovery's lowered-watermark sweep and `ingest::prefix_matches` both compare a
FILE offset against the STORED bytes, which only carries information while the
two coordinate systems coincide. A capture mode's elision is exactly what ends
that.

The sweep is restricted to sessions whose `capture_mode` is null or `full`.
Left alone it would "repair" every elided session on every run - `byte_offset >
uncompressed_len` is the NORMAL state there - lowering the watermark and making
the next pass re-read and re-append bytes the blob already holds, forever. The
orphan-watermark arm is untouched: it compares nothing against stored bytes and
is what removes the watermark of a session retention deleted.

`prefix_matches` answers `false` for a session not captured under `full`, so a
flagged session stays flagged, stays skipped, and stays named by `verify`. The
alternative is appending at a stale offset onto a blob whose middle no longer
exists anywhere, with `verify` reporting it clean because the blob's checksum
still matches itself.
john merged commit 261c691831 into main 2026-08-23 03:28:06 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: crenshawdev/verbatim#1
No description provided.