# AGENTS.md - csift operating manual

Project-specific operating manual for any AI agent (Claude Code, Codex, Cursor) working in this repo. Read this first when a conversation opens. This file is sufficient for **repo mechanics + invariants** - directory map, dispatch wiring, test harness, the rules you must not break - so a fresh mid-size model can add/modify a subcommand or fix a bug respecting every invariant without spelunking the source. For the exact **behavioural contract** of a subcommand (precise flag semantics, output shape, edge cases), open the matching `SPEC.md` section 6 subsection; SPEC is the authority on *what* each command does, this file on *how* to work in the repo.

> **About this file.** `AGENTS.md` is the canonical, vendor-neutral filename. `CLAUDE.md` is a **symlink to `AGENTS.md`** - edit `AGENTS.md` only; the symlink follows. **Companion doc:** [`SPEC.md`](./SPEC.md) (~175KB / ~1240 lines) is the product/behaviour spec - open it whenever you change search/verbatim/recover/etc. behaviour. Concrete pointers: SPEC **section 4** = record semantics, **section 5** = the full label taxonomy (`search -t` role.category.leaf), **section 6** = per-subcommand specifications (flags, output shape, edge cases), **section 7** = the performance contract, **section 8.2** = the JSON envelope v2; `## 6 Subcommand specifications` opens with the per-version CHANGE LEDGERS, ordered newest-first (the newest entry is authoritative; historical entries describe their own versions' surfaces). This file = how to work in the repo; SPEC.md = what to build. **The FIVE-DOCUMENT CONTRACT:** `SKILL.md` = the LLM manual (density + weak-attention retrievability) · `--help` = the human (CLI-proficient) manual — INFORMATION-PARITY with SKILL, never thinner, but plain prose with stronger structure/layout (help IS surface: changing it bumps the version triple) · `README.md` = promotion · `SPEC.md` = design intent · this file = maintenance. When code and a doc disagree, **code wins** - re-verify, then fix the doc.
>
> **MAINTAINING `SKILL.md`**: it is the always-CURRENT mastery manual an LLM loads, never a ledger. So every later edit REWRITES the section that owns the fact into its current form and NEVER appends a note beside the old one - no "since vX", no `(v0.13.0)` rider, no patch-on-patch. The **Surface line is the file's only staleness detector** (§7 versioning): a reader checks it against `csift --version` and re-reads on a mismatch, which is what makes carrying no history safe. State a fact ONCE, in the section whose topic owns it, and cross-reference by section number elsewhere - a fact in two sections is a defect, because one copy goes stale silently. A NEW surface adds its rows to the two 73-row transparency tables (identity + shape, pinned against `Class::ALL` by `model::inventory::tests::skill`), its relation to the inventory in §8.2 (pinned against `EdgeKind` in both directions, in SKILL **and** SPEC 4.12, by `model::provenance::tests`), its keys to §18.2, and its recipe to the topic section that owns it - never a new appendix. A retired spelling goes in §10 and nowhere else. TRANSPARENCY is the standing law: every leaf names the on-disk shape it stands for and every marker word and JSON field is documented, so a reader can cross-check a csift label against the raw line; csift may never speak a language the jsonl does not.

---

## 1. What csift is

**csift - "ripgrep for Claude Code session transcripts".** A fast Rust CLI that **lists** and **regex-searches** Claude Code session `.jsonl` files under `~/.claude/projects/`, plus recovers files, reconstructs compaction-clipped turns, maps subagents, and extracts pasted images.

- **Primary consumer is an LLM** - a Claude Code agent searching/recovering its own or a peer session. Output is clean, token-efficient, regex-driven. Default output is human/LLM-readable (session/turn/label/timestamp headers); `--format json` is the machine format (there is no bare `--json` flag). `search --raw` / `show --raw` emit VERBATIM jsonl lines (the escape hatch for unrendered fields).
- **Explicitly NO BM25 / embeddings / semantic search.** Pure regex only. Lexical tokenisation across scripts (CJK / multi-byte) is intractable for scoring; regex is the strength and the whole point. Never add semantic search.
- **Seventeen subcommands** (`Command` enum, `cli.rs`): `list`, `search`, `show`, `stats`, `agents`, `whoami`, `files`, `recover`, `plan`, `verbatim`, `image`, `status`, `wait`, plus the CHANNEL four `send`, `msg`, `ack`, `deliver` (+ ONE hidden `Turns` tombstone variant — never runs, always bails with the rename pointer to `verbatim`; keep it bare + hidden). `show` = record FETCH (by `--line`/`--uuid`/`--turn`, one transcript, rendered full or `--raw` verbatim bytes — `search` finds, `show` fetches; `--turn N|A..B` fetches EVERY record of the named turn(s) in the SAME `·tN` numbering `search`'s `<tok>·tN` headers print (`<tok>` = a STABLE first-8 id-prefix token, an `@` target as-is; there is no `sN` legend and no session ordinal), so `show --turn -3..` is the tail-peek/monitoring path — "read a session's recent turns from the live transcript"); `verbatim` = the compaction-fidelity specialist (reconstruct the verbatim user/assistant turns a compaction summary clipped — the command FORMERLY named `turns`, a zero-BC rename: `csift turns …` is now an unrecognized subcommand; the module file stays `src/commands/turns.rs`, handler `commands::turns::run_verbatim`, args `cli::VerbatimArgs`, variant `Command::Verbatim`); `stats` = one-scan per-session aggregates (tokens by model — usage counted ONCE per API message via `message.id` per-field-MAX dedupe: CC repeats the identical usage object on every per-block record, so per-record sums over-read 2-3.5x; dedupe is PER FILE, the scope TOTAL sums files / narration blocks per model / tools / LIVE turns (§3.3, the survival axis: equal to the `search --count-by turn` row count) / span / compactions / the three whole-file chain totals `abandoned_turns`, `rewound_turns`, `replay_copies` / a whole-file line-type census). (There is NO `pending` subcommand — the elicitation sidecar is merged TRANSPARENTLY into every record-reading surface instead; see §3.10.) `status`/`wait` are the LIVE-TRUTH pair — point-in-time, explicitly non-reproducible, the one documented departure from the forensic contract: `status` joins the harness session registry (`<claude-home>/sessions/<pid>.json`, transition-writes only) + the transcript tail state machine (unreturned tool call = in flight) + a per-platform pid probe (`ps`/`/proc` on unix, PowerShell `Get-Process`/`tasklist` on Windows; reuse-guarded via procStart - UTC asctime on unix, a FILETIME tick count on Windows - vs the probe's own start instant; a foreign `pidDomain` row is never probed) into one of SEVEN verdicts with named evidence (adds `idle-background-open`: a clean end_turn with counted background tasks not returned - never a `stop`) — child lanes classify `in-flight|generating|settled` (`generating` = tail-record age <=300s AND last stop_reason != end_turn; there is no mtime-derived state, because an mtime moves for a write that returns nothing), settled lanes FOLD to a count, a tasks section reads `<claude-home>/tasks/` (both the full-uuid and `session-<first8>` dir forms), a BACKGROUND section (`commands/live/background.rs` + `background_scan.rs`) scans the WHOLE main transcript for backgrounded shells / async agents / Monitor arms and their `<task-notification>` completions (joined by the launching tool_use id across three carriers incl. `queue-operation` lines and `queued_command` attachments; a subagent target also reads its PARENT main; the harness writes nothing about a running shell at EOT, so this scan is the only instrument) — and a shell reaches that section through FOUR doors, not one: the model's `run_in_background` flag plus the THREE harness-side entrances (ctrl+b, a timeout, a message delivery) that move a FOREGROUND command and leave the launching `tool_use` unflagged and unrewritten, so their only trace is the receipt sentence and each row carries `entered_by` (§3.9), under the LENS `--background-since WHEN` / `--ignore-background RE` shared by both commands, and a LAST section prints the newest prompt + reply as excerpts under the not-a-review warning; `wait` polls with STRICT post-start baseline semantics, REQUIRES `--timeout` (BREAKING - a background task may never return by design), censuses the activity that landed while it waited, and exits 124 on timeout with the at-exit report (the one exit-code exception). `--until notification` and the census read the same three pulse carriers the background scan joins (a user record for the idle delivery; a `queue-operation` enqueue line and a `queued_command` attachment for a pulse absorbed mid-turn, which never becomes a user record). **The CHANNEL four (`commands/live/channel/`)** carry a message between Claude Code lanes and from outside Claude Code, over files csift owns (§3.13): `send` queues ONE message for ONE resolved lane and prints ONE verdict from the policy table (`OK` | `FULL` | `MAY-FAIL` | `UNPREDICTABLE` | `REFUSED`, exit 0 for every one of them), delegating an official transport by PRINTING the call rather than performing it; `deliver` is the hook entry the user pastes as `csift deliver --slot k` lines and emits one envelope chunk per slot at a delivery event (it also refreshes the lane's armed marker, so a sender can tell CONFIGURED from ARMED); `msg` joins the ledger's INTENT to the receiver transcript's FACT into one verdict (`DELIVERED` | `INTENT-ONLY` | `QUEUED` | `HELD` | `EXPIRED` | `ACKED` | `REFUSED`) and, with no id, lists the lane's ledger; `ack` appends the one line only a receiver can write. `whoami` grew alongside them: a `@<agent-id>` (or `@<Name>@<Team>`) target, the `self`/`parent`/`topology` sections, `--to <TARGET>` (the same policy table run as a prediction, writing nothing), `--peers` (every live lane as `id kind state`, deliberately nothing more) and the outside-Claude-Code answer.
- **Subagent spanning default:** `list`/`search`/`stats`/`files`/`recover`/`plan`/`image`/`status`/`wait` span each session's subagent transcripts by default. EVERY span command answers the SAME two switches: `--no-subagents` (restrict) and `--subagents` (affirm — a no-op on the default-on commands, kept so the pair is uniform; the contradictory pair is a clap `conflicts_with` parse error). `verbatim` is the exception (its per-session budget MULTIPLIES, so it defaults to top-level thread only; `--subagents` opts IN — the field is named `include_subagents` but the FLAG is `--subagents` — and a target is REQUIRED). `agents` LISTS subagents as its targets, so it hard-rejects BOTH span flags with a pointed error (its `--subagents` twin is a hidden no-op that exists only to be rejected). EVERY default-on `*Args` exposes `want_subagents() -> bool` (= `self.subagents || !self.no_subagents`) feeding `SubagentScope::from` (`true` -> `WithSubagents`, `false` -> `TopLevelOnly`); `verbatim` returns `self.include_subagents && !self.no_subagents`. `SubagentScope` has exactly those two variants. (There is no `--subagents-only` mode.)

---

## 2. Targeting grammar (@-tokens) - shared resolver `path::resolve_session_files`

Every session-operating subcommand resolves its target(s) through ONE function:
`path::resolve_session_files(paths: &[PathBuf], scope: SubagentScope, caller: Caller) -> Result<Vec<PathBuf>>` (src/path/). It returns the concrete jsonl files to scan. Multi-target commands actually call the thin wrapper `path::resolve_targets_with_session_list` (src/path/), which first UNIONs in the `--sessions-from <FILE|->` id list (`extend_with_session_list`, src/path/ — bare or `@`-prefixed uuid/prefix/agent-id tokens, each appended as an `@<id>` positional; an EXPLICIT empty list resolves to an EMPTY scope, never all-projects). `whoami` is the only subcommand with no target (reads `$CLAUDE_CODE_SESSION_ID`).

Each positional token (for `search` the FIRST positional is PATTERN, sessions come as later `@<uuid>` positionals):
- **real cwd path** / `.` -> encode it, locate the matching `~/.claude/projects/<encoded>` dir.
- **encoded dir token** (`-Users-...`, or the Windows drive shape `C--Users-...`) -> used directly; a bare `--`-leading token is rejected as a mistyped flag by `parse_project_target` (src/cli/ — a UNC-encoded `--server-...` dir is targeted via `@--server-...`); a drive-shaped token that matches no projects dir falls through to real-path resolution (it can be a genuine relative path).
- **`~/.claude/projects/<encoded>` path** -> used as-is.
- **`@<uuid>`** -> that one top-level session (`is_uuid`).
- **`@<uuid-prefix>`** e.g. `@1a2b3c4d` -> the leading hex of a uuid (4..=11 dashless chars); unique-resolved against enumerated sessions, else ambiguity error (`is_uuid_prefix`).
- **`@main`** -> the CALLING TOP-LEVEL session, read from `$CLAUDE_CODE_SESSION_ID` (`resolve_env_session`).
- **`@trap:<marker>`** -> the CALLING SUBAGENT (or main thread). CC withholds a subagent's OWN id from its Bash env, so a running subagent cannot name itself. The caller INVENTS a unique literal marker, embeds it in *this very* csift command; csift finds the transcript whose SHELL `tool_use` carries it (`resolve_trap` — `Bash`, or Windows' separate `PowerShell` tool). STRICT marker grammar (`validate_trap_marker`, src/path/): EXACTLY 3 CamelCase words (no single-letter / ALLCAPS like `HTML`/`USB`) + exactly 4 non-trivial trailing digits, hand-invented one-shot + CONTEXT-INDEPENDENT, shaped like `@trap:JollyShinyBrook4283` - a RESERVED example csift hard-refuses (don't copy it; a pasted example self-collides, so invent your own). Routes on EVERY target-taking subcommand via the shared `resolve_session_files`, AND on `whoami` (`whoami @trap:<marker>` -> the subagent's full UPSTREAM ancestry chain self -> ... -> top-level root, the walk-UP mirror of `agents`' walk-DOWN; env-independent; `path::resolve_trap_who` reuses `agents::topology_for_session` + `parent_agent_id`). Malformed/ambiguous -> hard bail with guidance. **TIMING (measured live on both lanes):** a SUBAGENT's transcript flushes per content block, so the launching tool_use is on disk at dispatch and a first try resolves. The MAIN conversation's record is an ASYNC FLUSH of the completed assistant message, landing ~1-3.4s AFTER dispatch - a RACE, not a wait (a 3s in-command sleep seeing 0 on disk sits exactly at the window's edge and has no resolving power either way; a 263s command was observed with its unpaired tool_use on disk 39s in). csift finishes inside that window, so a top-level FIRST use normally misses and a re-run of the SAME marker resolves - the no-match error routes both paths, `@main` FIRST (the main thread's direct answer, no race), then the literal-marker check, then retry-as-lane-confirmation (a NEW, SEPARATE invocation: a second attempt inside ONE script runs in that same unlanded window, whose width is invisible from inside; a FRESH marker restarts the race). A @trap that RESOLVES to the main transcript prints a stderr lane note (stdout unchanged, exit 0). The same flush window bites a UserPromptSubmit hook asking for the PREVIOUS prompt: at that instant the CURRENT prompt's record may or may not be on disk yet (both observed live), so a hook-side "previous prompt" query excludes hits younger than now-3s (the measured window is ~1-3.4s) and takes the newest survivor.
- **`@<agent-id>`** -> a subagent + its TOPOLOGICAL subtree (unless `--no-subagents`). Accepts EITHER a bare hex (built-in/workflow, len >=12, `is_bare_subagent_hex`) OR a name-embedded **teammate** id like `aVSRepro-68a2a1661c9390c1` (`is_teammate_agent_id`, path.rs). The shared gate is `path::is_subagent_id` (= `is_bare_subagent_hex || is_teammate_agent_id`); use it (NOT `is_bare_subagent_hex`) for any new `@<id>` / pin routing. EVERY id `csift agents` prints round-trips here regardless of shape, through the `is_subagent_id` gate (src/path/) via `pins_single_session` (src/path/) — a teammate id tested with `is_bare_subagent_hex` alone falls through to the project-dir branch and fails — and the teammate arm tolerates dashes in the NAME (`aP1-engine-9cf2f06d6235ca64` is real data — the head accepts `[A-Za-z0-9-]` with an explicit `!is_uuid` guard so an `a`-led uuid can't slip in). Downstream `resolve_agent_subtree` matches a subagent by EXACT `session_id_from_path`, so it already handled non-hex ids; only the entry gate needed widening.
- **`@<Name>@<Team>`** -> a teammate's ROUTING form, the value the official `SendMessage` addresses. Grammar (`path::is_teammate_routing_id`, src/path/ids.rs): after the sigil a name of `[A-Za-z0-9_-]+`, one `@`, a team of `[A-Za-z0-9_-]+`; a name never contains `@`, so the split is unambiguous and no other `@`-target shape carries an interior `@`. Resolution (`path::resolve_teammate_routing`, src/path/routing.rs -> `subagent::teammate_ids_by_routing`) enumerates the scope's `subagents/**/*.meta.json`, keeps `taskKind == "in_process_teammate"` with `name` and `teamName` matched EXACTLY (Claude Code folds no case on that path), and yields the transcript-form id. Fail-loud both ways: zero matches names the grammar and points at `agents --shape teammate`; more than one lists every matching TRANSCRIPT id, because the ambiguity is REAL - the routing form can collide (two same-named teammates in one team share it), the transcript id never does. TWO IDS, minted apart at spawn: `identity.agentId` is the routing form, `identity.resumableAgentId` the transcript form, and only the latter reaches a hook payload and the `agent-<id>.jsonl` stem - so csift keys its own targeting on the transcript form and prints both (`agents` node line `routing: <Name>@<Team>` + JSON `routing_id`; `whoami`'s `self` section; the teammate control hint's third line).
- **`*.jsonl` file** -> one transcript; a path containing a `subagents` component is treated as a subagent (+ subtree), else a top-level session.
- **bare uuid (no `@`)** is NOT special - it falls to the path branch and fails as "no project dir named <uuid>". Always prefix with `@`.
- **unrecognized `@`-shape** (a 1-3-char hex prefix, a dashed fragment, any non-id token not leading with `-`) -> HARD ERROR naming the @-grammar (the `resolve_session_files` catch-all arm) - it never falls through to path resolution; only a `-`-leading token OR the Windows drive shape (`<letter>--...`) is treated as an encoded project-dir name (`@-Users-...` and `@C--Users-...` both work). A 1-3-char hex token gets the dedicated "too short for a session-uuid prefix - needs 4-11" message.

**DUPLICATE COPIES (claim PATH-014).** One session id can sit under TWO project directories - a renamed project whose directory was copied, and two harness doors that reach the same shape (the `CLAUDE_CODE_PROJECT_DIR_NAME` override, whose enumeration deliberately searches BOTH the overridden and the natural directory, and `relocateSessionTranscript`'s fallback mover, which can strand its source). Claude Code's own resume lookup walks every project directory for `<uuid>.jsonl` and returns null rather than choose when a second one has messages, so the harness treats the shape as real and as ambiguous without resolving it. `path::dedup_copies` (src/path/dedup.rs) collapses the copies at the ONE resolution point, keyed on the BASENAME (PATH-012) and decided by the BYTES: `DuplicateRelation::{Identical, Prefix, Divergent, Unknown}` - compare sizes, then the first min(size) bytes via mmap. IDENTICAL and PREFIX merge onto the LONGEST copy (a prefix copy is missing records the longer one carries); DIVERGENT merges NOTHING and every member stays in scope; UNKNOWN is a copy the mmap could not READ (its `DuplicateMember::error` carries the root cause) and merges nothing either - read as `&[]` such a copy is an EMPTY one, a prefix of every other and larger by metadata, so it would win the order and the note would announce the readable copy as dropped. `DuplicateRelation::merges()` is the ONE predicate both the drop set and the `list` row's fate line ask. An mtime never enters the rule (a preserving copy re-applies them); an equal-size tie goes to the copy whose directory basename equals the encoding of the FIRST record carrying a `cwd` (a probe deliberately SEPARATE from `admit_entry`'s line-1 `read_first_cwd`, whose widening would DROP both copies of a renamed project - the coupling PATH-008 records), then to the smallest path. The merge runs BEFORE subagent gathering, because a copied project directory carries the whole sidecar with it, and it runs ONCE MORE over the whole resolved UNION, which is what keeps one rule over the set: a DIVERGENT session pair keeps both sidecars (which can hold identical copies of one agent transcript), and `list @<uuid> @<agent-hex>` reaches one `agent-<hex>.jsonl` down two paths - two half-merges picked different winners and both copies survived. `resolve_agent_subtree` therefore COLLECTS and never decides: it finds every copy instead of the first one `read_dir` yields - stopping at the owning session and PROBING each project directory for a copy of that one basename, never walking every sidecar twice - and the union pass merges them. That pass reports only the groups it newly decided, since a group that merged nothing is still there for it to re-find (the basename is the id for both kinds: `<uuid>.jsonl` and `agent-<hex>.jsonl`). The `list` row join is on the row's own PATH rather than on the id, because a group's id is that shared basename while a subagent row carries the stripped hex. `resolve_session_files_reported` / `resolve_targets_with_session_list_reported` return `Resolved { files, duplicates }`; the plain forms keep the bare `Vec<PathBuf>`. Disclosure: `emit_duplicate_notes` prints one stderr note per group at resolution (the `@main` lane-note posture, so EVERY subcommand discloses), `path::duplicate_header_fields` adds FOUR keys to the JSON envelope header - `duplicate_transcripts` / `duplicate_records_dropped` (what the merge removed), `divergent_transcripts` (the copies it did NOT merge, because the first pair reads 0 both when nothing was duplicated and when a duplicate merged nothing) and `unreadable_transcripts` (the copies it could not READ, which is why their group merged nothing): an unreadable copy stays in scope, so a surface that reads EVERY file in its scope dies on it - fail-loud, after the resolver's stderr note and before its own header - while `agents` reads a session file only through its SIDECAR, so a copy with no `<uuid>/subagents` directory beside it is never opened, that run prints its header at exit 0, and the key is the only disclosure a reader of the stream gets. the text `copies` line sits beside the scope banner on `search`/`stats`/`files`/`recover`/`verbatim`/`image`/`agents` (`list` prints none: its row carries the fact) (`path::write_duplicate_banner` where the surface holds an `Out`, `emit_duplicate_banner` where it prints its rows - section 4's exit-code bullet says why the sink matters); a `list` row carries `also_at` + `duplicate_relation`. `show` counts the UNMERGED set and keeps bailing, naming every copy's path.

Resolution is **fail-closed/fail-loud**: an id that was pinned but matches no file bails (never a silent empty). `0 paths` => every project under the root. `Caller` is a subcommand discriminator threaded through for FUTURE subcommand-aware remediation text - but it is currently **INERT** inside `resolve_session_files` (`let _ = caller;`, src/path/); pass the right variant for forward-compat, but no remediation text varies by it today. `SubagentScope` (src/path/) has exactly two variants: `WithSubagents` (default) / `TopLevelOnly`; `SubagentScope::from(bool)` maps `true->WithSubagents`, `false->TopLevelOnly`. (The former `SubagentsOnly` variant + `files`' `--subagents-only` flag were both removed in the crate-wide span-flag cleanup.)

---

## 3. The Claude Code jsonl domain model (load-bearing - verified against real data)

### 3.1 Data location + path encoding
```
~/.claude/projects/<ENCODED_CWD>/<session-uuid>.jsonl                                  # a session transcript
~/.claude/projects/<ENCODED>/<uuid>/subagents/agent-<hex>.jsonl                         # (A) built-in Task/Agent subagent
~/.claude/projects/<ENCODED>/<uuid>/subagents/workflows/wf_<id>/agent-<hex>.jsonl       # (B) workflow / OMC subagent (dominant)
~/.claude/projects/<ENCODED>/<uuid>/subagents/workflows/wf_<id>/journal.jsonl           # (C) workflow EVENT log - NOT a transcript (excluded)
~/.claude/projects/<ENCODED>/<uuid>/subagents/**/*.meta.json                            # {agentType, description?} companions
~/.claude/projects/<ENCODED>/<uuid>/tool-results/<id>.txt                               # externalised tool output
```
**Encoding** (deterministic forward, lossy reverse; CC-exact, extracted from the 2.1.228 binary): the cwd is NFC-normalized, then every UTF-16 CODE UNIT outside `[A-Za-z0-9]` becomes a single `-` (an astral char = two surrogate units = TWO dashes). NO consecutive-dash collapsing; `.`,`/`,`_`,space all -> `-`. E.g. `/Users/u/Projects/w_app` -> `-Users-u-Projects-w-app`; a `/.claude/` segment -> `--claude-`; WINDOWS `C:\Users\x` -> `C--Users-x` (letter-led!) and UNC `\\server\share` -> `--server-share`. Reverse is lossy (`-` could be `/`,`_`,`.`) so we NEVER reverse. Accept either a real fs path (encode + locate) or a direct encoded dir. **>200-char cap:** CC caps the encoded name at `MAX_SANITIZED_LENGTH = 200` (src/path/); a longer cwd is stored `<first-200>-<hash>`. The hash is NOT reconstructible (CLI uses Bun.hash, SDK djb2 - different digests), so a long path is resolved by **prefix-scanning** the projects root for `<first-200>-` (src/path/, `find_dir_by_prefix`); among collisions, disambiguate by the in-record `cwd` (authoritative). **The map is not one-directory-per-session either way** (§2, claims PATH-014/PATH-015): one session id can sit under TWO project directories (merged by the bytes at the resolver), and a session's WORKFLOW SCRIPT directory is keyed on a DIFFERENT cwd from its transcript - the harness builds `<enc(shell cwd)>/<uuid>/workflows/scripts/<name>.js` while the transcript stays under the encoding of the session's originalCwd. Those extra directories hold scripts and nothing csift walks for: over two projects roots, 55 `<uuid>/` directories sit beside no transcript and 0 of them carry a `subagents/` subtree or a single `agent-*.jsonl`, so `sidecar_dir_for_session` stays anchored at the transcript's own directory and says so at the function.

### 3.2 Record model (one JSON object per line)
Top-level fields used: `type`, `uuid`, `parentUuid`, `timestamp` (ISO8601 UTC), `sessionId`, `cwd`, `version`, `gitBranch`, `isSidechain`, `userType`, `message`, + `subtype`/`content` on system records, `isCompactSummary` on compaction summaries. Many more exist and are IGNORED by the default scanning surfaces (`attachment`, `file-history-snapshot`, `queue-operation`, `isMeta`, `sourceToolAssistantUUID`, `slug`, `promptId`, ...; a `queue-operation` line with content, the `turn_duration`/`away_summary`/`stop_hook_summary` system records and the `file-history-snapshot`/`-delta` lines are the GATED promoted leaves, parsed only under an explicit selector or a `show` address - §3.3a) - The EXPOSED KEYS are the exception to that ignore list: EIGHTEEN top-level keys a record states about the CALL behind it - the five `attribution*`, `requestId`, `apiBlockIndex`, `toolEndsTurn`, `userFeedback`, the API-failure set (`error` STRING-form only, `apiErrorStatus`, `quotaLimits`, `truncatedAfterOutput`, `isAbortedMidStream`, gated on `isApiErrorMessage`), `interruptedMessageId`, `scheduledTaskId`, `scheduledFireId` and `toolUseID` - reach `Record` as ONE `model::Exposed` behind ONE `Box` (`model::record::keys`), read by the SAME serde pass that builds the record (`#[serde(flatten)]`, `Record::exposed_keys`) and allocated only for a record that names at least one. That shape answers both halves of the width question: every parsed line of every scan carries the row, and eighteen `Option`s in the struct measured 216 bytes where a pointer costs 8 (`size_of::<Record>()` 1,368 -> 1,376, and 1,384 with the API's stop-details object behind its own pointer, pinned in `parse::tests::spine`), while nothing walks the line twice. Inside `Exposed` the width is still paid down (`Box<str>`, `u32`, the quota object parsed straight into its two read members), and EVERY value decodes LENIENTLY - a key of an unexpected JSON type reads as absent rather than failing the whole record, which is the tolerance law of §4 applied to a field that has a declared type (`model::record::lenient`). Both alternatives were measured (§4 perf note). `toolUseResult` is NOT ignored: it is kept UNPARSED (`Box<RawValue>`, section 4 perf) and deep-read by `files`/`recover` (create-vs-edit, content anchors, the section 3.11 freshness signals) — with ONE opt-in exception: a `type:"attachment"` record whose payload is `{"type":"hook_additional_context","content":[...]}` (the context a SessionStart/UserPromptSubmit/... hook injected) classifies `harness.hook.context` and is scanned only when a selector reaches that leaf or `--attachments` is given (default OFF — a default scan never parses attachment lines; the candidate needle is `&&`-gated like the boundary keep) and rendered by an explicit `show --line`/`--uuid` address flag-free (the refetch law). `Record::hook_additional_context_text` (model.rs) is the one extractor — `content` is a string ARRAY joined with `\n` (bare string tolerated).

`type` values: `user`, `assistant`, `system`, plus metadata-only `last-prompt`/`ai-title`/`agent-name`/`mode`/`permission-mode`/`attachment`/`file-history-snapshot`/`queue-operation` (these often have **no `timestamp`** - skip in time logic, never crash).

**Block types** (`Block` enum, model.rs): `{type:text,text}`, `{type:thinking,thinking,signature?}`, `{type:tool_use,id,name,input}`, `{type:tool_result,tool_use_id,content,is_error?}`, `{type:image,source}`. `tool_result.content` may be a string OR an array of `{type:text,text}`/`{type:image}`. The enum has a `#[serde(other)] Unknown` arm - new/unknown block types parse, never crash.

### 3.3 Turn boundary = `opens_turn` (4 cases), NOT just `is_genuine_user`; classification is a SEPARATE axis
A `type:"user"` record's `message.content` is EITHER a string (genuine text, older format) OR an array of blocks. **CRUCIAL: a "user" record is NOT always a human turn** - `tool_result` blocks ride on `role:user` records too. Real ratio in one session: ~393 genuine vs ~1619 tool_result-carriers. `model::Record::is_genuine_user` (src/model/) returns true iff: `type:"user"` AND `role=="user"` AND `isCompactSummary` falsey AND **NEITHER AUTHORSHIP FLAG** - `isMeta` falsey AND `turnCompanion` not `true`, the second being what the harness stamps on a record it wrote to ACCOMPANY a turn and what Claude Code's own is-this-the-person's-message predicate excludes; the two are NOT one set, because two stamping paths of the shipped build (a slash command's expansion messages, and the extra `type:"user"` messages a tool returns beside its result) touch no meta flag at all, so reading `isMeta` alone opens a turn on such a record and shifts every later turn number - AND (content is a string OR blocks contain a `text` block and NO `tool_result` block) AND the string is NOT a synthetic marker AND **NOT `is_peer_message`** (an inbound `<teammate-message>` OR `<agent-message>` is `type:user`/`role:user`/string and matches no synthetic marker, so without this exclusion it reads as the human: 106 peer messages so labelled in one real session). **THE SECTION-BOUNDARY RULE:** peer/notification tags are detected ONLY at a SECTION BOUNDARY (`is_section_boundary` - content-start, just after the relay preamble `Another Claude session sent a message:`, or right after a prior section's close tag), so a genuine message that merely QUOTES `<teammate-message>`/`<task-notification>` mid-prose (common in csift's OWN dev sessions) stays `user.message`. A tool_result-carrier is NOT genuine.

**But `is_genuine_user` is NOT the turn delimiter.** The single boundary predicate every surface keys on is `model::Record::opens_turn` (src/model/):
```
opens_turn() = is_genuine_user()  OR  is_auq_answer_boundary()  OR  is_plan_rejection_boundary()  OR  is_peer_message_record()
```
A turn opens on ANY of four cases (using ONLY `is_genuine_user` drops three of them):
1. **genuine human message** (`is_genuine_user`).
2. **answered AskUserQuestion** (`is_auq_answer_boundary`, src/model/) - the user's ANSWER is their message; rides on a non-errored `tool_result` carrier (a cancelled / `is_error:true` / `Cancelled...` / `<tool_use_error>` AUQ is NOT a boundary).
3. **tool-use rejection-with-message** (`is_plan_rejection_boundary`, src/model/) - a rejection carrying a typed `To tell you how to proceed, the user said:\n` tail; a bare rejection (no typed tail) is NOT a boundary.
4. **inbound peer message** (`is_peer_message_record`) - a `<teammate-message>`, `<agent-message>` (the agent-message form is a peer message too) OR `<cross-session-message>` (the framing a session-to-session send lands in) from another session, at a section boundary: a real delivered message, so it STAYS a turn-opener (segmentation / turn-COUNT stays byte-stable where peers already opened turns - a non-isMeta teammate/agent message; an isMeta `<agent-message>`/`<cross-session-message>` newly opens one), but `is_genuine_user` excludes it so it never counts as the human. Body render comes from `inbound_comm_preview` (`verbatim`/`list`) / `record_text_sections` (`search`/`show`), never the raw XML - and both render the SAME tag-stripped body, so every surface agrees on what a peer said. **Consequence, and it is a real loss:** `search` matches RENDERED text only, so the wrapper attributes are unmatchable (`search 'teammate_id='` returns 0) and **`--raw` does not recover them** - the `Hit.raw` mmap backfill runs AFTER the match phase over lines that already matched, so it changes what a hit PRINTS, never what the matcher SEES. The escape hatch is an ADDRESS (`show @<id> --line N --raw`), which needs no match at all. **The cross-session shape (claim TURN-031):** `type:"user"` / `role:"user"` / STRING content, `isMeta:true`, `promptSource:"system"`, `userType:"external"`, `queueSkipAttachments:true`, plus a top-level `origin` object `{kind:"peer", from, verifiedPeerPid, msg_id?, name?, fromMode?, body?}`; the open tag carries up to five attributes in a fixed order - `from` (a transport address such as a `uds:` socket path), `from-session`, `hop-chain`, `from-name` (the sender session's display name) and `from-mode` - inside the usual relay preamble + security-footer envelope. Claude Code fills `origin.name`/`origin.body` by re-parsing the tag, so csift prefers them ONLY when the record carries exactly one peer section (a batched record reads each section's own attributes). A `queue-operation` enqueue line carries the identical content one line earlier; it is a harness RIDER, so `user.queued` refuses it through the SAME `is_peer_message` predicate - one detector, no second rule to drift.

**Opening a turn ≠ the `-t` label.** `opens_turn` segments the transcript; `Record::classify` (the role.category.leaf engine, §3.3a) is a SEPARATE axis that assigns the searchable label(s). A record that opens a turn classifies as `user.message` (genuine), `user.answer` / `user.rejection` (the AUQ/rejection duals), `agent.communication.inbox` (a teammate-message), or `harness.notification.<kind>` (a `<task-notification>`) - the opener body still renders via `reconstructed_user_text`, but its `-t` bucket follows `classify`, not `opens_turn`.

**EXCLUDED from opening a turn** (machine pseudo-turns that LOOK human - never delimiters, dropped/folded as turn MEMBERS): `isMeta` records; the two interrupt markers (`[Request interrupted by user]` / `...for tool use]`); `<local-command-stdout>...`; slash-command wrappers — `is_slash_command_wrapper` detects BOTH tag orders, a leading `<command-name>...` OR the current-CC `<command-message>...` (the new `COMMAND_MESSAGE_PREFIX`; verified 14 new-order vs 35 old-order sessions), and `is_genuine_user` excludes BOTH, so neither order masquerades as the human or opens a turn (turn NUMBERING differs on transcripts carrying new-order wrappers from what a one-order test gives); compaction summaries (`isCompactSummary`). **Slash-wrapper classify:** a WITH-args wrapper carries BOTH labels `[user.message, harness.command.invocation]` with `user.message` FIRST (richest-view law), so the UNFILTERED render is the extracted `/name args` (`slash_command_name`), never the wrapper XML; a NO-args wrapper → `harness.command.invocation` ONLY; an explicit `-t harness.command.invocation` still renders the raw wrapper. (SPEC §4.2.3 is the authority.)

**`<task-notification>...` automation pulse** (src/model/) passes every `is_genuine_user` gate so it DOES open a turn, but is a machine trigger, not operator prose - so `classify` buckets it under **`harness.notification.<kind>`** (NOT `user`, because a machine trigger is not the operator), `<kind>` = one of FIVE `AutomationKind` slugs `background-command`/`workflow`/`subagent`/`monitor`/`task` (the role-`agent`-colliding `agent` slug renders as the `subagent` leaf). Render it via `Record::automation_label()` -> `[<kind> <task-id>[, <task-id>…] <status>] <summary>` (parsed from the summary, src/model/; status falls back to `<event>` then `completed`) - NEVER dump the raw `<task-id>`/`<output-file>`/`<status>` XML. **The id slot names EVERY task the pulse closes**: the next session start's ORPHAN RECONCILIATION closes several at once, and its `<task-id>` list also carries Claude Code's own scan markers - `__orphan_summary__:<agent|shell|workflow>` names the reconciled KIND and `__orphan_summary_live__:<id>` excludes a still-live task - so `model::section_task_ids` (src/model/automation.rs) splits the tags by the shared `__orphan_summary` prefix: the real ids render comma-joined, the kind renders as the trailing `(orphan reconciliation: <kind>)` marker, and a marker never occupies the id slot (`AutomationTrigger::task_id` is the first REAL id). Both facts ride a `search` hit as JSON `task_ids` (array) + `orphan_kind`; `list`, `show` and `verbatim` share the one helper, so their previews follow. `monitor` is the Monitor/ScheduleWakeup cadence pulse (`AutomationTrigger::from_summary`, src/model/automation.rs; for it the status slot renders the `<event>`, NOT a fabricated `completed` - directly relevant to monitoring/cron transcripts). A pulse carrying a `<result>` is a background-agent REPORT, so it ALSO carries `agent.communication.inbox` (child ⇨ self). **A SCHEDULED TASK coming due is the other machine trigger, and it is a different shape** (claims CLS-031/CLS-032): the scheduler writes a `type:"system"` record `subtype:"scheduled_task_fire"` whose `content` names the instant in a trailing parenthesis (`Running scheduled task (<when>)` for a cron task, `Claude resuming /loop wakeup (<when>)` for a wakeup - one formatter, two wordings, so read the PARENTHESISED TAIL, never either sentence), and then SUBMITS the armed prompt as its own `type:"user"` record - `isMeta:true`, `promptSource:"system"`, parented to the fire record's `uuid`. That fire record is `harness.schedule.event` and the prompt below it is `harness.schedule.fire` (`Record::is_scheduled_fire_prompt`, src/model/classify/injections.rs), joined by the PARENT LINK (§3.3a); the `queue-operation` line carrying the identical text one line earlier is a harness rider, so it takes the plain `harness.bookkeeping.queue` leaf by the same content-shape law `user.queued` follows. The armed text is whatever the operator stored, so no text test can find one - the leaf keys on the PARENT LINK, never on the text, and its arm sits LAST among the harness marker arms so a tick carrying a loop marker keeps its own leaf. `promptSource:"system"` is NOT the scheduler's own stamp (the submit path puts it on every `isMeta` submission) and csift reads it nowhere: it also has a version floor between 2.1.159 and 2.1.177, so counting it names about a third of the fired prompts on disk (claim CLS-031 carries both censuses). The two peer guards stay - the relay framing through `is_peer_message` and the `origin` object, which the harness stamps on everything it did not submit itself. The INSTANT is a cross-record fact and rides the label zone (`[scheduled fire <when>]`, JSON `scheduled_at`), display-only like `[summarize <dir>]` and for the same reason; `model::ScheduleFireIndex` joins it by `parentUuid`, built per file from the fire lines one more `&&`-gated candidate conjunction admits (`scheduled_task_fire` AND the key-only `"subtype"`) and DEMOTED to spine rows afterwards, so a flagless scan gains no `harness.schedule.event` hit from the keep that read the instant. **Marker arms anchor at CONTENT START**: all FIVE loop markers (the two headers `# Autonomous loop check` / `# Autonomous loop tick`, the two body sentences `You're being invoked on a timer` / `Run the autonomous check`, and the `<<autonomous-loop-dynamic>>` sentinel) are tested with `starts_with` after a `trim_start`, never `contains` - the resolver builds the whole delivered prompt so a real tick carries its marker at offset 0, and a marker deeper in a body belongs to a record QUOTING the prompt (a skill's instruction text, a peer relay, a compaction summary), which a `contains` test would hand the tick's leaf. **Standing caution: these section-3 enumerations can LAG the enum - verify the live set against `AutomationKind::slug` (src/model/) before trusting a fixed list.**

**Render + group via the shared helpers, never hand-roll:** `Record::reconstructed_user_text(plan_index)` (src/model/) yields the normalized opener body for ALL three cases (genuine text / full AUQ Q+options+answer unit / rejection text + optional `[plan: <path>]` pointer when `plan_index` resolves the rejected id; `None` = does not open a turn). Delimit with `group_turn_indices_chained` (src/model/, the ONE production delimiter - it drops every record Claude Code's own conversation chain no longer reaches), reached directly by a caller that holds the whole DAG or through `ChainView` by a file-oriented one; the bare `group_turn_indices` is the test-only bool-fixture base, and `group_turn_indices_deduped` is the middle rung described at the end of this section. **The three opener heuristics are GONE: the ONE rule is `model::Chain`, the SURVIVAL AXIS** (src/model/chain.rs + chain/). Claude Code does not reconstruct a session by reading its jsonl top to bottom: it loads every `user`/`assistant`/`attachment`/`system` record with a string uuid into a map (LAST occurrence of a uuid wins), picks ONE leaf and walks `parentUuid` from it to the head - `logicalParentUuid` is never read by that walk, so a `compact_boundary` (parentUuid null) ends it. Only what the walk reaches is the conversation. `Chain::build(records, leaf_hint)` mirrors it hop for hop: the uuid map, the preserved-messages relink and pre-boundary cut that run BEFORE any leaf is picked, the four leaf gates (a `last-prompt` line's `leafUuid` recorded with every write batch and WIPED by a later `compact_boundary`; `explicit:true` + a null leaf clearing the set; a recorded leaf that is a proper ANCESTOR of the file-order-last record yielding to that record; a leaf naming no record here falling back to the tail, never to the next-newest leafUuid), the tips fallback when a relink anchor skips the recorded leaf, the 5 s same-`isSidechain` parent repair, the cycle guard, and the two membership rules that widen the chain past plain ancestry (same-`message.id` assistant siblings of a chain assistant - plus the `tool_result` carriers parented to them - are Live, and every NON-conversation descendant of the leaf is Live). Per record it yields `Survival::{Live, PreCut, Abandoned{root}}` and, for an ABANDONED opener, `Kind::{Draft{superseded_by}, Rewound{resend}}` - the discriminator is whether an assistant record hangs below it (claim TURN-032, measured over a real corpus's 910 shared-parent opener pairs: 858 without an assistant descendant, 52 with, 0 of the 910 on the chain). **TWO fail-open guards, both load-bearing.** csift's §7d prefilter keeps only role-bearing lines, and a chain built from those alone resolves nothing (measured: 1 of 30,613 conversation records reachable on a real session) - so EVERY surface splices back the STRUCTURAL rows of the lines its own prefilter drops, via `parse::spine_record` (a depth-1 key walk lifting `type`/`uuid`/`parentUuid`/`timestamp`/`isSidechain` + `subtype`/`logicalParentUuid`/`compactMetadata`/`leafUuid`, never the payload). **The spine row is its OWN kind on its OWN stream** - a `Record` in a shared vector carries the axis's clock cost, and the walk does not. `parse::SpineRow` (src/parse/spine.rs) is the row at its real width: the physical line, a `SpineKind` tag instead of a decoded type string, `uuid`, `parentUuid`, the RAW `timestamp` string and `isSidechain`, with the rare fields (`subtype`, `logicalParentUuid`, `leafUuid` + its two flags, `compactMetadata`) behind one `Option<Box<SpineExtra>>` an ordinary attachment row never allocates - `size_of::<SpineRow>()` is 72 bytes against `size_of::<Record>()` 1,384, both asserted by an EXACT unit pin rather than assumed - a record's width is paid on every parsed line of every scan, so widening it is a deliberate act with a measurement behind it and the pin is what makes it one. The raw timestamp is deliberate: the chain runs it through the same `timez::epoch_ms` at the same two call sites, so neither kind can answer a timestamp question the other would answer differently. The producing scan is `parse::scan_lines_parallel_split`, whose per-line `SplitVerdict` routes a line to one of two output vectors with the SAME newline-aligned chunking, line numbering and malformed accounting; the single-stream scan IS that function with an uninhabited second stream (one chunking implementation, not two), and `non_candidate_split` is its arm of the malformed-line rule. A line the prefilter ADMITTED but the gates leave unsearchable takes the same road from the other side: `scan::search_one_file` moves the already-parsed record onto the SPINE stream through `parse::spine_from_record` - the identical row, field for field, pinned by a unit test - instead of removing it, so a gated attachment is a chain node under EVERY query and the flagless answer equals the `--attachments` one. The SECOND guard is the FLOOR, and where it sits is a DECISION rather than an accident. A `compact_boundary` carries a null `parentUuid` (278 of 278 boundary records over the top-level transcripts of one live projects root at the time of its census; the root grows, the ratio holds), so the loader's walk ends there - and so did csift's, which made the whole history above a cut a region csift refused to resolve. The boundary also NAMES the record the compaction re-anchored onto, in `logicalParentUuid` (278 of 278 name one), and the walk FOLLOWS that link under a CROSSING BUDGET: the global `--cross-compactions <N|all>` flag, default `CROSS_COMPACTIONS_DEFAULT` (src/model/chain/budget.rs; the value is the measured one - SPEC section 7j carries the rule and the numbers), threaded EXPLICITLY from `main::run` to every `Chain::build` / `ChainView::build` site on the seven chain-walking commands (`search`/`show`/`stats`/`files`/`recover`/`verbatim`/`image`) rather than installed as a process-global the way `--claude-home` is, so a hard-coded budget is visible where it sits. Above a boundary the walk CROSSED, the ordinary rules apply - `Live` where the chain reaches a record, `Abandoned` where it does not - which is how a turn the operator rewound past ABOVE a cut finally gets named. `--cross-compactions 0` is Claude Code's own reading, and under it csift discloses nothing about crossing at all, neither a footer line nor a JSON key, because it crossed nothing on purpose. It ends the walk AT every boundary with no exception, which is the ONE place a BUDGET and a GUARD on the `logicalParentUuid` arm decide differently: a boundary whose OWN `parentUuid` is non-null and names no record is climbable by that arm as a guard, while budget 0 buys no hop at all, so its records read `PreCut` at 0 and resolve at any non-zero budget. The corpus the differential measures - the seven top-level transcripts of one projects root that carry a boundary and sit between 9 and 21 MiB, plus the 397 MiB landmark, 32 streams over the eight - holds no transcript of that shape, so compare the budgets rather than assume on one that does. The walk still ends ON a boundary for FOUR causes - the budget ran out standing on it, or one of the three facts shut its door - and every one of them leaves a FLOOR: an off-chain record physically ABOVE the terminating record is `PreCut`, never `Abandoned`, because csift declines to call a record abandoned in a region it could not resolve, and a `PreCut` record is selectable by every selector exactly as before, only flagged. The causes are named on every surface (`model::FloorCause`): `budget-exhausted`, and one of three for a boundary whose door is shut - `unresolvable-boundary` (the `logicalParentUuid` is on no line of the file), `relink-removed` (it is on a line the pre-boundary cut took out of the map) and `boundary-loop` (it is on a line the walk already reached, which is what a preserved set produces by construction). Over 82 top-level transcripts at `--cross-compactions all` the three split 9 / 0 / 9 across the 18 files that end on a boundary; one cause absorbing all three printed `names no record this file carries` about a record the file carries, on half of them. Which of them a bigger budget MOVES is a second question and the report answers it with a PROBE rather than with the cause alone - the walk, standing on the boundary it stopped at, asks whether its `logicalParentUuid` still resolves here (`Chain::floor_crossable`), because a budget can also run out AT a boundary whose door is shut, and naming a flag value there sends a reader to re-run a scan that lands on the same record. Claim MISC-043's 52 of 278 boundary records, over 14 files, name a `logicalParentUuid` matching no `uuid` on any line of their own file (the pre-compaction records deleted from disk by the pre-boundary deletion path, the producer MISC-037 already cites), and the relink's own cut removes more; `head` and `dangling-parent` are the two non-boundary ends. The DISCLOSURE is `model::CrossingReport` and it is built in ONE place per surface: up to two parenthesised footer lines (`lines()`, used by `search`/`show` - both its `--branch-points` census and its ADDRESS modes, which move with the budget - /`verbatim`/`files`/`recover`/`image`, on stdout; on STDERR for the three modes whose stdout is a datum rather than a report, `search --count-by`, `show --raw` and `recover`'s DEFAULT restore mode), a one-segment form for `stats`' dense `chain` row (`compact()`), and SIX JSON keys (`json_fields()`: `crossing_budget`, `boundaries_crossed`, `budget_exhausted`, `boundaries_above_floor`, `floor_line`, `floor_cause`) present whenever the budget is non-zero. The floor's LINE and CAUSE are single-transcript facts and drop on a multi-transcript fold exactly as `boundary_cut_line` does; the counts add. In the blind region above the floor the measured same-parent opener rule (an opener with a LATER opener sharing its `parentUuid`) still marks the draft, so draft detection never depends on the chain reaching that far. **A REPLAY COPY is the EARLIER line**: the loader's map holds the last line carrying a uuid, so that line is the survivor for opener treatment and for addressing-by-uuid, the earlier line renders `[replay copy of L<n>]`, and BOTH stay searchable and counted - the same double-count law as the §3.5 clone, within one file instead of across two. Turn NUMBERING shifts on a transcript carrying a rewind or a replayed block (the same shift a new-order slash wrapper and a per-API-message usage dedupe each produce). **`user.unsent` is RETROACTIVE and cannot be assigned at write time** (claim TURN-026, censused over all 886 drafts and all 2,595 `user.message` records of the 28 draft-carrying sessions): a draft and its resend are the same record shape - same key set on 852 of 886 pairs, nothing but identity and time always differing - and no successor line shape separates the two populations. The ONLY discriminator is the later sibling, so a hook at prompt submit cannot apply the label, because that sibling is the prompt being submitted. The same holds for `user.rewound`, whose discriminator is a DESCENDANT. A RENDERED draft also carries its distance from the survivor (`differs from the sent message in N chars (P% of the final)`; JSON `superseding_line`/`superseding_uuid`/`diff_chars`/`diff_pct`/`diff_exact`), computed at emission only, by the bounded char-level Myers walk in `src/chardiff.rs`; a REWOUND opener leads that line with `rewound: the conversation continued from L<n> instead`. Both `search` exchange reconstruction and `files` mutation attribution route through the shared grouper so they never drift. An abandoned record stays OUTSIDE turn numbering but is searchable/censusable - an abandoned OPENER under `user.unsent` / `user.rewound`, every other abandoned record under its ordinary leaf with an `[abandoned]` / `[rewound]` label-zone marker; the footer discloses every count (`abandoned_records`, `rewound_turns`, `replay_copies`, `boundary_cut_line`, `leaf_source`, and `superseded_drafts` as before) and the JSON summary carries the same set, with `survival`/`abandoned_root_line`/`replay_copy_of` per hit; an explicitly ADDRESSED abandoned record always fetches via `show --line`/`--uuid` (JSON `superseded_draft:true` + null `turn_index`); a `--turn` window suppresses abandoned units (they belong to no numbered turn). `stats` is wired to the same spine and takes the CHAINED form, so its `turns` is the count of LIVE numbered turns - equal to the row count `search --count-by turn` reports, measured on 75 of 75 top-level transcripts - and it adds three whole-file chain totals per session and per scope TOTAL (`abandoned_turns` = openers off the chain, `rewound_turns` = the answered subset of them, `replay_copies`), never windowed by `--since`/`--turn` because an abandoned opener carries no turn index to window on. `show --branch-points` reads the same chain: it locates a fork's parent over EVERY parsed line rather than the role-bearing ones (a fork parent is usually not a conversation record - 585 of 747 corpus forks are parented to a `system` or `attachment` line), names the LIVE child, and gives every child one of five verdicts (`live`/`rewound`/`draft`/`abandoned`/`pre-cut`). **Every surface numbers turns alike, and there are TWO ways in.** `search`/`show`/`stats` merge their two streams back into ONE BORROWED file-order row list and delimit with `group_turn_indices_chained`, which takes a projector into it: an abandoned record belongs to no numbered turn, neither as a boundary nor as a member. That list is `search::Row` (src/commands/search/rows.rs, a two-arm enum over `&Kept` and `&parse::SpineRow`; `stats` and `show --branch-points` keep a local merge of the same shape), and `Row::kept()` is the ONE gate every emission pass takes - `Kept` carries no `spine` flag, because a spine row is not a `Kept` at all. The chain reads BOTH kinds through one shape, `model::ChainNode` (src/model/chain/nodes.rs): every accessor answers what a `Record` would have answered, and the three message-derived ones answer the same `false`/`None` a message-less record gave, which is exactly what a spine row WAS before. So `Builder` holds `Vec<ChainNode>`, `Chain::build_by` and `group_turn_indices_chained` take the projector, and `SummarizeIndex::from_nodes` runs the summarize pairing over the same merged list - a compaction boundary is a spine row under every selector that does not reach the boundary leaf, and the pairing has to see it exactly as it did when both kinds shared one vector. The file-oriented commands (`verbatim`/`files`/`recover`/`image`) index everything by their own record POSITIONS and cannot splice a foreign row into that list, so they go through `model::ChainView` (src/model/chain/view.rs) instead: `parse::parse_candidates_with_spine` lifts a dropped line's spine row in the SAME pass (one parse per line, never two), the view merges the surface's own records with that `&[SpineRow]` slice by line number, runs the ONE chain over the merged DAG, and answers back in the surface's own index space - `survival(i)`, `stamp(i)` (`TurnStamp::{Live(n), Abandoned{root_line}}`, whose `text()` is the one spelling of a turn slot: `7`, or `abandoned (root L42)`), `order_turn(i)`, `turns()` and `groups()` (the live turns THEN one group per abandoned branch, so a per-turn extractor keeps each branch's own `tool_use`/`tool_result` join scope). Both ways answer identically on the same DAG; the conservative `group_turn_indices_deduped` (which trusts a hole-punched DAG only as far as the superseded OPENER) now has no production caller and stays as the documented middle rung. **This is not cosmetic:** trusting a role-only chain to call a whole BRANCH abandoned removed every mutation on that branch from `files --by timeline` (measured on two real transcripts: 230 rows down to 92, and 4,778 down to 3,054), which is why the splice, not a fallback, is the fix. **What each surface DOES with the answer splits on one question - is it reporting the CONVERSATION or the DISK?** `verbatim` replays LIVE turns only: an abandoned turn is counted in a per-session footer carrying the exact `csift show @<id> --line N` that fetches it, and never re-emitted (claim CMP-020 - the compaction summariser's input is the in-memory message array, so it never saw that turn either, and replaying it would put text no model read into a reconstruction of what a compaction clipped); JSON adds `abandoned_turns` to the summary and `survival` to each unit. Reconstruction still crosses a compaction cut where Claude Code's own loader stops, by TWO mechanisms: file order, which always reaches those turns and flags them `pre-cut`, and the bounded crossing, which RESOLVES them where the boundary's `logicalParentUuid` still names a record the file carries, so at a non-zero budget the same units read `live`. `image` LISTS an abandoned image and extracts it (the bytes are on disk), marked `[abandoned]` / JSON `survival`; only `--turn`, which windows on numbered turns, leaves it out. `files`/`recover` KEEP every abandoned row and event and mark it (`[abandoned]`, a turn slot reading `abandoned (root L<n>)`, JSON null `turn_index` beside `survival` and `abandoned_root_line`), because an Edit on a rewound branch LANDED on disk; their replay stays FILE ORDER, the `files --by summary` rollups add the abandoned share without subtracting it, `recover` discloses how many replayed events came from abandoned records, and a `--turn` window on these two admits such a row by the live turn it physically FOLLOWS (`ChainView::order_turn`), because a window over a span must not silently omit a write inside it. The conversation surfaces make the opposite call for the same reason. **Perf note:** the spine pass reads the payload bytes it deliberately never parses, so `parse::spine_record`'s `skip_value` jumps string interiors with a SIMD `memchr2`; the walk cannot stop early, because `parentUuid` leads a line while `uuid` and `timestamp` sit AFTER the payload key on almost all of them (claim REC-104). The walk was never the clock cost - on a 397 MiB transcript `image` paid 103 ms of CPU inside rayon for 17 ms of wall - so what the split removes instead is the SERIAL carry of the rows, by splitting the streams and narrowing the row (§4 perf note, SPEC §7g).

### 3.3a The `role.category.leaf` classification (`model::taxonomy` + `model::classify`; SPEC §5 is the authority)
`Record::classify(ctx) -> Vec<Class>` (src/model/classify.rs) is the engine `search -t` keys on. It is **multi-label** (one physical record can carry >1 leaf), pure, tolerant (a shape csift models no leaf for → empty `Vec`, never a crash). Three ROLES, **17 CATEGORIES**, **73 leaf classes** (`Class::ALL` + `Class::CATEGORIES`, src/model/taxonomy/ - the single source of truth; a hand-kept oracle pins all 73 paths and a second table the 28 leaves `Class::llm_visible` calls invisible):

```
user      (the human)          user.{message, answer, rejection, unsent, rewound, queued}
agent     message              agent.message.{plain, structured}
          thinking             agent.thinking.{summary, narration, omitted, redacted}
          tool                 agent.tool.{use, result}
          communication        agent.communication.{inbox, sent, signal, channel}
harness   notification         harness.notification.{workflow, monitor, subagent, background-command, task}
          compaction           harness.compaction.{summary, boundary}
          command              harness.command.{invocation, stdout, caveat}
          interrupt            harness.interrupt.{user, tool}
          schedule             harness.schedule.{wakeup, event, fire, tick}
          resume               harness.resume.{prompt, placeholder}
          hook                 harness.hook.{context, outcome, feedback}
          context              harness.context.{reminder, listing, prompt, file, mode, ide, task, image, skill}
          enforce              harness.enforce.{structured-output, brief, handback, retry}
          turn                 harness.turn.{duration, stop-hooks, recap}
          system               harness.system.{refusal, api-error, notice, kill, command}
          bookkeeping          harness.bookkeeping.{last-prompt, mode, name, atis, bridge, suppression,
                                                    pr-link, artifact, file-history, cost, fork,
                                                    continued, queue}
```

The space is split by **PRODUCER**: what wrote the record decides its category. That is what replaced `harness.meta`, which had pooled six unrelated producers plus two catch-alls.

- **The engine is a DISPATCH over five MECHANICAL family tables** (src/model/classify/), one per axis a transcript splits on: `bookkeeping` (the line's own top-level `type` - the 18 non-transcript types), `system` (a `type:"system"` subtype - the 11 observed), `attachments` (an attachment payload `type`), `thinking` (a content block's shape), `injections` (an isMeta-user TEMPLATE). Each is a value in and a leaf out, with no look at the payload; the engine keeps the ORDER (elicitation sidecar → the channel view and the attachment table → the non-transcript line types → the three transcript record types) and the cross-record facts. Two payload types need one more fact than their value and the CALLER supplies it: `hook_additional_context` leads with `agent.communication.channel` when its FIRST content string is a csift envelope, and `queued_command` is the person's own mid-turn steer when `origin.kind == "human"`. A value no table names carries NO leaf - which the §5.4 unmodeled unit then renders rather than hiding.
- **`-t` selector = a dotted path matched by dot-SEGMENT prefix** (src/cli/selectors.rs, derived from `Class::ALL`). THREE forms: a bare ROLE **or CATEGORY** (`is_bare_face` - the two coarse "show me the conversation" asks) selects only the LLM-visible face; a GLOB (`-t 'user.*'`) and an exact LEAF reach every record under all three narrowing axes. `harness.bookkeeping` is the one category whose whole face is invisible: the bare form is ACCEPTED, matches nothing by the law, and `search` prints one stderr note naming the remedy beside the ordinary empty-result diagnosis (JSON `empty_visible_face`, derived by `cli::empty_visible_faces`). `is_statically_empty` bails ONLY when `-T` cancelled `-t`.
- **Retired spellings are HARD ERRORS naming their successors** (`retired_successors`, src/cli/selectors.rs): `harness.meta` and every `harness.meta.*` path, the flat `thinking`/`tool`/`tool-response` spellings, and `harness.schedule.continuation`. No alias, no forwarding arm. `agent.message` and `agent.thinking` are NOT retired - both are CATEGORIES rather than leaf paths, so each parses and names a whole face. `--additional-context` is retired the same way (a hidden tombstone flag that bails): the leaf it gated has a name, `-t harness.hook.context`. `--attachments` keeps its wide meaning.
- **A bare face narrows on THREE axes** (SPEC §5.8/§5.9; only the first is per-LEAF): ① leaf visibility - the 28 `llm_visible() == false` leaves, grouped by WHY (the two scan-layer user leaves; `user.queued` and all 13 bookkeeping leaves, non-transcript lines with no `message{}`; the two textless thinking leaves; the message-less system records; `harness.hook.outcome`); ② record-level DELIVERY (`Record::delivery_override`, src/model/delivery.rs - the assembler's three cases plus the attachment converter's two: a payload type in `inventory::NEVER_CONVERTS` is not delivered, and a `hook_success` is delivered iff the RECORD carries a top-level `rendered`); ③ record-level SURVIVAL (§3.3). The three are independent, and a `PreCut` record is never narrowed away by ③. **ONE leaf is exempt from ②, the single stated exception to the visible-face law** (`cli::selectors::keeps_face_against_delivery`): `agent.message.structured` rides a `structured_output` attachment, a `NEVER_CONVERTS` payload, so ② would drop it from `-t agent` and `-t agent.message` and leave that two-leaf category showing only its plain half. The axis asks what the model RECEIVED and this is the leaf the model WROTE; the API gives a turn free text or a schema-bound answer and never both, so a bare `agent.message` answers with whichever the turn used. FACE MEMBERSHIP alone moves - the hit's `delivered` still reads false and the `[not delivered]` marker still renders, because the record genuinely was not delivered.
- **The SHAPE INVENTORY** (`model::inventory`, src/model/inventory/): one row per on-disk shape on those five axes - 22 top-level types, 11 system subtypes, 52 attachment payload types, 8 block types, 21 isMeta template families - each naming its leaf (or none), its GATE, whether the harness converts it for the model, and whether a person sees it. It is a RECORD of the mapping, never a second one: a unit test walks every row back through the family table that decides the leaf, and another asserts the shaped leaves plus a hand-kept `UNSHAPED` table partition `Class::ALL` exactly. The candidate GATES derive from it - `cli::gated_leaves` (the `Gate::Selector` rows), `system_line_leaves`, `attachment_leaves` - so the honest-empties note can never name a stale leaf. When adding a leaf: add its shape row, or add it to `UNSHAPED` with the reason; the test fails either way round if you do neither.
- **Gated leaves**: every non-transcript line kind is admitted ONLY by an explicit `-t` reaching its leaf (or a `show` address); an attachment line ALSO by `--attachments` / the attachment census axis. The needles live in `scan::gates` (src/commands/search/scan/gates.rs), one `&&`-gated memmem each, with the 15 bookkeeping needles behind one bitmask that short-circuits the whole group on a default scan. THE NEEDLE LAW: a bare VALUE substring or a quoted KEY, never a compact `"key":"value"` pair. A bookkeeping unit RENDERS its key=value head and then the line's own verbatim JSON as the body (the matchable-text law below). The body half is the choice `--attachments` already makes for a payload, and for the same reason: a byte substring of the source line needs no synth marker; the HEAD half is fabricated, so each of the sixteen line types that builds one registers its own quoted type value in `matcher::synth::synth_marker_finders` under the same `scans_gated` predicate that admits the line - a marker set narrower than the candidate gate is the section 7 silent drop on the one path allowed to prove a negative.
- **The `harness.bookkeeping` FAMILY** (src/commands/search/record_text/bookkeeping.rs; claims QT-045..QT-051, QT-054, QT-055). EIGHTEEN non-transcript line types over THIRTEEN leaves - `mode`+`permission-mode` -> `.mode`, `ai-title`+`agent-name` -> `.name`, `frame-link`+`artifact-comment-monitor`+`artifact-autoreact-ledger` -> `.artifact`, `file-history-snapshot`+`file-history-delta` -> `.file-history`, and one each for `last-prompt`, `atis-latch`, `bridge-session`, `history-suppression`, `pr-link`, `cost-state`, `fork-context-ref`, `continued-in` and the non-human `queue-operation`. THE INVARIANT: none of these lines carries a `uuid` or a `message{}`, which is Claude Code's own split (one transcript-membership predicate over `user`/`assistant`/`attachment`/`system`, and two 38-key registration tables keyed on the same set), so none is a chain node and the only address is `show --line`; four types carry a `timestamp` and `history-suppression` names its own `ts`. THE COST: the family is about a tenth of a transcript's lines, because the whole session-metadata set is re-stamped once per 32,768 bytes appended - which is why every leaf is gated and a default scan parses none of them. THE RENDER: sixteen line types build a `[<line type> key=value ...]` head from the SOURCE LINE in the writer's own key order (an account, organisation or record uuid at eight characters, every other value cut at 80, an object as its sorted key list), and the two `file-history` forms keep the head `promoted_record_text` builds from the fields `Record` models. FIVE types name something outside themselves: `last-prompt` -> `for: leaf`, a file-history line -> `for: versions`, `fork-context-ref` -> `for: forked_from` (the cut record plus its session) and `continued-in` -> `for: continued_in` (a whole TRANSCRIPT and no record in it, which is the builder's session-only answer `[for: continued_in @<session>]`), while `cost-state` names an INSTANT, which no target shape can express without putting a timestamp in a field documented as the target record's uuid, so it stays the `bookkeeping::Fact` variant `checkpoint_closes_at`. Every unit carries `[not delivered]`. A `queue-operation` line carrying the person's own text is `user.queued` and renders that text verbatim, never a family head.
- **Multi-label + richest-view dedup**: a record selected by ≥2 of its labels emits ONCE, richest view. JSON carries the matched leaf as `label` + the full set as `labels[]`.
- **Direction (`from ⇨ to`)** on every `agent.communication.*` hit (`Record::direction`, src/model/classify/direction.rs). ONE arm answers with a WORD rather than an id: a COORDINATOR relay reads `coordinator ⇨ self`, and the `from` is the literal WORD rather than an id because the record's top-level `origin` object carries the kind and no sender field - every construction of that object in the shipped build is the one-key form (claim CLS-046).
- **Render markers**: a ROLE glyph leads each hit line - **`◂` user · `▸` agent · `⚙` harness · `?` an UNMODELED unit**; `⇨` = the comm direction; `▹` = ONLY the `agent.tool.use ▹ agent.tool.result` pairing. Two EXTENSION POINTS carry what a family adds: `Hit.markers` (a `Marker` - a `&'static str` head + an optional detail, rendered in push order after the engine's own markers) and `Hit.facts` (a `HitFact`, whose variants project themselves via `label_zone()` and `json()`, so `search` and `show` both follow with no edit at the projector). Add a variant, not a field.
- **The THINKING family** (`model/classify/thinking.rs` + `commands/search/record_text/thinking.rs`): the four `agent.thinking.*` leaves split on the BLOCK's own shape, and TWO of them carry no text at all - so their BODY is empty and what they ARE rides the label zone, where a fabricated tag is display-only and no prefilter reasons about it. `agent.thinking.omitted` is `[no summary text]`: the API returned a signed block with no summary text, which its `thinking.display` mode `omitted` means and which the shipped SDK documentation states as the API DEFAULT (claim CLS-039; the client cannot blank a block it received - the only discard path REMOVES it). `agent.thinking.redacted` is `[redacted thinking]`, the `data` ciphertext printed on no path but `--raw`. The consequence is real and documented: with nothing rendered, a pattern scan reaches neither leaf - use an empty pattern plus an exact `-t`, `--count-by label`, or a `show` address. TWO more markers name text the CLIENT wrote into the assistant's role: `[cancelled stream salvage]` on a thinking block with text and a PRESENT-BUT-EMPTY signature carried by a record wearing the `<synthetic>` sentinel (it keeps `agent.thinking.summary` and stays DELIVERED - the `isVirtual` flag that kept it out of the cancelling session's own request is stripped on the persist path and reaches no line, claim CLS-040), and `[thinking removed by the client]` on an `agent.message.plain` block whose WHOLE trimmed text is `[Thinking removed]` (claim CLS-037). Two per-block JSON keys ride every thinking unit: `signature_len` (CHARACTER length; `0` present-and-empty, `null` when the block carries no signature field - the distinction the salvage discriminator rests on) and `thinking_text_absent` (the omitted leaf alone).
- **The ONE STAMPING SITE** (`search::family_facts`, src/commands/search/hit_facts.rs): the two extension points are FILLED in exactly one place, which dispatches in a fixed order to `record_text::{bookkeeping, attachments, injections, system, thinking, keys}::facts(rec, raw, section, ctx)`. It is called at BOTH hit-construction sites - the emission closure in `hits.rs` and `addressed_unit` - so a record fetched by `show --line` carries every fact the same record carries under `search`. `Section` is the per-UNIT half (`class`, and the source `Block` when the unit came from one), so a fact that belongs to ONE block of a multi-block record is possible; the block sink carries it through (`hits/blocks.rs`). A family owns its `facts()` and its classify/render files and edits nothing else in the engine; the gate that refuses a unit the family does not own lives INSIDE that family, and a stub returns two `Vec::new()`, which allocate nothing. `ClassifyCtx::line_of_uuid` is the one cross-record helper it may use: the chain's OWN uuid map, handed back by `Chain::build_by_indexed` rather than rebuilt (`model::UuidIndex`, joined to the surface's rows by `search::RowLines`), so a `for` edge naming another record renders that record's LINE and a caller with no row space gets an honest `None`.
- **The EXPOSED KEYS family** (`model::record::keys` + `commands/search/record_text/keys.rs`): the one family keyed on FIELDS rather than on a leaf. The eighteen keys ride `Record` as ONE `Exposed` behind ONE `Box`, flattened out of the record's own top level by the same serde pass and allocated only for a record that names one, and every value decodes leniently - an unexpected JSON type reads as absent and costs its own key, never the record (`model::record::lenient`; the record still emits, its text stays searchable, and nothing is booked malformed). A record states facts about the CALL behind it, so its `facts()` gates on the KEY - a key is true of the record whatever csift labelled it, and gating on a label would make the answer depend on which selector the reader passed. Two exceptions are the ones the HARNESS scopes: the API-failure set is read only through `isApiErrorMessage`, the flag its one factory sets, and `toolUseID` only on the `stop_hook_summary` subtype, where it is a HOOK-RUN id rather than the API tool_use id its name suggests. ONE fact reads a key pair `Record` does not model - `pdf_pages` on the `agent.tool.result` of a `Read` that carried a `pages` parameter, `[pdf pages A-B of <basename>]` in the label zone and `{first, last, count, path}` on the wire - and it is read through `commands::image::record_pdf_pages` (§3.6), the same function `image` derives each page row from, so the two surfaces cannot drift. COST, stated as what it is: per EMITTED UNIT it runs `Record::tur_probe`, which returns on its first `?` for a record with no `toolUseResult` but otherwise DESERIALIZES THE WHOLE RESULT BLOB for six small fields - a typed walk that skips the unlisted values without allocating them, not a walk that skips the bytes - and only once that reads `parts` does the full parse follow. Measured: no difference on the section 7 landmark, +9% on a synthetic worst case of 64 KB of result blob per emitted tool result. Emission is bounded by matches, so it rides hits and never a scan. Twelve facts, six of which render a label-zone tag and six of which ride JSON alone; the unit's ONE `for` edge comes from `interruptedMessageId` and the hook-run id and goes through the shared `for_edge`, so both render the same three answers - and neither is a record uuid, so an ordinary transcript renders the id with `not in this file` rather than a line. The family has NO render: its facts are keys a record exposes, never what a line says. ABSENCE is read at KEY level: a missing key means this record does not say, and a fact object that IS present names every member with null for one the record lacks.
- **The RENDER SEAM** (`record_text::family_render`, src/commands/search/record_text/seam.rs): the stamping site's twin, for what a line SAYS rather than what is true of it. `record_raw_text` calls it FIRST - a `Some` wins, a `None` falls through to the engine's own generic arms - and it dispatches by the leaf's CATEGORY to `record_text::<family>::render(rec, raw, class, ctx)` through `render_families`, a table rather than a match, so the contract six lanes work against is one a test can walk rather than re-derive. TWO categories belong to two families and the ORDER is the fact: `harness.hook` offers the record to the attachment family before the injections (a `.context`/`.outcome` payload is what it is whichever leaf asked; a `.feedback` injection is decided by its text), and `harness.schedule` the same way for the scheduler's own record against the three prompts it injected. There first-Some-wins is a TIE-BREAK, never a fallback chain: **EXACTLY ONE FAMILY ANSWERS FOR A RECORD**, so a family whose render fires for a record it does not own is a bug even when another family already answered - the winner would be a table order no reader of the output can see, and the loser's text would vanish in silence. Release keeps the short-circuit; a DEBUG build asks every family in the list and `debug_assert!`s on a shadowed answer, and the seam test sweeps one specimen per leaf of the two shared categories with the family that answers for each. WHICH leaf it dispatches on is `render_leaf`, and its order is what keeps the seam behaviour-identical: the LINE's own leaf (an attachment's payload type, a non-transcript line's `promoted_class`) first, the caller's class only as the fallback - so one line renders the same text under every view of it, and the isMeta injections, whose own type names no leaf, still have a way in. Two of the five renders are deliberately EMPTY and say so at the function: a thinking leaf is decided on a content BLOCK and never renders at the record level (the family's render is `thinking::block_body`, which the block loop calls), and an isMeta injection's text is its own `message.content`, which the engine already joins. The engine keeps exactly two arms a family could have taken: the verbatim payload of an attachment csift models no leaf for (no family is offered it), and `promoted_record_text` for the turn family's three closers plus the person's queued text. `keys` has no render at all - its facts are keys a record exposes, never what a line says - and the drift test (`search::tests::seam`) walks every leaf to pin all of it.
- **PROVENANCE - `for_root` on every unit, `for[]` where a record states a relation, and the ONE-INDEX LAW** (`model::provenance`, `search::hit_facts::backfill_provenance`, `search::reverse`): `for` answers which message a unit corresponds to, and it is answered for EVERY emitted unit rather than only for the records carrying a key. `for_root` names the record that OPENED the unit's turn plus the parent-hop distance up to it - `[root L<n> +<hops>]` in the label zone, nothing on the opener itself, and ALWAYS the JSON object `{line, uuid, hops, note}`, because its FOUR answers (IS the opener / walked there in N hops / placed by the grouping and not reached by the parent links / no opener above it at all) are four facts a missing key could not separate. The opener is the turn's own opener and NOT `records[0]`: the shared grouper folds a synthetic pre-user LEAD into turn 0 and a real transcript almost always opens with one, so reading position 0 named that lead as the message every unit of turn 0 corresponds to. The hops come from a per-turn TABLE filled in ONE forward pass, never a per-hit walk - a turn is a parent CHAIN (CLS-049), so a walk per hit is quadratic in the turn and was measured at 12.9 to 25.3 seconds on a 20,001-record turn against 0.09 before the axis. `EdgeKind` is the CLOSED twenty-seven-word relation vocabulary every `for` edge names itself with; `--count-by for-kind` declares its key space from it, and the RELATION INVENTORY in SPEC 4.12 and SKILL lists every one beside the relations that are real and are NOT edges - a drift test over `EdgeKind` holds the two tables to the enum in both directions. TWO RULES keep a relation out of the vocabulary: a CROSS-FILE join is a field on the surface that owns it (`clone_of` / `cleared_from` / `continued_in` / `minted_elsewhere` on `list`, `spawn_tool_use_id` / `parent_agent_id` on `agents`, the channel verdict on `msg`), because `ForTarget` carries a session id and not the id trio; and a target that is not a RECORD (a command, an instant, a lane, an external file, an event) is a per-family FACT with its own key, because an edge resolves a record address. SIX of the twenty-seven are the ENGINE's and ride JSON alone (`EdgeKind::rendered_in_label`), each restating what its own unit already shows: `parent` (`parentUuid`, basis position), which STANDS DOWN when ANY OTHER edge on the unit already names that same target - a family's word, or the engine's own `pairs` / `repairs`, both of which name the record above; ONE RELATION, ONE EDGE, and it removes no FIELD (83 records on the landmark transcript: 80 compaction summaries, 3 paired placeholders) - `result_of` (a tool result's `tool_use_id`, basis key), the one relation that is not a uuid relation, which `model::provenance::ProvIndex` closes beside the chain's uuid map, and the four SURVIVAL relations `supersedes` / `continues` / `replays` / `branches`, which the chain already computes and the unit already renders as its distance line, its `[replay copy of L<n>]` pointer and its `[abandoned]` / `[rewound]` marker (stamped beside the survival FIELDS in `backfill_survival`, so the two can never disagree). The ENGINE also owns five relations that DO render, because no family owns their leaf and nothing else on the unit gives their target as an address: `pairs` / `precedes` / `preserves` on a compaction boundary and its summary (`pairs` on basis KEY off `preservedSegment.anchorUuid`, falling back to the `SummarizeIndex` position pairing where the boundary names none or names ITSELF - the `/rewind` summarize path passes the boundary's own uuid, claim CMP-030), `repairs` on a PAIRED resume placeholder, and `reports` on a `<task-notification>` pulse, read off the record's own text so BOTH carriers answer - the ordinary non-isMeta one is 3,922 of the 4,017 pulse-carrying records on one projects root and never reached the injections family at all - the tag answers 2,459 of them with an address, 61%, and the declared task-id follow-on would reach 1,228 more, about 92%. COUNTING RULE, two halves and both load-bearing: the FILE SET is every `*.jsonl` under one projects root - the top-level transcripts AND the `<uuid>/subagents/**` sidecar ones, excluding `journal.jsonl`, `elicitations.jsonl` and anything under a `csift-channel/` directory - and the READING is the text the classifier reads, the bare string or the newline-join of the content array's `text` blocks. A string-only reading gives 3,805 rows instead of 4,017 and moves the frame count 95 to 56, and the engine's reader takes the wide one, so its domain IS this population. **THE ONE-INDEX LAW:** no relation is computed twice. The uuid-to-line answer is the chain's own map handed back by the build; the `tool_use_id`-to-line answer is filled by the walk that already built the tool-name map and the pairing sets (`build_tool_index`, one walk where there were two); the turn root is read off the turn grouping the scan already has. A new surface PROJECTS one of those - it never re-derives it - and a new relation is a variant on the enum plus one lookup, never a second index. COST: the forward answers are per HIT and the turn's root TABLE is built on its FIRST hit and not before, so a turn that matches nothing pays nothing; the ENGINE's two edges are built only where a reader projects or censuses them (`SearchArgs::projects_engine_edges` - they ride JSON alone, so a text scan that does not census them renders every byte without them), and what remains is measured as the OUTPUT the primitive prints rather than as overhead (SPEC section 7i: a wide-match text row pays about 2.3% of CPU for 3.1% more bytes, the whole-scope JSON dump 9.2% for 17.6%). `--for <ADDR>` (`search` only - it returns a SET with a summary, which is a scan's shape, while `show` fetches the records an address names) is the REVERSE closure and runs only under its flag; its parent arm STOPS AT A TURN BOUNDARY, without which it swallows the rest of the file from any address.
- **Per-family FACT enums, and the one `for` EDGE** (src/commands/search/types/facts.rs): `HitFact` carries ONE arm per family (`Bookkeeping(bookkeeping::Fact)` .. `Keys(keys::Fact)`), each forwarding `label_zone`/`json` to an enum that lives in that family's own `record_text/<family>.rs` - so a family adds variants to its own file and edits neither `types.rs` nor `types/facts.rs`. What stays SHARED is what more than one family needs: the unmodeled shape, and the `for` EDGE. `HitFact::For { kind, basis, target }` / `ForUnknown { kind, reason }` are built through `record_text::for_edge(kind, basis, uuid, session, ctx)` (and its `for_edge_at` twin, for a caller holding a resolved line and no ctx), the only place the uuid lookup happens, and it gives FOUR answers - a uuid this transcript carries renders `[for: <kind> L<n>]`, an id no line here carries keeps the id with `(not in this file)`, a target that is a whole TRANSCRIPT and no record in it (a `continued-in` handover) renders `[for: <kind> @<session>]` alone, and a record that names nothing renders `[for: <kind> unknown (<reason>)]` rather than dropping the edge. The id is cut to its first EIGHT characters only when it is uuid-shaped (`path::is_uuid`), the handle the compaction excerpt already prints: a uuid's other 28 characters are the same shape, while a WIRE id's head is the part every one of them shares, so cutting `hook-bb000000` to `hook-bb0` or `toolu_01zzreef` to `toolu_01` keeps what does not distinguish and throws away what does. `Basis` (`key` | `position` | `inferred`) is the honesty column and is never defaulted. Every edge projects ONE key carrying a one-element array, so several on one unit accumulate through `attach_facts` - the single merge rule both JSON projectors call, where an array value APPENDS and anything else replaces. A DESCENT edge (one record to many) is not this shape: the chain's uuid map answers one way, so a family owning one writes `ForUnknown` with that as the reason.
- **The `harness.system.*` family renders per EVENT KIND, names the record each leaf is FOR, and one field is never printed** (claims CLS-060..CLS-068; `commands/search/record_text/system.rs` + `system/{heads,edges,fallback}.rs`). The five leaves render `[refusal <subtype>] originalModel=... fallbackModel=...|none apiRefusalCategory=...` in the harness's own key words (`fallbackModel=none` is the `model_refusal_no_fallback` shape, printed rather than elided because the absence IS the event - and dropped entirely when there was no source line to read, since a negative csift did not look for is a fabricated one), `[api-error status=... type=<kind> retry N/M in Xms]` plus the failure's own wording (the line carries no `content` key on 29 of 29 and 258 of 258 specimens, so the kind and the wording are all there is; each is read where its own shape puts it - the connection shape's wording at `error.message` with no kind anywhere, the HTTP shape's kind at `error.type` and its wording two hops down inside the response body it kept, so one reader walks the `error` chain for the wording and takes the kind at one level; an absent attempt renders `?`), `[notice <level>]`, `[kill]` (the record carries no payload on disk) and `[command]`; `harness.schedule.event` renders `[scheduled <when>] task=... cron=... prompt=...` off the same parenthesised tail the fired prompt joins on. Their fields live in TOP-LEVEL keys `Record` does not model, so the render reads the SOURCE LINE - parsed ONCE per entry (the render and `facts`), never once per field. EVERY exposed key is also IN the rendered text: a key that reaches JSON alone is unsearchable, so the family's `Fact` enum and the head carry one value each rather than two. The PROVENANCE edges go through the shared `record_text::for_edge` - `refused` from `refusedUserMessageUuid` (basis key), `wraps` from a delivered command's `parentUuid` (basis POSITION, because a run of local commands chains stdout to stdout and the link names the record above rather than the wrapper by construction) - while `armed_by` and the fallback turn's `banner` are `ForUnknown` with their reasons: a `taskId` is joined by MATCHING an id inside a tool result and a banner DESCENDS from its record, and a uuid map does neither. **NEVER print `error.headers`** (the section 4 invariant below). An assistant turn the API served from a FALLBACK model renders under `agent.message.plain` with the marker `[served by <to.model>, fell back from <from.model>]` (JSON `served_by` / `fell_back_from`); the ids come off the PARSED BLOCK (`Block::Fallback { from, to }`) rather than the source line, which is what makes a scanned unit and an addressed one carry the same fact. A record whose SOLE block is that one has no text at all: the 1:1 law's addressed unit renders it, and a scan emits nothing for it - the emission sweep's named exemption.
- **The MATCHABLE-TEXT LAW** (`record_text::verbatim_body` / `head_and_body`): for a line whose content lives in fields `Record` does not model, the family's key=value view is the HEAD and the line's own VERBATIM JSON is the BODY. A summary is lossy - it shortens a uuid, prints three of twelve keys - and `search` matches rendered text, so a key only the summary elided becomes unfindable rather than merely harder to find. The verbatim half is a byte substring of the source line, so the 7d prefilter and the 7f whole-file gate hold with no synthesized marker. It applies to the thirteen `harness.bookkeeping` leaves. ONE family is deliberately outside it: a `system`/`api_error` line carries the whole HTTP response header set inside its `error` object and csift prints those on no path but `--raw`, so the system family renders the FIELDS it names rather than the line.
- **The isMeta INJECTION family, and what protects a person** (`model::classify::injections`, `record_text::injections`): the harness writes text into a turn as a `type:"user"` record shaped exactly like the operator's own, one fixed template per producer, and `isMeta` is the only field that separates them. So EVERY text arm of that table anchors at CONTENT START after one `trim_start` (a template quoted deeper in a body belongs to the record QUOTING it) and EVERY text arm sits below one `isMeta` guard - without it a person pasting the nudge they are asking about is relabelled out of `user` while their message still opens its turn. Two arms are STRUCTURAL and lead, because they read a field rather than prose: the relayed human message (`origin.kind == "human"`, which takes `user.message` with the `[relayed]` marker and its `relayed` JSON twin, and opens no turn, so it is searchable without moving the turn numbering) and the fired scheduler prompt (its `parentUuid` names the fire record). `harness.hook.feedback` answers to all four names of the producer's closed table (`Stop`, `TeammateIdle`, `TaskCreated`, `TaskCompleted`) and its prefixes stop at the COLON while the producer's separator carries a newline, so csift's arm is deliberately one character wider (claim CLS-035). `harness.enforce.retry` is the HARNESS CONTINUING A TURN THE MODEL DID NOT FINISH, on its own instruction: NINE whole messages matched by equality, because the producer holds them in one list its own is-this-mine predicate tests by membership - the four redo nudges, the two classifier-stop forms (the second the first plus a sentence about a call that was in flight), the output-token-limit resume and the two mid-stream cut-off forms (one per LANE) - plus the edit-failed notice, the one member of the leaf matched as a prefix. Five of the nine carry an em dash, so a text retyped with an ASCII hyphen matches no record; keying the leaf on the producer's own list is what leaves no member of that list in the residue. An isMeta record matching no template is the RESIDUE: no leaf, nothing on a scan, `? unmodeled: user/isMeta` under an address.
- **The injection family's `for` EDGES, and the two relays a leaf cannot separate**: the family reads its edge from the `parentUuid` Claude Code stamps from the running tail of the transcript on every appended record (claim CLS-049) - `redoes` an enforce nudge's assistant turn, `blocked` a hook feedback's, `fires` a fired prompt's event record, `attached_to` everything else - or, for a notification FRAME, from the `<tool-use-id>` its wrapped pulse names, which is a wire id rather than a record uuid and renders with the builder's `not in this file` note. `harness.schedule.wakeup`, `harness.schedule.tick` and `harness.resume.prompt` carry NO edge, because no key on those records names one. The `agent.communication.inbox` arm keys on the record's own `origin.kind` and never on the leaf: a COORDINATOR relay and a PEER relay carry that one leaf and both answer `replies_to` empty, but a coordinator's origin carries only its kind while a peer's names the lane that sent it (`[peer lane <id>]`, JSON `peer_sender_task_id`, on 50 of 55 and 109 of 109 such records), so a leaf-keyed arm would print one shape's reason on the other's record (claim CLS-046).
- **The ATTACHMENT family's body and provenance** (`record_text::attachments`): an attachment line keeps TWO copies of itself and the unit names which it shows. The body rule is the conversion the harness persisted back as a top-level `rendered` (`[rendered]`, JSON `body_source`), then a field the payload carries in its own right (a `hook_success`'s `stdout`, a `hook_additional_context`'s joined `content`), then the verbatim payload JSON (`[payload]`). The decoded conversion is NOT a byte substring of its line - it is a JSON string, and every measured one carries a newline as `\n` - so what keeps the 7d prefilter and the 7f gate sound over it is the NEEDLE law rather than the substring the bookkeeping law relies on: `required_needles` derives a needle only from a run the safety predicate admits - no whitespace, no `"`, no control character, no DEL - and such a run occurs verbatim inside the raw line's JSON string, which is the same property every decoded `message.content` render already rests on. The BACKSLASH is one of those characters and took a release to become one: the predicate counted the quote, the control characters and DEL but not it, so a needle carrying one was emitted while the raw line stored it as `\\` and the prefilter proved absence over a body csift renders - measured on a converted body and, identically, on a plain `user.message` whose text carries a backslash run, which is what put the hole in the PREDICATE rather than in this family. Counting it splits such a run exactly as a quote already splits it, so `C:\Users\dev` derives the needle `Users`, and it closed for every decoded render at once. A `queued_command`'s SECOND conversion is reported (`[human-turn render differs]`) and never chosen, because the harness reader picks on its caller's own `inHumanTurn` option and no field of the record says which turn it was replayed into (REC-110, REC-112). Provenance: `attached_to` and `for_tool_use` go through the shared `for_edge`, and `produced_by` is a family fact because it points at a COMMAND rather than a record (REC-111, CLS-041). EVERY view of an attachment line takes that body rule now, the person's mid-turn steer included: `record_text_emission`'s `user.message` arm tried the verbatim payload BEFORE the seam, so that one view showed the payload JSON while an address on the same line showed the conversion; it falls through to the seam instead, and the body fact's `UserMessage` exception went with it.
- **The 1:1 law**: `show --line N` never hard-misses a PARSEABLE line. A shape with no leaf renders `? unmodeled: <type[/subtype|/attachment.type]>` plus a key=value excerpt of the line's own top-level keys (JSON `label: null`, `labels: []`, `unmodeled`). The SHAPE stands in for the leaf path and the unit's family facts render after it in the ordinary push order, so the text surface and JSON say the same things about it - a leafless record is still a record the stamping site ran for, and an attachment of a payload type csift models no leaf for still carries the attachment family's three provenance facts - a NON-isMeta image-only user record is one (`? unmodeled: user/image`; the isMeta one is `harness.context.image`, the leaf being keyed on the SHAPE), and so is a user record carrying the OTHER authorship flag and no template csift models (`? unmodeled: user/turnCompanion` - the shape names the flag, because which of the two the harness stamped is what a reader needs to tell a command's expansion from an injected prompt). That excerpt is the one rendered path that walks a line's values without knowing what they are, so TWO member names are elided at every depth and print `<elided>`: `headers`, by the sensitive-headers law of section 4, and `data`, for the opposite reason - a base64 image or document source and a `redacted_thinking` block both carry one, their head identifies nothing, and the per-value cut is 80 characters, so printing the blob costs the sibling keys that DO say what the shape is (measured on a 1,222-character payload: `media_type` and the block's own `type` reached the screen only once the blob was elided). A SCAN never emits one (gated on `address.is_some()`), so no census, `-t` result or count moves; a `--line` address also admits the NAMED lines past the §7d prefilter, scoped to those lines. A line that is not a JSON object stays a hard miss pointing at `--raw`.
- **The EMISSION law, the 1:1 law's twin on the scan side**: a record carrying a leaf the model receives is emitted by a FLAGLESS scan, exactly once. Twice a leaf was assigned to a record nothing could emit - the person's queued steer and the coordinator relay, both rendering through `reconstructed_user_text`, which answers only for a record that OPENS a turn - so the census read zero, the leaf's own `-t` found nothing, and `show --line` was the only way in. Both arms now fall back (the steer to its payload JSON, the relay to its own raw text), and the rule is SWEPT rather than pinned case by case: `every_visible_leaf_specimen_emits_exactly_one_hit` (src/commands/search/tests/hits.rs) walks every `model::inventory::specimens` row. A specimen with a visible leaf and no text section is exempted BY SPECIMEN with its reason at it (an image-only record, a fallback-model turn), never by leaf - exempting `agent.message.plain` would exempt every assistant message. When adding a leaf, add its specimen; if it cannot emit, write down why.
- **`search`-surfacing note**: `classify` runs on every record; §7 stage-1 (`line_is_transcript_candidate`) keeps `role:user`/`role:assistant` lines plus each gated keep behind its own `&&` gate. The `compact_boundary` keep is a CONJUNCTION of the rare literal and the key-only `"subtype"` bytes, because the literal alone is not the discriminator: it also sits inside the PAYLOAD of lines that are no boundary (attachments and `queue-operation` lines, which no other flagless keep reaches). The KEY is a property of the PRODUCER where the literal's rarity is a property of the CORPUS - a session writing ABOUT the literal accumulates it in prose, so that payload population is a MOVING count taken at an instant and never a fixed one. The fired-prompt keep takes the same two-literal shape for the same reason. Both censuses, each with the file set it was taken over and its counting rule, sit at their own keep in `search::scan::gates`, beside the code they justify and stated once. Every OTHER keep past the role marker is its selector bool and ONE needle, or a bitmask over a needle set - the two attachment views, the fifteen bookkeeping types - which is how a query that cannot want a line kind pays nothing for it.

### 3.4 AskUserQuestion
A `tool_use` block with `name=="AskUserQuestion"`. **HARD-WON, RE-MEASURED at CC 2.1.258 and RE-READ in the binary (ledger ELI-009): whether a PENDING AskUserQuestion reaches the jsonl while its dialog is open is a TIMING outcome of the persistence frontier, not a rule keyed on the question count** - the binary has no question-count branch on the write path (`questions.length` occurs zero times), and the frontier that releases an assistant record once its `stop_reason` lands is unchanged across builds (so "since 2.1.258" was wrong). Measured live on macOS and Windows: a **MULTI-question ask (`input.questions` length >= 2) was on disk as an unreturned `tool_use` while the dialog was up**, and a **single-question ask stayed buffered until it was answered or cancelled** (a one-question option-picker freeze looks identical to a stall on disk); the gate that holds the single-question record past the end of the API turn is not yet located, so state the two observations, not a law. `live::tail` reads an unreturned `AskUserQuestion`/`ExitPlanMode` at the main tail as `waiting-hitl`; the elicitation sidecar (§3.10) stays the instrument for the buffered shape. The registry row's `status:"waiting"` (§3.9) covers both plus permission prompts. The user's answer to an AskUserQuestion classifies as **`user.answer`** (dual-labeled with `agent.tool.result` since it rides on the answering tool_result carrier; deduped to the richer `user.answer`, §3.3a). **The answer's structured signal is EITHER of two `toolUseResult` fields:** a non-empty `answers` map (the TUI dialog: a listed label, a typed "Other" text, or the `(notes only)` sentinel plus `annotations.notes`), OR a non-blank freeform `response` string (the binary's `The user responded: <text>` branch, written by an answerer other than the TUI dialog and paired with an EMPTY `answers`); `is_auq_answer_boundary` accepts both, and `auq_exchange` renders the questions plus a `response:` line for the second. The synthesized text prefixes (`markers::AUQ_ANSWER_MARKERS`) are the fallback for carriers without `toolUseResult`; the idle-timeout branch (`afkTimeoutMs` set, text `No response after ...`) is NOT an answer and never opens a turn.

### 3.5 Compaction shape
The summary is a `type:"user"` record with `isCompactSummary:true` carrying **string** content (NOT a `type:"summary"` record). Its SECOND key is an either/or, not a constant (claim CMP-001): an ordinary compaction co-sets `isVisibleInTranscriptOnly:true`, a `/rewind` **summarize** writes `summarizeMetadata:{messagesSummarized, userContext?, direction}` in its place - one ternary in the writer, so never both and never neither (corpus: 243 of 244 summaries carry the flag, 1 carries the metadata). `direction` is the discriminator (`"up_to"` / `"from"`; `userContext` is written from a possibly-undefined value and is absent from the specimen, so never key on a three-key shape). A separate `type:"system"` `subtype:"compact_boundary"` record carries `compactMetadata` PLUS a top-level `logicalParentUuid` (the true predecessor record the compaction re-links to; the boundary's own `parentUuid` is null) - surfaced in the boundary's rendered excerpt as `[logicalParent=<uuid>]`. **`compactMetadata` is nine keys, not four** (claim CMP-002): the four scalars `trigger`/`preTokens`/`postTokens`/`durationMs` on every boundary, plus `cumulativeDroppedTokens` (the running total of context tokens every compaction of this session has dropped), `preCompactDiscoveredTools[]`, `preservedSegment:{headUuid,anchorUuid,tailUuid}` and `preservedMessages:{anchorUuid,uuids[],allUuids[]}` (the records the compaction KEPT: `uuids` is the on-disk subset, `allUuids` the in-memory superset, which is why an `allUuids` entry can resolve to no line), and on a summarize only, `messagesSummarized`. The boundary excerpt renders each present field and COUNTS the uuid lists (`preserved=<N uuids, M allUuids, anchor <first8>> segment=<head first8>..<tail first8>`); the full lists ride JSON `compact_metadata`. A boundary carrying only the four original scalars still renders byte-for-byte what it always did. **MODE:** csift derives `compact` | `summarize-from-here` | `summarize-up-to-here` from the summary's direction and prints it on BOTH records (`mode` in JSON, a `[summarize <direction>]` label-zone tag on the summary, a banner segment in `verbatim`); the BOUNDARY has no direction of its own, so it learns its mode by pairing with the summary that follows it (`model::SummarizeIndex`, built per file in `turns_match` and carried on `ClassifyCtx`) - an unpaired boundary and an unmodeled direction both read NULL, never a guessed `compact`. The pairing is WINDOW-INDEPENDENT: it is built from the whole parsed record set before any `--turn` window, `--max-count` cap or `show` address narrows the output, so fetching a boundary alone still reports its mode; only the FILE can leave a boundary unpaired (no summary after it, or a second boundary first). **A summarize IS a compaction**: it clips turns exactly like an auto-compact, so `verbatim` reconstructs across it unchanged and the mode only labels the event. A compaction summary MUST be excluded from "genuine user". `verbatim` reconstructs the verbatim exchange a summary clipped. **The loader CUT vs csift's reconstruction (claims MISC-006, REC-102):** Claude Code's own resume walk reads `parentUuid` only and STOPS at the boundary's null parent - it never follows `logicalParentUuid` (12 occurrences at 2.1.258, zero load-path readers) - so on resume the model sees the summary and the preserved set, not the clipped turns. csift deliberately does NOT stop there: it walks the FILE, so `verbatim` reconstructs across the cut and turn numbering continues through it. Both readings are correct about different questions ("what the model sees now" vs "what was written at the time"); every csift consumer wants the second. **Clone law (`session::clone_head_boundary`):** a transcript whose FIRST TIMESTAMPED record is a `compact_boundary` was minted by COPYING another session at that compaction (background-job forks preserve uuids, strip the slug, carry timestamps predating the file — never trust file birthtimes); `list` detects it and joins the origin (`clone_of`), and a clone DOUBLE-COUNTS its inherited records on every spanning surface until scoped away. The PLAN corollary: a clone has no `plan_mode` attachment, so `plan` falls back to the first-VALID-slug law (CC's own binding rule; `binding_source:"slug-only"`, `minted_at_compaction` when the first carrier is the boundary). **Handoff law (claim REC-106):** the SAME background gesture that mints that clone also appends one line to the PARENT transcript naming the child - `{type:"continued-in", timestamp, sessionId, continuedInSessionId}`, four keys and no `uuid`, so it is not a chain node and its only address is its jsonl line. It is the one SUCCESSION link Claude Code STATES rather than leaving to be inferred - the other stated one, `minted_elsewhere` (§3.14), answers which session minted a record rather than which session followed this one - and `list` reports it verbatim (`continued_in`, text row `handoff`). An in-place `/fork` reaches the same spawner with `keepParent` and writes no such line, so absence never means "not forked"; the append also runs on the failed-spawn path once a rescue row is queued, so presence does not prove the child ran - the harness's own reader opens the named child and demands conversation content in it first. The line has no `role` marker, so `list`'s prefilter carries a second needle for it (the quoted KEY, the needle law's key-only form), and because `list` reads head/tail windows only, a line a later `--resume` of the parent pushed above the tail window reads null - `stats`' line-type census is the whole-file answer. **Lane law (claim REC-107):** every record a BACKGROUND lane writes carries a top-level `sessionKind:"bg"`, and a foreground lane's records carry no such key at all - one stamp, one env var, a three-value allowlist (`bg`|`daemon`|`daemon-worker`), an undefined value in a foreground process and a serializer that drops an undefined property. It is a per-PROCESS fact, so the clone's copied records carry none (all three fork copiers set it to undefined) and ONE transcript can hold a background lane's records above a foreground resume's. `list` reports the value SET from its windows (`session_kind`, an ARRAY, empty for a foreground session) - the same place Claude Code's own summary reads it, off the FIRST record - and the SPAN only under `--lineage`, ONE whole-file `parse::lineage_fields` key walk that also resolves `continued_in` exactly. That walk never parses a payload, so a nested key is not a field and it adds NO malformed count: `list`'s `skipped_lines` keeps its window-census meaning.

### 3.6 Externalised output
Large tool outputs are moved to a sibling `tool-results/<id>.txt`, leaving an inline `<persisted-output>` pointer (absolute path + preview). `search --resolve-persisted` replaces the pointer with full file content BEFORE matching: prefers structured `toolUseResult.persistedOutputPath` (src/model/), falls back to scraping `Full output saved to: <ABS_PATH>`. Read failure is non-fatal - keeps inline + appends `[csift: could not resolve persisted output ...]`.

**PDF PAGES ride the OPPOSITE shape - inline, one element per page, unlabelled** (claims IMG-016/017/018). A `Read` of a `.pdf` carrying a `pages` parameter returns ONE `tool_result` block holding a leading text plus one `{type:"image", media_type:"image/jpeg"}` element per requested page, so `image` has always listed and extracted them; the whole-PDF read (no `pages`) returns a `{type:"document"}` block instead, which `image` does NOT list (no `ext()` row for `application/pdf`) and which only `--raw` reaches. WHICH page an element is lives on no key of it: the harness's mapper writes a successful page bare and labels only a FAILED one, so the offset is the record-level `toolUseResult.firstPage` and the rule is `firstPage + <the element's position among the content elements after the leading text>` - CONTENT POSITIONS, never image ordinals, because a failed page is a text element in the same sequence and would shift every later page by one. `commands::image::record_pdf_pages` is the ONE reader (`src/commands/image/refs.rs`) and both surfaces go through it - `image`'s ` p<N>` column (with the document's basename once per RECORD GROUP - one transcript, one line, one document) and `page`/`pdf_path` JSON, and the `agent.tool.result` unit's `[pdf pages A-B of <basename>]` fact on the keys family - so the two can never disagree. It is gated on the record's own result reading `type == "parts"` WITH a `firstPage` AND on the record carrying exactly one `tool_result` block; everything else (a screenshot, a build before the key existed, the REPL tool's re-emitted inner-`Read` pages, which carry no offset and flatten several documents into one list) reads NULL rather than an assumed page 1. `img_index` is not the page index - it counts images across the whole record.

### 3.7 Subagent on-disk shapes, flat nesting, topology
Three on-disk shapes: (A) built-in Task/Agent under `subagents/agent-<hex>.jsonl`; (B) workflow/OMC under `subagents/workflows/wf_*/agent-<hex>.jsonl`; (C) `journal.jsonl` event log = NOT a transcript (read only for completion status, never listed/searched). **Kind = on-disk location, NOT `agentType`** — `BuiltinTask` vs `Workflow` (subagent.rs) — **with ONE meta-driven exception: `Teammate`.** Canonical agent id = bare `<hex>` (= the record/journal `agentId`) for A/B. The parent uuid is the dir segment above `subagents/`.

**Teammate subagents (the `in_process_teammate` kind, the new Claude Code "teammates"/FleetView feature).** A teammate lands at the BUILT-IN location (`subagents/agent-<id>.jsonl`) so location alone can't classify it — the discriminator is its `meta.json` `taskKind:"in_process_teammate"` (`make_subagent` upgrades `BuiltinTask` -> `Teammate` when it sees that). Distinctive shape, all verified against real data: (1) the canonical id EMBEDS the teammate name — `aVSRepro-68a2a1661c9390c1` = `a<Name>-<16hex>`, NOT a bare hex (so `is_bare_subagent_hex` rejects it; `is_teammate_agent_id`/`is_subagent_id` accept it — see §2); (2) the meta carries `{name, taskKind, teamName, color, model, permissionMode, spawnDepth}` and the `agentType` is OVERLOADED with the teammate NAME (e.g. `"VSRepro"`), NOT the real subagent type; (3) the meta has **NO `toolUseId`** — the usual id-join to the spawning tool_use can't reach it; (4) it is spawned by an `Agent` tool_use whose `input.name` == the teammate name AND `input.subagent_type` == the REAL type (`oh-my-claudecode:qa-tester`); (5) the team lead talks to it via `<teammate-message teammate_id="team-lead">` blocks + the `SendMessage` tool (bidirectional, persistent — unlike a one-shot Task subagent). **Spawn linkage = a NAME-join, not an id-join** (`ParentSpawnIndex::by_name` / `spawn_id_for_name`, joined in `node_for`): the teammate's `meta.name` -> the `Agent` spawn's `input.name` recovers the spawn `tool_use_id`, the TRUE trigger ts, the parent agent, and the real `subagent_type` (which `node_for` then prefers over the meta's overloaded `agentType` ONLY for a teammate). The node also carries `name` + `team_name`. A teammate is a normal topology PARENT — children it spawns nest under it by the usual issuer link. `agents --shape teammate` filters to them. **Control hint (NOT a csift op — csift never drives the harness's own control tools; §4 writers law):** `agents` surfaces a pointer to the CORRECT control tool because a real session burned ~30 min trying to `TaskStop`/`pkill` a runaway teammate — feeding it the name, the `Name@team` form, AND the exact `aName-<hash>` agentId csift prints, all rejected (`No task found with ID`) on the build of the day. The ids were CORRECT; the TOOL was wrong THEN. A teammate is steered/terminated via `SendMessage` addressed BY NAME (`message:{type:"shutdown_request"}` terminates — verified from the live SendMessage schema); from Claude Code 2.1.198 `TaskStop` ALSO resolves a teammate by its name and by the `name@team` identity (the teammate-aware resolver is absent in 2.1.197 and present in 2.1.198, ledger SUB-020), while the `aName-<hex>` transcript id csift prints is never a task id on any build, and `pkill` never helps (in-process, shares the orchestrator PID). Surfaced as a text footer when ≥1 teammate is in scope (`agents.rs` `TEAMMATE_CONTROL_HINT_*` / `any_teammate`) and as each teammate node's JSON `control_hint`. Keep the wording FACT-led (in-process Agent subagent, addressed by name) so it survives a tool rename.

**Workflow RUN `status` is journal-verbatim (open set).** An `agents` `kind:"run"` row's `status` is read straight off the workflow `journal.jsonl` (`str_f("status")`, subagent.rs) — observed values include `completed` and `killed`; it is NOT a csift enum and is distinct from the per-agent csift-computed lifecycle status. Never tighten it into a closed set.

**Flat on disk, nesting reconstructed:** all subagent transcripts sit flat under one session; depth>1 nesting (an agent spawning an agent) is NOT encoded in the path. `build_topology(session_jsonl, with_files)` (src/subagent/) rebuilds the tree: build a GLOBAL spawn index (`build_global_spawn_index`, src/subagent/) by forward-scanning the main transcript PLUS every subagent transcript for `Task`/`Agent`/`Workflow` `tool_use` spawn records, keyed by `spawn_tool_use_id` (at 2.1.258 `Task` is the `Agent` tool's declared ALIAS, not a third registered tool, and `MultiEdit` is no registered tool at all; csift keeps matching both names because older transcripts carry them - a historical arm, never a statement about the current registry; ledger SUB-011, REC-083); each child's `spawn_tool_use_id` (from its head record / meta) joins to its issuer -> `parent_agent_id`; `assign_depths` walks the id->parent chain (cycle-guarded). `SubagentNode` carries `agent_id`, `kind`, `parent_session_id`, `parent_agent_id`, `spawn_tool_use_id`, `spawn_tool`, `workflow_id`, `agent_type`, `description`, `trigger_utc` (true spawn = parent tool_use ts; falls back to child-head `started_utc`), `completed_utc`, `returned_message` (+`_source`), `status`, `files_changed`, `depth`, `children`. **Subagent env quirk:** a workflow `agent()` subagent's `$CLAUDE_CODE_SESSION_ID` holds the PARENT session uuid, not its own - and current CC hands an Agent-tool subagent the PARENT uuid too (the subagent's own id is withheld from its env; a build old enough to hand a built-in Task subagent its own id is the reason never to assume either) - never assume env == self; `@trap` is the env-independent self-resolution.

**THE ID TRIO - EVERY emitted row carries `session_id` + `is_subagent` + `parent_session_id` (REQUIRED, not optional).** A subagent transcript's `session_id` is a bare `<hex>` - it is NOT a re-feedable `@<uuid>` target (`csift verbatim <hex>` fails). So `list`/`search`/`files`/`verbatim`/`recover` rows are a TRIO, not a lone id: `session_id` (display) + `is_subagent` (id-domain discriminator) + `parent_session_id` (the always-re-feedable owning uuid; == `session_id` for a top-level row). Every search HIT (and sibling) object inside an exchange row carries the trio too — bare `.hits[]` flattening keeps real ids (jq cannot fail loud on a missing key, so the data matches the natural access pattern). Emitting only the hex = unusable rows. **Derive all three with `crate::subagent` helpers - NEVER read the path stem yourself** (the stem is `agent-<hex>` -> reading it gives the wrong `agent-`-prefixed id): `session_id_from_path` (src/subagent/ - strips the `agent-` prefix to the bare-hex canonical `agentId`), `is_subagent_path` (src/subagent/ - true iff a `subagents/` path component), `parent_session_id_from_path` (src/subagent/ - the dir component just before `subagents/`; `None` for a top-level file, so callers `.unwrap_or_else(|| session_id.clone())`). To re-feed a subagent match downstream, use `parent_session_id`, never the bare hex. **envelope v2 (SPEC §8.2) is built ONLY through `text::envelope_header`/`envelope_scope_header`/`envelope_summary`** — every `--format json` stream is exactly ONE `{"kind":"header","command":…}` line + kind-tagged rows + ONE `{"kind":"summary"}` line; `kind` belongs to the envelope exclusively (a transcript's shape is `shape`, a boundary's classifier is `cause`), and the jsonl-line key is `line` everywhere. The row TRIO keys themselves are still hand-built in each module's `json!` projector — when adding a spanning JSON surface, emit the envelope through the text.rs builders and replicate all three trio keys, deriving values via the `crate::subagent` helpers above.

### 3.8 whoami detection
CC exports `$CLAUDE_CODE_SESSION_ID` into the Bash tool env == the session's own jsonl basename. Definitive, per-session, version-independent, zero false positives. Use it and nothing else. When absent/empty, **DO NOT GUESS** (concurrent sessions; most-recent-mtime is a false-positive trap) - error with guidance to pass `@<uuid>`. whoami saying "ambiguous" is acceptable. LANE HONESTY: the env names the TOP-LEVEL session in EVERY lane, so the env form's `is_subagent`/`parent_session_id`/`depth` are NULL (unknowable, never fabricated), text carries a `lane unknown` line, stderr names the resolution path; every `@main` resolution prints an unconditional stderr lane note (csift cannot know the caller's lane). (`CODEX_COMPANION_SESSION_ID` mirrors it but is Codex-specific.)

### 3.9 Pending tool_use / "frozen lane" (a permission ESCALATION leaves NO jsonl trace)
A pending tool-approval lives ONLY in CC process memory - it is in NEITHER transcript NOR any per-lane control file. The ONE surface that shows it is the top-level session's registry row (`<claude-home>/sessions/<pid>.json`, binary 2.1.258): `status` is the closed set `busy | shell | idle | waiting`, computed as `waiting` (with an unpersisted `waitingFor` kind) whenever a dialog blocks the session - a queued elicitation, a permission prompt, a plan approval, a sandbox or worker request - else `busy` while loading or delegating, else `idle`, which is relabeled **`shell`** while a background shell task is open (so `shell` = idle + a dev server, NEVER a running shape). The row is transition-written, so a stale `waiting` from a dead session is refuted only by the pid probe (`live::registry::probe_pid`: `ps` + `/proc` on unix; PowerShell `Get-Process` + `tasklist` on Windows, where `procStart` is a FILETIME tick count instead of asctime; a row whose `pidDomain` - `darwin` / `linux` / `win32:<host>` - is not ours is never probed). Per LANE, on disk a blocked lane looks like: the assistant's `tool_use` record (`stop_reason:"tool_use"`, the command in `message.content[].input.command`) is the LAST record, FROZEN, with NO following `tool_result` for its `tool_use_id` (the block carries only `{type,id,name,input,caller}` - no `permission`/`isEscalated`/`requires_approval`); minutes later the `tool_result` is appended once a human answers. So THREE states share ONE on-disk signature: ① escalation-blocked (waiting for a human to approve) ② awaiting-execution (a slow tool) ③ wedged/dead. **Worse, the OLD status logic mis-reported a frozen lane as `completed`** - the tail walk-back for an end-of-turn text found the assistant text that PRECEDES the frozen `tool_use` (the L629->L630 shape) and called it a clean finish. `lifecycle` now detects the frozen lane from the NEWEST meaningful record (an unreturned `tool_use` ⇒ blocked, NEVER `completed` - status forced `Running`) and classifies it: a Bash command the harness would HOIST for a human even under bypass ⇒ **escalation-blocked** (the one state jsonl can POSITIVELY confirm); otherwise **awaiting-execution** (slow OR wedged - jsonl can't tell these apart, so don't pretend; the caller weighs elapsed-since-`pending_since_utc`). Surfaced on `agents` nodes as `pending_tool_use_id`/`pending_tool_name`/`pending_classification`/`pending_since_utc`, plus `pending_reason` and `pending_checker` - WHY the classification reads as it does. On EVERY agent node `completed_utc/_local` (+`duration`) are non-null ONLY when `status==completed` (a frozen lane is never "done" — the JSON now matches the text tree's suppression), and `last_activity_utc/_local` carries the tail newest-record instant regardless of status (== `pending_since_*` when frozen); `--order-by completion` windows on that terminal instant, so frozen lanes window on their freeze instant. Higher-fidelity live signal would need a pre-installed CC `Notification`/`PreToolUse` hook writing a sidecar marker; the heuristic is the zero-setup retroactive path.

**`bash::danger` predicts a decision TWO checkers make, and which one runs is not a property of the command's content (claims BASH-031/035/036).** The Bash permission path parses the command with tree-sitter and DECOMPOSES it (`Dfe` @159477546). A `too-complex` parse - for/if/while/until/case, a subshell, a compound statement, a function definition, a parse error, or over 10,000 bytes - is the ONLY arm that reaches the LEXICAL classifier (`Hno` @162867544 → `$no` @162861871 → `hnt` @162773520); a cleanly parsed removal, a command substitution included, is decided by the STRUCTURED checker `_9` @162770807. Both write the same object through the one factory `sF` @162770607 with the breaker `dangerousRemoval`, which `_lr` @157113966 marks bypass-immune - so BOTH can produce the escalation-blocked state, and the generic too-complex ask and the no-rule-match ask cannot. The classifier runs AFTER deny rules and BEFORE the allow-rule short-circuit, so an allow rule for the exact command does not pre-empt the ask. **THREE GENERATIONS, keyed on the record's `version`:** Gen1 (< 2.1.208) is the branch plus `hnt`; Gen2 (2.1.208 ..= 2.1.260) adds `Bno` @162863005 - the substitution census whose bail above 64 (strictly greater, and only with an rm word) is itself an immune ask, the per-substitution re-run, and a bounded sixteen-iteration `__CMDSUB__` fixpoint feeding `_9`; Gen3 (>= 2.1.261) replaces `hnt` with the rewritten `out`, which walks tokens instead of matching a clause head (so `do rm`, `then rm`, `sudo rm` and `xargs rm` all reach the target test), scans `find -exec`, widens the target forms and recurses into a nested `sh -c` to depth 2. **That recursion reads a REBUILT script, never a slice** (claim BASH-038): the shield `sLo` @166794908 has already replaced every separator inside a quoted run with a private-use stand-in, so `iLo` @166795515 reassembles the script from its quoted and unquoted runs, resolves a double-quoted run's escapes (`\$` becomes a stand-in unless it names a variable) and unmasks with `sut` @166795380 before the walk sees it - a slice arrives as ONE token and the walk never reaches the verb. The allowance the positional target form runs under is DERIVED there from the script's own tail @166796336 (two or more words after it, or an `xargs` carrying neither `-I` nor `-n`, mean `$1` is supplied), and only the double-quoted arm inherits the caller's. So `if true; then sh -c 'rm -rf $D/*'; fi` is an ask at gen3 while the same `sh -c` alone parses cleanly and routes to the structured checker. A missing or unparseable version falls to the generation of the build the ledger is verified at and SAYS so. **The port predicts; it never improves.** `hnt` is purely lexical and never resolves the variable - mirror that. `_9` reads the filesystem (it resolves the operand against the shell cwd, realpaths both, and compares against the working-directory set), so only three of its five arms are decidable from a transcript: critical path, unresolvable target, and cd-plus-relative-glob. The workspace-or-ancestor arm and the unenumerable-glob arm answer `Unresolved`, which renders as `awaiting-execution` with the reason `removal target needs the filesystem state at the time` - never a guess in either direction. Reason tails are the harness's own, verbatim. **What the old port got wrong, and the receipts:** it ran the lexical classifier on every command (attributing structured verdicts to the wrong checker) and it hand-stripped leading shell keywords, which `hnt`'s clause head `_to` @162773345 does not do - a pure false positive on `do rm`/`then rm`/`else rm`. Over 162,118 distinct corpus command-and-version pairs the re-port flips 12 lanes: 8 up to escalation-blocked (5 structured unresolvable-target, 2 census bails, 1 census-reached structured arm) and 4 down to awaiting-execution, every one of the 4 a keyword-attached rm with no plain rm clause anywhere in the command.

**The three harness-side entrances into the background (claim BG-040).** A foreground shell does not only end or freeze: Claude Code moves it into the BACKGROUND on three gestures the model never asked for — the user presses **ctrl+b** on the in-flight call (keybinding `{"ctrl+x ctrl+b":"task:background","ctrl+b":"task:background"}` in the `Task` context; the chord hint appears after 2 s, only the `Bash`/`PowerShell`/`Agent` drivers emit the progress event that reveals it, the whole gesture is off under `backgroundTasksDisabled` / `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS`, and ONE press backgrounds every eligible in-flight task) — the command **hits its timeout** — the command is **moved aside to deliver a queued message**. In all three the launching `tool_use` is already on disk and is NEVER rewritten: no `run_in_background`, nothing added. The only trace is the `tool_result`, and ONE formatter writes it with FOUR arms in this order: `Command was manually backgrounded by user with ID: <id>.` / `Command was moved to the background (ID: <id>) so that a message …` / `Command did not complete within its <N>s timeout and was moved to the background (ID: <id>).` / the ordinary `Command running in background with ID: <id>.`. Every arm then interpolates `Output is being written to: <path>.` (the path is a plain parameter, not an arm), and two tails may follow — but the MANUAL arm drops both, so a **ctrl+b receipt is the only single-sentence ack**. `toolUseResult` carries `backgroundTaskId` plus the entrance's own flag (`backgroundedByUser` / `backgroundedToDeliverMessage` / `timedOutAfterMs`). csift mints a `BgKind::Shell` row from that receipt whenever the `tool_use` id has no row yet (`background_scan::receipt_entrance`), records the door as `entered_by` (`model` | `user` | `timeout` | `deliver-message`; `None` for an agent or a Monitor), and — since the launch line carries no background needle and cannot be reached by the first pass — runs ONE targeted second pass over the same bytes, keyed on the minted tool_use ids, to recover the launch instant, command and description (`fill_receipt_launches`). A row whose launch line is absent keeps the RECEIPT instant under an explicit `launch_note` (`launched-at unknown; receipt at <ts>`), never a fabricated launch time. Such rows are `BgState::Open` like any other, so they reach `idle-background-open`, hold `--until stop` open, and answer the lens. **The MINT GATE is the WHOLE SENTENCE, and that is the mirror image of this very fix**: a transcript can carry a RENDERING of the template rather than a receipt of one (a grep of the harness binary is a real corpus shape, `${e}` and `<id>` both observed), and minting from one would fabricate a task, flip a stopped session to `idle-background-open` and hold `--until stop` open forever. So each arm needs its opening clause AND a task id of the `local_bash` grammar (`b` + 8 base36, claim BG-009) in the id slot AND the arm's own closing clause — the manual arm's output path must name the task's OWN file (its stem equals the id), the timeout arm needs the `<N>s timeout` digits and a `)` right after the id, the deliver arm needs its `) so that a message` clause. **The FIFTH way in is a bounded blind spot**: a plugin's turn abort sets `backgroundedByTurnAbort` but renders the ORDINARY sentence, so text alone cannot separate it from a model-requested launch and csift does not claim it. Completion is the ordinary `<task-notification>` on the usual three carriers — the entrance is invisible there, which is exactly why the receipt is the only discriminator.

**Windows shell reality (extracted from the CC 2.1.228 binary):** on Windows CC ships a SEPARATE first-class tool literally named **`PowerShell`** (tool registry `["Bash","BashOutput","KillShell","PowerShell",…]`; same `input.command` field; "Executes a given PowerShell command with optional timeout…"). Selection: `CLAUDE_CODE_USE_POWERSHELL_TOOL` explicit override → else PowerShell is FORCED ON when no Git-for-Windows bash is found (detection: `CLAUDE_CODE_GIT_BASH_PATH` override → `C:\Program Files\Git\bin\bash.exe` → the `(x86)` sibling) → else a feature gate decides. The Windows `Bash` tool runs the REAL Git-for-Windows (MSYS2) bash — POSIX syntax, so csift's lexical layers stay valid on those records. csift handling: `@trap` matches BOTH shell tools; the bash-LEXICAL layers (`bash::danger` escalation, `bash::mutations` attribution in files/recover) deliberately do NOT run on `PowerShell` records — a pending PowerShell lane classifies `awaiting-execution`, and PS shell-side file mutations are invisible to the heuristic (structured Read/Write/Edit attribution is unaffected); `--count-by tool` reports `PowerShell` as its own key. The `$(…)` fixpoint is in the classifier from 2.1.142 and the port loops over it; 2.1.208 is the floor of the SUBSTITUTION CENSUS, not of the fixpoint (§3.9's generation paragraph, claim BASH-017). **MEASURED LIVE on a Windows 11 ARM64 guest at CC 2.1.258:** with an msys2 `bash.exe` merely on PATH (no Git-for-Windows install) the session offered BOTH tools at once - `Bash` (POSIX paths like `/c/work/todo`) and `PowerShell` - so the "forced when no bash" rule above is the bashless case only; `PowerShell` accepts `run_in_background:true` and returns the SAME result grammar as Bash (`Command running in background with ID: <id>. Output is being written to: <path>`), so `live::background_scan` ingests both by name; task output files live under `%LOCALAPPDATA%\Temp\claude\<encoded-project>\<session>\tasks\<id>.output` (macOS: `/private/tmp/claude-<uid>/<encoded-project>/<session>/tasks/`), and an async agent's `outputFile` there stats 0 bytes on Windows (no symlink to the child transcript); records carry `cwd` as `C:\work\todo` (backslashes) and `gitBranch:"HEAD"`; the Windows line-type census adds `atis-latch`, `bridge-session` and `file-history-delta` beside the usual set.

### 3.10 Elicitation sidecar - the TRANSPARENT merge (AskUserQuestion / ExitPlanMode / MCP)
Three elicitations BLOCK a session on a human yet leave NO usable live trace in the native jsonl: **AskUserQuestion** + **ExitPlanMode** (CC buffers the WHOLE assistant turn until answered - the tool_use is committed to the persisted message array only AFTER the interactive tool resolves; nothing on disk during pending - see §3.4) and **MCP Elicitation** (the inner `elicitation/create` request is an in-memory MCP-client callback, never a transcript record). A CC hook (SKILL.md recipe) records each to an append-only **SIDECAR** `<session-sidecar-dir>/elicitations.jsonl` (the `<uuid>/` dir beside `subagents/`, via `crate::subagent::sidecar_dir_for_session` / `crate::commands::elicitation::sidecar_path`) - NEVER the native transcript (no pollution). Each line carries `csift:"elicitation-marker-v1"` + `csiftPhase`∈{`pending`,`resolved`} + `csiftKind` + `csiftKey`; a `pending` line is shaped like the NATIVE record CC will eventually write (an AUQ/ExitPlanMode `tool_use` on a `type:"assistant"` record; an MCP `type:"system"` record), a `resolved` line is a lightweight `type:"csift-elicitation-resolved"` close marker. **Keyed by the TOP-LEVEL session** (the hook's `session_id` is always the top-level/leader uuid, never a subagent's). The tolerant `csift*` fields parse onto `model::Record` (`is_elicitation_marker()` gates them).

**The merge (NO subcommand - this REPLACED the former `pending` subcommand).** `crate::commands::elicitation::unresolved_pending(session)` groups the sidecar by `csiftKey`; a key with a `pending` and NO `resolved` is UNRESOLVED → its pending record is emitted (exactly the one MISSING from the native transcript - once resolved CC wrote the real record, so the pending is paired off and DROPPED ⇒ no duplicates, the auto-dedup is the whole point). **GHOST-PENDING guard:** CC fires NO PostToolUse for a REJECTED AUQ/ExitPlanMode, so the hook can never write `resolved` on that path — `unresolved_pending` therefore ALSO drops an AUQ/EPM pending whose `csiftKey` appears on a native record as an actual `tool_use` block id / `tool_result` `tool_use_id` (`native_closes`, elicitation.rs — STRUCTURAL check, a key quoted in prose does not count; cost paid only when ≥1 such key is sidecar-unresolved). The native transcript outranks the sidecar; MCP markers (no native form, key may be a server NAME) are exempt and stay sidecar-paired only. **search / show / verbatim / list** read the sidecar when they read a TOP-LEVEL session (subagent transcripts have none — `show` merges too, via `fetch_records`; a merged record is addressable by `--uuid`) and merge the unresolved-pending records as if native: `classify` labels a pending marker **`agent.tool.use`**, so `search` matches all three kinds under `-t agent.tool.use` (an AUQ/ExitPlanMode `tool_use` via the `Block::ToolUse` arm; the MCP `system` record - which has no `tool_use` block - via a guarded §3.10 arm in `collect_record_hits` that matches its top-level `content` string and tags it `agent.tool.use` named by `csiftKind`, so `search MCPPROBE` and `-t agent.tool.use` both find it; that arm is gated to a marker with NO tool_use block so AUQ/ExitPlanMode never double-emit), `verbatim` appends each as its own pending turn unit (ranked most-recent), `list` annotates the row. A merged record has NO physical line - it renders `(elicitation sidecar)` in place of `Lnnnn` (never a fabricated L), carries JSON `source:"elicitation-sidecar"` + null `line`/`line_no`, and every surface that merged ≥1 record prints the EXACT note **`with elicitation sidecar`** (JSON `with_elicitation_sidecar:true`). Malformed sidecar lines are skipped + COUNTED (folded into the surface's `skipped_lines`, never silent) — and so is a SENTINEL-bearing marker whose `csiftPhase` the current schema cannot read (schema skew: a pre-release fossil under old field names `phase`/`kind`/`key` — provably ours, uninterpretable ⇒ counted, never merged, never invisible - the `_` arm of `pair_unresolved`); a missing sidecar dir/file ⇒ no merge (not an error); near-free when nothing is pending (typically 0 records). **files / recover** carry no file ops for these records, so the merge would produce no output there - they deliberately do NOT read the sidecar (a no-op for uniformity). **Targeting rejection:** `resolve_session_files`' `*.jsonl` branch `bail!`s when the target `is_sidecar_path` (basename `elicitations.jsonl` OR a content-sniff: every line is a `csift`-marked record) - the sidecar is read automatically, never searched directly.

### 3.11 The Bash shell cwd + file freshness (verified against CC 2.1.237; SPEC 4.9 is the authority)

**cwd:** CC spawns a FRESH shell per Bash call at a TRACKED cwd and stamps that value on EVERY record as the top-level `cwd` field (subagents included, compaction-stable). The tracker advances via a `&& pwd -P >|`-chained read-back (non-zero exit / backgrounded commands never advance it); a cd into a project SUBDIR persists silently across calls; a reset fires only outside originalCwd + the `/add-dir` set ("Shell cwd was reset to <path>" in the result). **ANCHOR LAW: read `cwd` off the TOOL_USE record, never the result record** (~0.24% async lag). So bash-operand resolution needs NO cross-command state: `bash::mutations::cwd` joins each operand against the record's own cwd through the in-command cd checkpoints, with an explicit `Resolution` class per row (`absolute`/`cwd-joined`/`cd-tracked`/`unresolved` - verbatim, never guessed). Class-marker pseudo-paths (`git:`/`fmt:`/`interp:`/`pkg:`/`extract:`, `is_class_marker`) are flags, never resolved or path-matched.

**Freshness:** Write/Edit gate on mtime vs the last Read with a content-hash absolution for FULL reads only; bash never invalidates readFileState (bash `cat` SEEDS it); rejections always land `is_error:true`. Three stream signals are consumed by `recover`: `toolUseResult.staleReadFileStateHint` on Bash results (CC names the modified files, paths relative to the shell cwd -> resolved via the CARRYING record's cwd -> hard `hint_modified` boundary; `parse_stale_read_hint`, commands/recover/carriers.rs), `toolUseResult.staleRecovered:true` on successful Edits (-> `stale_recovered` annotation), and the `edited_text_file` attachment (16KB budget; an EMPTY snippet = the over-budget degraded form). A change producing NONE of these leaves no transcript trace - absence of signal is never evidence of absence, hence recover's per-window opaque accounting (`ScanResult::opaque`, the M/K/P disclosure + suggested-search). **The fourth signal is the file-history instrument, WIDENED to the recover target.** `files` reports a version-jump-with-no-tool-write only for the settings family, because the tracked set spans every written path and a global timeline row would flood the output (censused: 58,115 such jumps over 1,121 non-settings paths in 31 of 75 top-level transcripts, against 47 in the settings family). `recover` reads ONE path - the `--file` target - where that flood cannot arise, so it reports the same inference there on any path, as an `external_write` boundary reading `external write (inferred, snapshot vN->vM, no tool record since L<line>)`. Both file-history line shapes feed the version sequence now (`file-history-snapshot`'s per-prompt table AND the per-write `file-history-delta`'s `trackingPath` + `backup`, admitted by widening the candidate needle to the shared `file-history-` prefix), so the interval is as tight as the harness's own bookkeeping allows. The motivating producer is the /rewind "Restore code" gesture, which writes NOTHING to the transcript; the next prompt's `edited_text_file` attachment stays the authoritative boundary it already was. **Survival stamping:** recover's replay is FILE ORDER and stays so - a write on a rewound branch landed on disk - while its turn stamps are the LIVE numbering (`ScanResult::stamps`, keyed by jsonl line and re-keyed onto the synthetic coordinate by the cross-transcript merge); an event from an abandoned record prints `turn abandoned (root L<n>)`, its JSON `turn_index` is null beside `survival`/`abandoned_root_line`, and a one-line disclosure names how many the window held.

### 3.12 The settings cascade as csift models it (`src/path/settings.rs` + `settings_slots.rs`)

`send`/`whoami --to` answer "will a delivery hook actually run in that lane?", which means reading the RECEIVER's settings the way Claude Code folds them. FIVE named scopes, later winning: `userSettings < projectSettings < localSettings < flagSettings < policySettings`, with a plugin `settings` base BELOW the user scope. csift reads the file scopes and reports the rest as unobservable rather than guessing.

- **Paths.** user = `<claude-home>/settings.json`; project = `<project-root>/.claude/settings.json`; local = `<project-root>/.claude/settings.local.json`. Claude Code canonicalises the local scope to the GIT ROOT only when a uid-ownership probe passes, and that probe leaves nothing on disk - so csift reads BOTH the project-root and the git-root local file (`git_root_above`, strictly above), folds the git-root one FIRST so the deeper path wins, and reports both (`SCOPE_LOCAL_GIT` / `SCOPE_LOCAL`). Policy is a COMPOSED tier and it is FIRST-WINS, not a merge: `<claude-home>/remote-settings.json` (the server-managed cache) IS the whole tier when present, and the managed files that exist but did not compose are still reported with the reason (`POLICY_FIRST_WINS`); otherwise `<managed-dir>/managed-settings.json` plus every `<managed-dir>/managed-settings.d/*.json` (name not starting with `.`, sorted, which is also the order they win in). `<managed-dir>` = `/Library/Application Support/ClaudeCode` on macOS, `C:\Program Files\ClaudeCode` on Windows, `/etc/claude-code` elsewhere; `merged_in` takes it explicitly so a test can point it at a temp dir.
- **Two block rules differ from "later scope wins".** `env` merges PER KEY (a later scope overrides only the keys it names) and csift keeps the WINNING SCOPE per key (`env_scope`), so a gate verdict can name a file instead of asserting. `hooks` CONCATENATES per event (a project scope ADDS to the user's hooks, it does not replace them). Plugin hooks bypass the merge in Claude Code and are unioned at read time: `<claude-home>/plugins/**/hooks/hooks.json` plus each plugin's own `plugin.json` `hooks` field, folded as one source labelled `plugin:<name>` (bounded walk: depth 6, 512 directories, and hitting the budget is REPORTED, never silent).
- **Three policy switches, evaluated AFTER the fold** (`Fold::finish`): policy `disableAllHooks:true` -> the hook set is EMPTY; policy `allowManagedHooksOnly:true` OR a `disableAllHooks:true` set outside the policy tier -> POLICY hooks only. The switch that fired rides `Merged::policy_switch` and becomes a `send` risk line, because a configured `csift deliver` hook that a switch removed will never run.
- **UNOBSERVABLE, listed as such** (`UNOBSERVABLE`, five entries): `--settings` (a path or inline JSON, materialised per invocation), `--managed-settings` (a spawning parent's in-memory tier), `--setting-sources` / `--restricted` (can empty the user, project and local scopes), the trust-dialog state (before acceptance only user, flag and policy env applies in full), and MDM policy (a managed plist or a registry tree), which csift never reads. Gate verdicts inherit that honesty: teams reads the settings `env` key and says `enabled via settings env (<scope>)` or `no settings-level enable; shell env and CLI flags are not observable -> unknown; use evidence: teams directories N, teammate lanes N`; harbor reads the registry row and says `registry messagingSocketPath present -> on and bound` or `... absent -> unknown` (`GateVerdict::teams`/`harbor`, src/commands/live/channel/caller.rs).
- **API** (all `pub(crate)`): `merged(claude_home, project_root) -> Merged` (+ `merged_in` for tests), `hooks_for_event(&Merged, event)`, `deliver_slots(&Merged, event) -> Vec<u32>` (the `k` of every `csift deliver --slot k` command hook, sorted + deduped, read by a whitespace TOKEN walk that cannot fail - never a constructed regex in a library path), `env_value(&Merged, key) -> Option<(value, scope)>`, `string_value_in(&Merged, key, scopes)` (the winning value restricted to a NARROWER cascade, which is how `commands/plan/slug.rs::plans_dir` keeps its exact prior semantics on top of `merged()`). Tolerant throughout: an unreadable or malformed file becomes a `SourceReport.note`, never an error, so one broken file never hides the rest of the cascade.

### 3.13 The csift-channel sidecar (`src/commands/live/channel/`)

The channel's whole state is FILES CSIFT OWNS, under `<session-sidecar-dir>/csift-channel/` - the `<uuid>/` dir beside `subagents/`, reached through `crate::subagent::sidecar_dir_for_session` (the same anchor the elicitation sidecar uses, §3.10). Both the SENDER's session (its `outbox.jsonl`) and the RECEIVER's session (`messages/`, `inbox/`, `ledger/`, `armed/`) may be written; directories are created on demand.

```
<projects>/<ENCODED>/<session-uuid>/csift-channel/
  messages/<id>.json      the message source, written ONCE at send, never rewritten
  outbox.jsonl            the sender's log: one line per send {id, ts_utc, to_lane, to_session, mode, verdict, channel, official{}}
                          (in the SENDER's own session dir; an EXTERNAL caller has no session, so its line falls back to the receiver's root)
  inbox/<lane>.jsonl      what was enqueued for one receiver lane {id, enqueued_utc, mode, expires_utc} - append-only, nothing is ever removed
  ledger/<lane>.jsonl     what csift DID about them: kind emit|held|expired|ack|redelivered|refused
  armed/<lane>.json       the runtime marker `deliver` rewrites on EVERY event it handles {slots_seen[], last_event, last_ts_utc, hook_session, claude_code_version}
```

- **`<lane>` is the TRANSCRIPT-form id** (a session uuid, a bare `a<16 hex>`, or a teammate `a<Name>-<16 hex>`), validated by `paths::validate_lane_id` against the crate's existing shape predicates (`path::is_uuid` / `path::is_subagent_id`) so no fourth copy of the grammar can drift - and, since every accepted shape draws from `[A-Za-z0-9-]`, a separator, a `..` or a drive letter cannot pass into a path component. A message id is exactly 16 lowercase hex characters (`is_message_id`).
- **Two write shapes, and the choice is not stylistic.** APPEND (`outbox`/`inbox`/`ledger`) opens with `append(true).create(true)` (O_APPEND on unix, FILE_APPEND_DATA on Windows) and writes the line and its newline in ONE `write_all`: several hook processes fire concurrently on one lane, so an append that seeks would interleave halves of two lines. REWRITE (`armed/<lane>.json`) goes through a pid-and-counter-named temp file plus a rename, so a reader racing the writer sees the old marker or the new one, never a half-written one. Reads are tolerant and NEVER silent: `read_jsonl` returns the parsed values PLUS the count of lines that did not parse (a missing file is the common case and yields an empty result, not an error).
- **The ledger is INTENT, the receiver's transcript is FACT.** An `emit` line says csift printed a chunk at a hook point; the proof it landed is a `hook_additional_context` attachment in the lane's OWN transcript carrying the envelope id, which is exactly what `search '<id>' @<lane> -t harness.hook.context` finds and what `msg` joins. Keeping the two apart is the point: a ledger with no matching record is `INTENT-ONLY` (a crash window, or a flush lag), never a delivery.
- **Envelope** (`envelope.rs`): `CHANNEL_MARKER = "[csift-channel v1 "` at the START of the content string is the detector, and it must never collide with the harness's own relay framings (`<teammate-message`, `<cross-session-message`, `<agent-message`, and the three peer relay preambles) - the unit tests pin each one. `CHUNK_BUDGET = 9200` characters per chunk, framing included, under the harness's 10000-character inline additionalContext limit (over that the harness spills the string to disk and leaves a pointer plus a 2000-character preview, which would strip the header from the model's view). Chunking counts and slices CHARACTERS, never bytes, so a multi-byte body is never cut inside a UTF-8 sequence; the part count is a FIXPOINT (a wider `N` in `part=k/N` costs body room and can force another part, so `render` re-packs until the printed count equals the produced count). Everything a reader needs - id, part, mode, sender, relation, receiver - lives in the header LINE inside the content array, because a hook attachment's `hookName`/`hookEvent` are not part of its searchable text.
- **Slot sequencing lives OUTSIDE the sidecar**, under `std::env::temp_dir()`: `csift-deliver-<ppid>-<event>-<lane>/s<k>.done`. Claude Code runs same-event hooks CONCURRENTLY and concatenates their output in completion order, so slot 1 wipes and recreates the directory, slot k > 1 waits for `s<k-1>.done`, every slot marks itself, and the last removes the directory. A timeout NEVER blocks: the slot emits anyway, with a one-line warning that the order may be disturbed. (Same barrier idea as the SKILL's verbatim-slice hook recipe, moved into the binary.)
- **Classify + search.** A delivery attachment classifies `agent.communication.channel` FIRST and `harness.hook.context` second (richest-view law, §3.3a) via `Record::csift_channel_text` (src/model/classify/channel.rs) - the FIRST content string, verbatim, when it opens with the header prefix. It is ONE of THREE attachment leaves a DEFAULT scan reaches (the other two are `user.message` on a `queued_command` payload whose own `origin.kind` reads `human`, and `agent.message.structured` on a `structured_output` payload - each with its own value needle under `Gate::DefaultNeedle`; what the three share is that all are words someone OWNED rather than machinery, which is the whole rule). The channel's candidate needle `csift-channel v1` is a bare VALUE substring with no JSON-escaped character (so it survives a reserialize, which is what the needle law asks), it is `&&`-gated on the label selector alone, and a `-t user` / `-t harness` query short-circuits before the memmem. Render is verbatim, so no synthesized-marker machinery is needed; `Record::csift_channel_direction` reads the header's `from=` for `<sender lane> ⇨ self` and returns nothing for a CONTINUATION chunk, whose header names no sender.

### 3.14 `/clear`: a new session in the same process, with no lineage on disk

`/clear` (aliases `reset` and `new`, optional `[name]`) is ONE generator, and its order is fixed (claim MISC-044): the SessionEnd hook fires first with reason `clear` on the OLD id; the conversation state is emptied; the cost-ledger SAVER runs; the session id is regenerated; the SessionStart hook fires LAST with source `clear` on the NEW id. The saver running BEFORE the swap is the whole basis of the join below - `ddn` reads the id through `Q()` and is gated on the ledger still belonging to the current session, and the regeneration is what re-scopes the ledger, so the `cost-state` line the saver writes carries the OLD id and lands in the OLD file. The new id is a fresh `crypto.randomUUID()`; the transcript file handle is dropped, so the next append opens `<new>.jsonl` in the SAME project directory; the registry row `sessions/<pid>.json` keeps its pid and swaps `sessionId` in place; `$CLAUDE_CODE_SESSION_ID` in that lane reports the NEW id. The `conversation_reset` frame an SDK consumer sees carries a SEPARATELY minted uuid that is NOT the new session id - never join SDK frames on it.

**Nothing links the two ids on disk** (claim MISC-045). The old id survives only as an in-memory `parentId` the rewind UI reads; `parentId` occurs 32 times in the shipped binary, 17 of them the session object's own lifecycle and none of them an append, and a census over one projects root finds `"parentId"`, `"clearedFrom"` and `"previousSessionId"` on ZERO lines of 9,521 transcripts. A FORK does persist lineage (`fork-context-ref.parentSessionId`, 33 lines in that corpus); a clear does not. So `list`'s `cleared_from` is an INFERENCE and the row says so.

**The mint signature and the join** (claims REC-105, QT-044). The NEW transcript opens with the re-stamped session-state lines (`mode`, `bridge-session`, `file-history-snapshot`, ...), then an isMeta `user` record whose content opens `<local-command-caveat>`, then the `/clear` slash wrapper ITSELF (a non-isMeta user record carrying `<command-name>/clear</command-name>`, parented to the caveat record), then a `system`/`local_command` record with empty stdout, then the first real prompt. So the wrapper for the command that ENDED the previous session lands in the file that command CREATED, and the file is self-identifying: `minted_by: "clear"` iff its first non-isMeta user record is that wrapper (`session::clear_mint_verdict`, the ONE copy of the rule - the `list` head scan folds it in, `clear_mint_wrapper` is the entry for a caller holding only a path). Its predecessor is a sibling in the SAME project directory carrying a `cost-state` line whose `startTime + totalDuration` sits within `CLEAR_JOIN_WINDOW_MS` (2000 ms) of the wrapper's timestamp; the nearest LINE wins, and a tie between two different FILES is reported and joined to neither, exactly as `clone_of` refuses its own ambiguity. Measured on a live clear the wrapper sits 164 ms before that close instant (the caveat +3 ms, the `local_command` +2 ms) and the nearest competing checkpoint was 43,968 ms away. **`startTime` is per SESSION and is reset at the clear, so equality of `startTime` across two files is NOT a join - the SUM is**; and neither file mtimes nor the time adjacency of ordinary records ever stand in for it. Cost: the mint probe rides the head scan `list` already runs, and the sibling sweep runs only for a transcript that opens with the wrapper.

**The checkpoint is a CHECKPOINT, not a close marker** (claim QT-043). The `cost-state` literal occurs 7 times in the shipped binary (an interned name, the schema, ONE builder, two policy tables, two readers) and the one writer has FIVE entry points: the registered saver (which the clear, a background handover and an in-app resume all run), a background spawn, a resume or in-app fork, the shutdown-gated teardown, and the exit re-stamp provider - so an EXIT writes TWO lines, with the session-metadata re-append between them. Corpus: 14 of 9,519 transcripts carry one, 44 lines, and 35 of the 44 are not their file's last line. Schema, twelve keys: `type, sessionId, totalCostUSD, totalAPIDuration, totalAPIDurationWithoutRetries, totalToolDuration, totalLinesAdded, totalLinesRemoved, totalDuration, startTime, modelUsage, hasUnknownModelCost` - no `uuid`, no `timestamp`, no `message`, so it is not a chain node and its only address is its LINE. `status`/`wait` report one at the TAIL as an evidence row plus `last_checkpoint`, never as a verdict: the seven-verdict set stays closed and the registry row keeps deciding liveness. The loader admits only `user | assistant | attachment | system` into the message array, so a checkpoint never reaches the model; its values reach a request only through the restored ledger under `--max-budget-usd`.

**What a clear resets in memory, and what it does not.** Reset: the message array, readFileState, nested memory paths, session env vars, the memory selector, EVERY plan slug, file history, the cost counters, the prompt-assembly sections (the system prompt is rebuilt and CLAUDE.md plus the memory files are re-read), skills-sent state, the slash-command caches, invoked skills, the WebFetch / ToolSearch / agent-definition caches, the metadata cache, and the shell cwd (back to originalCwd). NOT reset: MCP connections (only the ide server's cache is cleared) and the permission cache. Background tasks survive unless `isBackgrounded === false` (a foreground task still in its turn is killed), and a surviving agent's output symlink is repointed under `<new>/subagents/`. No PreCompact or PostCompact hook fires and no compaction summary or boundary is written - a clear is not a compaction. **The PLAN corollary** (claim PLAN-021): the regeneration calls `forgetPlanSlug` on the outgoing id, so the plan binding does NOT survive; a clear-minted transcript has no binding at all until plan mode is entered again, and `csift plan` correctly reports none rather than inheriting the predecessor's.

**The task store is keyed by the STARTUP id, not the session id** (claim REG-018). The list id resolves as `CLAUDE_CODE_TASK_LIST_ID` else the team context's `teamName` else an inherited name else the leader team name else the session id, and the directory is `<claude-home>/tasks/<that id>`. With the agent-teams path active the implicit team `session-<first 8 of the startup id>` is minted ONCE at CLI startup, its `config.json` records `createdAt` from that same instant and a `leadSessionId` written once and never updated, and the initializer RENAMES `tasks/<startup uuid>` onto that name. A clear, a resume and a fork never revisit any of it, so `TaskList` still answers after a clear while the DIRECTORY keeps the old name. `live::tasks::tasks_report_with` therefore tries the owner's own id (both forms), then the `cleared_from` chain's ROOT (`session::cleared_from_root`), then the team file whose `createdAt` is within 5 s of the registry row's `startedAt` - taking its `name`, because `leadSessionId` can be a throwaway id after a `--resume` launch. A tier is consulted only when the tier above found nothing, a directory matching no candidate is NEVER read, and every store that answered prints with the candidate that found it. With the teams gate off the store is `tasks/<session-uuid>` and a clear strands it outright.

`--continue` reopens the newest transcript by mtime, so it lands on the POST-clear session; the old one stays resumable by explicit id.

**The four lineage links a `list` row can carry, and only two are stated.** A clone is INFERRED from a boundary shape (§3.5) and a clear from a checkpoint sum (this section); a BACKGROUND HANDOFF is the exception - the harness appends a `continued-in` line to the parent naming the child, so `continued_in` is read, never derived. The FOURTH is the only one a RECORD states about itself: a line whose snake-case `session_id` names a different session than its own `sessionId` was minted elsewhere and hydrated into this file through the Remote Control bridge, and `list` reports those ids as `minted_elsewhere` (claim REC-151) - a SET with its count, because one transcript can hold runs from several, and answered by the `--lineage` whole-file pass alone, the same rule `session_kind`'s SPAN follows. It answers a different question from the other three: which session minted a RECORD, not which session followed this one. The four are independent and a single row can carry all of them: the background gesture that mints the clone is also what writes the handoff line and what stamps the child's records `sessionKind:"bg"`, and a clear can have happened before any of it.

---

## 4. Invariants + design decisions (with rationale)

- **The writers law (amending the old "csift only reads").** There are EXACTLY THREE writers - `send`, `deliver` and `ack` - and they write EXACTLY ONE place: the csift-owned sidecar directory `<session-sidecar-dir>/csift-channel/` (§3.13). NEVER a transcript, NEVER the team mailbox, NEVER the cross-session messaging socket, NEVER the session registry, NEVER any settings file, NEVER the harness task list, NEVER a plan or a project file. csift does not install its own hook either: `deliver --recipe` PRINTS the settings block and the user pastes it. Every other subcommand stays read-only, and an official transport is DELEGATED by printing the exact call, never performed - csift is a binary and those transports are model tools. Rationale: the harness owns mutable state that is unversioned and written mid-flight, so a tool that reads it for forensics must not become a second writer of it; a directory csift alone creates and appends to has no such contract with anyone. When adding a surface that needs to persist something, it goes under `csift-channel/` through `live::channel::paths` (the atomic append / temp-plus-rename primitives) or it does not get written.
- **The sensitive-headers law: an HTTP RESPONSE HEADER SET reaches NO rendered or JSON output path, ever - `--raw` is the one documented exception.** A `type:"system"` `api_error` record carries the failed request's whole header set inside its `error` object, and the measured set holds `set-cookie`, `anthropic-organization-id`, `request-id`, `cf-ray` and `traceresponse` among 18 names (claim CLS-045: 5 of 29 such records over one projects root carry the object, and they are exactly the 5 whose top-level key set lacks `slug`). A family that renders a known shape names the FIELDS that say what failed and drops the rest; the UNMODELED unit's key=value excerpt, which is the one rendered path that walks a line's own values without knowing what they are, elides every `headers` member at every depth by exact name and prints `<elided>` in its place - stated, not dropped, so a reader learns the set is there and that `--raw` is the way to it. Rationale: a forensic reader pastes csift output into a report, a chat or another model's context, and a rendered transcript view is not a place to put a session cookie. A future record shape carrying another credential-bearing object takes the same treatment.
- **No `unwrap`/`expect` in library/hot paths.** Propagate via `anyhow::Result` + `?`. Tests may `unwrap`. Only `main.rs` turns an error into an exit code. Rationale: a panic in a transcript-recovery tool destroys the recovery; surface the full chain instead.
- **No silent truncation / no silent failure.** Any cap (`--max-count`) MUST report the drop count; **THE MALFORMED-LINE RULE:** a skipped malformed line is counted, never hidden — this includes NON-candidate lines: every byte-prefilter rejection path runs the O(1) `parse::line_shape_malformed` shape check (non-blank but not `{…}`-framed ⇒ counted; catches free-text garbage + crash-truncation at zero §7 cost). Residue: a `{…}`-framed invalid INTERIOR is only counted on a parse candidate - EXCEPT on `stats`: its non-candidate type probe (`line_type_probe`, stats.rs) fully validates every line for the line-type census, so the named census authority's count is exact. **ENCODING is NOT malformity.** A line whose BYTES are not valid UTF-8 but whose JSON is otherwise sound is decoded LOSSILY and counted as a RECORD, never as malformed: `parse::parse_line` retries once on the `String::from_utf8_lossy` form, so the undecodable byte renders as U+FFFD and the text on that line stays searchable, addressable and countable. The retry is GATED on `std::str::from_utf8(payload).is_err()`, so a line that is merely bad JSON takes exactly one parse, one error and one malformed count as it always did, and `--raw` still returns the line's original bytes. WITHOUT the retry a bad byte in a field `Record` models fails the whole record: serde skips such a byte only while it sits in a field the model does not read, and the `#[serde(flatten)]` exposed-key map buffers every unknown key, so nothing is skipped any more. Measured on a four-line fixture with two such lines, the retry is the difference between 2 exchanges + `(2 malformed line(s) skipped)` and 4 exchanges + none, between a `stats` line-type census reading `user×2` and one reading `user×4`, and between a `show --line` bail and the rendered text. The shape is absent from the corpus (0 of 1,811,997 lines over 9,828 files of one projects root are invalid UTF-8), so the change is in the contract rather than in any real reading. **SCOPE, THE WINDOW-CENSUS RULE: whole-file census vs window census.** The count is a WHOLE-FILE census only on the full-scan commands (`search`/`stats`/`show`/`files`/`recover`/`verbatim`/`image`); the head/tail readers — `list` rows and `agents` lifecycle — census the LINES THEY READ: `parse::head_records*` returns `(skipped, consumed_end)` and the paired tail scan FLOORS at that offset, so the two windows are DISJOINT and every malformed line in them is booked exactly ONCE (without the floor an all-garbage file reports exactly 2×: both scans, finding no anchor, walk the whole file and double-book every line. The tail still walks below the floor for missing anchors; it must not re-count there). A mid-file tear is OUTSIDE those windows BY DESIGN (§7 — full coverage measured ~4× the unscoped `list` runtime), so `list`/`agents` `skipped_lines: 0` is NOT a whole-file verdict: the text note scope-qualifies (`among the head/tail lines read — full census: csift stats`) and `stats` is the named census authority. When pairing a head and a tail scan over ONE file, ALWAYS pass the head's `consumed_end` as the tail `floor` — summing two unfloored scans reopens that double-book. When adding a scan with a byte prefilter, route the non-candidate arm through `parse::non_candidate_verdict` — a bare `LineVerdict::Ignore` there reopens the malformed-line hole. `resolve_session_files` bails when a pinned id matches nothing rather than returning empty. Rationale: a quietly-shortened search result is a correctness bug for an LLM consumer that trusts completeness. **Context-safety output caps.** `list` carries a DEFAULT cap of `session::DEFAULT_LIST_CAP = 50` rows when listing ALL projects (no target / no `--sessions-from`) — the flood guard against a ~69k-line all-projects dump; a scoped query is UNCAPPED, and `--max-count N` overrides. `stats` gained an opt-in `--max-count N` (default unlimited). Both keep the MOST-RECENTLY-active rows (`session::last_activity`) and report the drop (text footer + JSON summary `dropped_by_cap`), never silent — and `list`'s scope banner / JSON header `sessions_in_scope` are computed PRE-cap (the row flood-guard never shrinks the scope numbers; only the rows are capped). `show` carries a DEFAULT cap of `show::DEFAULT_SHOW_CAP = 200` record units (open ranges like `--turn ..` address a whole transcript) — keep-FIRST; the drop reports the EXACT continuation command (`+N more record unit(s) beyond the 200-unit cap · continue: csift show @<id> --line A..B  (or --max-count 0 = uncapped)`; JSON `dropped_by_cap`/`refetch_remainder`). **`--max-count 0` = UNCAPPED uniformly** on `list`/`stats`/`search`/`show` (read as a literal zero-cap it would be an absurd ask). `files` (a compact `--by summary` rollup) and `agents` (a tree scoped to its target) were left scope-bounded, so neither took a default cap.
- **Tolerant parsing.** Real jsonl carries far more fields than documented and some records lack `timestamp`. Deserialize only what's used, ignore the rest, never crash on a new field/block type (the `#[serde(other)] Unknown` arm). EXTEND the model tolerantly when CC evolves; never tighten it into a crash.
- **JSON output = the `serde_json::json!` macro ONLY, never `#[derive(Serialize)]`.** There is ZERO `Serialize` derive in `src/` (verify: `grep -rn "derive(Serialize" src/` is empty). A fresh model's instinct - "add `#[derive(Serialize)]` to the row struct" - is WRONG and breaks the house style. Every JSON line is hand-built with `json!({...})` in a per-module `*_json` projector (`hit_json`/`preview_json`/`node_json`/...) then `serde_json::to_string(&value)?` + `println!`. **One JSON object per line** (JSONL, not a pretty array). Render structs stay plain (`#[derive(Debug, Clone)]`); the projector maps struct -> `json!` explicitly, so the wire shape is decoupled from field layout. **Envelope (search + verbatim):** the FIRST line is ALWAYS the shared `text::envelope_scope_header(command, top, sub)` record `{kind:"header", command, sessions_in_scope, top_level_sessions, subagent_sessions}` (text.rs) — emitted UNCONDITIONALLY, NOT gated on `sub > 0`; `list`/`files`/`recover` emit it too. `search` ALSO prints a TRAILING summary object `{matched, sessions, transcript_ids, transcript_ids_truncated, dropped_by_cap, skipped_lines, with_elicitation_sidecar, excerpts_truncated}` plus the survival set `{superseded_drafts, abandoned_records, rewound_turns, replay_copies, boundary_cut_line, leaf_source}` as the last line (search.rs `render_json`); each HIT carries `survival`/`abandoned_root_line`/`replay_copy_of` beside `labels[]`, never inside it (SPEC §5.6 - the verdict is a fact on the row, not a leaf name). `transcript_ids` (+ `transcript_ids_truncated`, first-100 cap) = the distinct MATCHING-TRANSCRIPT ids — DELIBERATELY named apart from `-l`, which emits the OWNING-session `parent_session_id`s; the two answer different "which sessions?" questions. On a ZERO-match result the summary gains `definitive_absence:true` + `active_filters` + `excluded_by_label` (§ empty-result self-diagnosis, below). `excerpts_truncated` = ≥1 emitted excerpt was clipped to the cap (machine echo of the text reader-caution; always false under `--no-truncate` — and under `show`'s `--line`/`--uuid`, which lift the cap to `usize::MAX` so `Hit::truncated` is never set. `show --turn` does NOT lift it: it is a WINDOW over the turn numbering, not an address, so it renders through the ordinary 400-char budget and its `text` carries the explicit `… (+N chars)` marker like any other excerpt). Reproduce BOTH when adding a JSON surface that spans/caps. **`body` (`Hit.body`)** rides every `search` hit and sibling and every `show` record row beside `excerpt`/`text`: the SAME rendered section text `match_excerpt` windows into, but WITHOUT its whitespace collapse, so a table or a paragraph break survives into JSON. One rule sets it, keyed on the BUDGET and not on a flag so the two fields can never disagree (`search::uncapped_body`, called at every `Hit` construction site): non-null iff `excerpt_max == usize::MAX`, i.e. exactly where the cap was lifted (`search --no-truncate`, `show --line`/`--uuid`), else null - the default stream stays one fragment per hit rather than a fragment plus a full copy of every matched record. It is the RENDERED form, so a source the model layer already flattened (`user.message` and the other `reconstructed_user_text` cases go through `flatten_content_text` -> `normalize_line`) carries no newline in `body` either; `--raw` stays the path to the bytes. When adding a Hit-bearing JSON surface, project `body` beside the excerpt. **Row-kind set** (`kind` belongs to the envelope alone): `search --count-by <axis>` emits `census` rows `{axis, key, records}` + a `{axis, matched_records, distinct_keys, excluded_records, dropped_by_cap, skipped_lines}` summary; **`agents --format json` is FLAT** — `{kind:"session"}` count row → `{kind:"run"}` rows → each subagent a `{kind:"agent"}` row in tree PRE-ORDER (nesting is TEXT-only, rebuilt from `parent_agent_id`/`depth`; NO nested `workflow_runs`/`children` row field, and `--agent` is no bare-node exception); `agents` resolves TopLevelOnly and walks the sidecars itself, so it runs the merge over its OWN node paths (`dedup_copies` again, dropping the nodes whose transcript it removed and deduping the workflow manifests a copied directory duplicated) - the resolver's union merge never sees a transcript `agents` discovered. Every agent row carries `path`, its own transcript: an agent whose own copies DIVERGE yields two lanes identical in every other field, the id included (identical and prefix copies merge to one row like any transcript), and the text tree prints a `transcript` line for exactly the ids that occur twice. The session COUNT row equals the rows it counts and the text tree prints the same set in the same order, because both take ONE walk (`agents::preorder_depth`) keyed on a node's POSITION in the set rather than on its `agent_id` - two divergent copies of one session keep both sidecars, whose agent transcripts share an id by construction, and an id-keyed walk emitted one of the pair while the count row counted both; a node's transcript shape is `shape` (not `kind`). `files` summary gained `sessions` (distinct owning sessions; deliberately NO `dropped_by_cap` — files has no cap); `show` summary is `{records, dropped_by_cap, refetch_remainder, non_record_lines, skipped_lines, with_elicitation_sidecar}`. **`pairing` is a closed machine enum** `paired`|`pending`|`orphan`|`null`. **Timestamp PAIR LAW:** every machine instant is a `_utc`+`_local` PAIR — a record's OWN instant is `ts_utc`+`ts_local`; a NAMED instant is `<name>_utc`+`<name>_local` (`first_*`/`last_*`/`trigger_*`/`started_*`/`completed_*`/`last_activity_*`/`pending_since_*`; on an agents node `completed_*` is STATUS-GATED — non-null only when `status==completed` — and `last_activity_*` carries the tail instant on every timestamped lane). The text form derives from these but drops the UTC copy (§4 timez helpers).
- **Shared `text::` + `timez::` helpers are REQUIRED, not optional.** Do NOT hand-roll truncation, the scope banner, the malformed-line note, or a range parser - a divergent fourth copy (`agents::one_line` dropping the elision count) is exactly the silent-truncation contract bug this consolidation killed. REUSE:

  | Need | Helper | Notes |
  | --- | --- | --- |
  | excerpt + explicit `... (+N chars)` | `text::truncate_excerpt(s, max)` | CHAR-counted (codepoint-safe); cap = **200** for `list`/`agents` scannable previews, **400** for `search`/`recover` context excerpts |
  | flatten multiline + truncate | `text::collapse_and_truncate(s, max)` | the `agents` returned-message preview |
  | malformed-line note fragment | `text::malformed_note(n)` | bare `N malformed line(s) skipped`; caller frames it |
  | scope span wording | `text::scope_span_fragment(top, sub)` | the one phrasing |
  | text scope banner (suppress when `sub==0`) | `text::write_scope_banner(w, top, sub)` on a surface that holds an `Out`; `text::emit_scope_banner(top, sub)` on one that prints every row with `println!` | ONE text builder, two sinks. `search`/`files`/`verbatim`/`agents`/`image` take the writer form (the banner is their first stdout byte, so it must fail the way their rows fail); `list`/`stats`/`recover` the print form. `path::write_duplicate_banner` / `emit_duplicate_banner` pair the same way |
  | JSON scope header line (ALWAYS the envelope's line 1) | `text::envelope_scope_header(command, top, sub)` | `{kind:"header", command, sessions_in_scope, top_level_sessions, subagent_sessions}` (text.rs); emitted unconditionally |
  | range spec: PARSE, then RESOLVE against a domain length | `text::parse_range_spec(s, label, one_based) -> RangeSpec` then `RangeSpec::resolve(len, one_based) -> (usize, usize)` | THE one range grammar (`--line` · `--turn` on EVERY command that windows on turns — `search`/`show`/`stats`/`files`/`recover`/`verbatim`/`image` · `--file-lines`): `N` (single) · `A..B` (closed) · `N..` (to end) · `..N` (from start) · `..` (all) · negative `-k` = k-th from the END (`-3..` = last 3, `-1` = the last). Types: `text::Endpoint` (At / FromEnd / Open) + `text::RangeSpec {start, end}`. A statically-detectable reversal (`9..3`, both explicit) errors at PARSE; a len-dependent reversal resolves to an EMPTY range. Each consumer resolves the spec against its OWN per-file domain length (turn count / line count) — `search`/`stats`/`files`/`recover`/`verbatim`/`image` resolve `--turn` per-transcript; `show --line` / recover `--file-lines` against the file's line count. The dash form `A-B` still hard-errors with the correct spelling. The range-valued clap args carry `allow_hyphen_values = true` so the space form `--turn -3..` parses (not mistaken for a flag). |
  | timestamp — the ONE canonical local text form `YYYY-MM-DD HH:MM:SS <TZAB>(UTC±offset)` | `timez::format_timestamp(raw)` (seconds) | system-local via jiff, infallible; NO raw-UTC copy (that lives only in JSON `ts_utc`) — shares `tz_marker` with the ms variant |
  | compact local instant `…HH:MM:SS.mmm <TZAB>(UTC±offset)` (no UTC copy) | `timez::format_local_compact(raw)` (ms) | for token-lean text; same renderer + `tz_marker` as `format_timestamp` |
  | JSON `ts_local` ISO+offset | `timez::local_iso(raw)` | `None` if absent/unparseable |
  | the detected local zone | `timez::local_tz()` | `TimeZone::system()` |

- **Performance is a contract** (SPEC section 7). `list`/`search` stay fast on 200MB+ files via: **mmap** (`parse::mmap_bytes`) + **`memchr` SIMD newline scan** (`scan_lines_bytes`, `memchr_iter`) + a cheap **byte/regex prefilter** with full `serde_json::from_slice` ONLY on candidate lines (`parse_candidates_parallel`) + **tail reads that SEEK from EOF backward** newest-first (`tail_records`, never parse the whole file to find the last user/agent msg; `list` additionally byte-prefilters head/tail lines via `head_records_prefiltered`/`tail_records_prefiltered` so megabyte metadata lines are never parsed) + **`rayon`** fan-out across files AND within one giant file (`scan_lines_parallel` — `search`/`verbatim`/`files`/`recover` all use it) + the **§7f whole-file gate** (see the landmine below) + **`mimalloc`** as the global allocator (macOS libmalloc contends across rayon workers) + heavy raw fields kept UNPARSED (`Record.tool_use_result`/`attachment` are `Box<RawValue>`; the hot paths read small fields via `Record::tur_probe`, deep consumers parse on demand via `tool_use_result_value`/`attachment_value` — never revert these to eager `Value`) + **`subagent::JournalCache`** (each workflow `journal.jsonl` read+parsed ONCE per topology build, not once per agent). Don't regress these: don't `BufReader`-copy a whole file, don't full-parse every line, don't lose the prefilter. **The RENDER is contract too (SPEC §7k):** the row-dumping surfaces — `search` (text, JSON and `--raw`), both of `show`'s, `files`, `agents`, `image` and both of `verbatim`'s — write through ONE `crate::text::Out` (a `BufWriter` over a held `StdoutLock`) instead of `println!`, which is a `write(2)` plus a backward newline scan PER LINE - 297,976 of them on the landmark's `search 'the'`, measured as 0.45 s of the 0.59 s its whole render phase cost; and `search` renders its per-EXCHANGE rows in bounded parallel chunks through `crate::text::write_ordered`, writing the chunk buffers in INDEX ORDER so the byte stream is the one a serial loop produced. Don't regress either: don't `println!` in a row loop, don't drop the ordering, and don't widen the look-ahead (8 rows per chunk, 16 chunks per wave - the whole memory bound the pipeline adds). TWO obligations come with the writer and both are correctness. ① A surface holding an `Out` routes EVERY later stdout byte through it and FLUSHES before any stderr note the contract orders after a byte stream — and a stray `println!` fails DIFFERENTLY depending on the thread, both ways bad and only one loud: on the SAME thread the lock is reentrant, so the print does not block and simply lands AHEAD of everything the buffer holds (measured: a stray line after `write_ordered` comes out FIRST, no error, exit 0), while on a WORKER thread inside the `write_ordered` closure the lock is held by ANOTHER thread, the worker parks on stdout, the main thread parks on that worker's buffer and the PROCESS HANGS with nothing written (measured: 800 exchanges hang indefinitely, 3 finish — rayon runs a small item set inline, so the hang appears only at scale). ② The parallel render's closure must be pure with respect to the run - the one cross-row decision the text render makes (which row carries the single image-extraction hint) is taken by `first_image_row` before any worker starts. ① is MECHANICAL, not a convention: every module that builds or writes through an `Out` carries `#![deny(clippy::print_stdout, clippy::print_stderr)]`, and each legitimate site (the `-c`/`-l`/`--count-by` terminal printers, the pre-scan advisories, the stderr notes that follow a flush) carries an `#[allow]` stating why it prints outside a buffer — the hook runs clippy with `-D warnings`, so a stray print is a failed commit, not a silent hazard. EVERY byte of a buffered surface goes through its writer, its SCOPE and DUPLICATE banners included: they were `println!` sites one line above the `Out`, which kept them in order (a `LineWriter` flushes at each newline) but gave them the `println!` ABORT on a write that cannot land, so one query answered a closed pipe two ways depending on how wide its scope was. The two helpers now have two sinks over one text builder (`text::write_scope_banner` / `path::write_duplicate_banner` for a surface holding an `Out`, `emit_*` for one that prints every row). **The RESIDUALS the lint does not cover, because it fires at the PRINT site** - a call that prints from ANOTHER module. NO TOTAL of them is stated here: this sentence has carried one twice and been wrong twice, and the honest source is the sweep itself, which resolves every call in a denying module to its definition and asks whether that file denies. The edges it returns are named below with the guard on each, and `src/text/out.rs` carries the same list at its module doc; re-run the sweep rather than trust either list to be complete. A CALL to `text::emit_scope_banner` / `path::emit_duplicate_banner` from inside a buffered module compiles clean and passes clippy, since the `println!` itself sits in `text.rs` / `path/dedup.rs`, neither of which denies (both legitimately print) - and it lands ahead of the buffer and aborts on a closed pipe exactly as before. Those two sinks have THREE callers, all of them surfaces that print every row (`commands/session/render.rs` names the scope sink; `commands/stats/render.rs` and `commands/recover/render.rs` name both); a new BUFFERED surface takes the writer form, and what keeps this one shut is a tree sweep in `tests/cli/contracts/write_errors.rs` that refuses a module carrying the deny and naming EITHER `emit_*` sink - ONE such call is enough to fail it. Asking PER SINK is a separate assertion in the same test, the positive control over those three print-form callers: it names each sink each of them must still be seen calling, so half a migration of one of THOSE fails too. `commands/image/run.rs` carries the deny and calls `extract` in `commands/image/convert.rs`, a file that carries none and holds six prints: what keeps it shut is EXCLUSIVITY rather than the lint, `run_image` dispatching `extract` and the two listing renders in either/or branches where only the renders build an `Out`, so the `--out` path has no buffer for a print to land ahead of. `commands/show/run.rs` carries the deny and returns through `run_branch_points` in `commands/show/branch.rs`, a file that carries none and holds fourteen `println!` across its two renderers: what keeps it shut is the ORDER of one early return, the `--branch-points` arm leaving `run_show` above that file's only `Out`, so `show --branch-points` prints row by row like `list` and `stats` with no buffer for its lines to precede. Each of those two callees says so at its own module doc and names the condition that would end it; the sweep is not widened to reach either - its sink set is the two banner names, and adding `extract` would flag `run.rs` for a call that is correct. **The ONE added cost (SPEC §7g):** every full-scan command byte-walks the lines its prefilter drops and lifts the structural spine row off each (§3.3), which is what the survival axis is built on. It cannot stop early (claim REC-104), so it jumps string interiors with a SIMD `memchr2`; `stats` pays nothing extra because `parse::line_type_and_spine` folds the row out of the parse its census already runs. The clock cost was never that walk - it is the SERIAL carry of the rows plus the chain build - so the split takes the spine rows out of the record vector (`scan_lines_parallel_split`) and narrowed the row to 72 bytes from a `Record`'s 1,184 at the time (1,384 at this tip; `SpineRow`). Medians of 21 reps on a 397 MiB transcript, against a build carrying the rows in the record vector: `image` -29.2%, `verbatim` -14.6%, `show --branch-points` -14.2%, `files` -8.1%, a heavy search -6.8%, `stats` -6.7%, `recover` -6.1%, with a same-binary control band of at most 1.1%. What the axis itself costs, against a build without it: `image` 1.233x (was 1.74-1.77x), `show --branch-points` 1.300x (1.54x), `verbatim` 1.153x (1.35x), `recover` 1.125x (1.19x), a heavy search 1.089x (1.17x), `stats` 1.073x (1.17x), `files` 0.776x (0.84x - one chain build replaced four). That residue is the parallel byte walk and one chain build over the whole DAG, both of which the axis needs. **Prefilter silent-drop landmine (where "no silent truncation" actually bites).** `search`'s literal prefilter (`required_literal` + the `Prefilter` enum, search.rs) is the ONE place a naive optimisation silently drops matches. It scans RAW JSON line bytes, where string content is JSON-encoded (`"` -> `\"`, `\` -> `\\`, every control char `< 0x20` + DEL -> `\uXXXX`/`\n`/...), AND several render paths rewrite whitespace before matching (`normalize_line` collapses runs; multi-part texts join with `' '`/`'\n'` seams). So prefilter needles are derived NECESSITY-ONLY by `required_needles` (matcher.rs): the whole-pattern plain literal stays the fast path, and a pattern WITH metachars goes through the `hir_needles` walk — a literal contributes its longest SAFE run (whitespace + JSON-escaped chars SPLIT runs; a sub-run of a required literal is itself required), a concat contributes its strongest part, an alternation gates ONLY when EVERY branch contributes (union, <=8 needles), min-0 repetitions/classes/anchors contribute nothing. The safety predicate applies PER NEEDLE: no JSON-escaped char (`json_escapes_in_string`: `"` / `\` / control / DEL - the backslash is there because a raw line stores it doubled, so a needle carrying one occurs nowhere in the bytes the prefilter scans; it splits a run exactly as a quote does), no whitespace (a rendered `hello world` can be raw `hello\nworld`), >=3 bytes. An eligible needle set gets a byte prefilter in BOTH case modes: case-sensitive -> per-needle `memmem` (`Prefilter::Literal`/`AnyLiteral`); smart-case-insensitive -> ONE `(?i)`-escaped-literal-alternation bytes regex (`Prefilter::CaselessLiteral`, Teddy-class caseless scan). Non-ASCII (`>= 0x80`) survives verbatim as UTF-8, so it stays prefilter-eligible. **Synthesized-text escape hatch (`Matcher::synth`, REQUIRED for correctness):** a small closed set of render paths fabricates matchable text that is NOT a verbatim substring of the raw line (automation labels' kind slug/status, the AUQ scaffold, a rejection's `[plan: ...]` pointer resolved from a DIFFERENT record, the compact-boundary `trigger=...` excerpt, `--resolve-persisted` external content); each is detectable by a raw marker its carrier ALWAYS bears, checked via per-needle `memmem` finders (NOT Aho-Corasick — the quote-leading `"answers"` needle degenerates AC's start-byte prefilter on JSON). Markers split two tiers (`Matcher::synth_verifiable`/`synth_conservative`): VERIFIABLE markers (`<task-notification>`, `"answers"`, the AUQ answer markers, `-t`-gated `compact_boundary`) re-render JUST the marker lines through the SHARED engines (`record_text_sections`/`auq_exchange`/`record_raw_text`) and regex-check the synthesized texts — so a marker-heavy main session still gates when none of them can match; CONSERVATIVE markers (`To tell you how to proceed` — its `[plan: …]` pointer resolves through ANOTHER record — and the flag-gated persisted pointers — external file content) force the full scan. (No `AskUserQuestion` needle: the question-side `tool_use`'s matchable text is the verbatim `name` + re-serialized `input`, and the name bytes sit in the raw line — a bare needle would disable the gate for every file whose injected context merely mentions the tool.) **Whole-file gate (§7f):** when a PARALLEL per-line pre-scan (same rayon chunking as the full scan; a relaxed AtomicBool short-circuits it on the first may-match line) proves a whole file can't match (and no `--line`/`--uuid` addressing), `search` skips building records for it entirely — the gated file's candidate lines were each syntax-validated (`parse::validate_line_syntax`, no Record build) so the malformed-line count stays exact (a TESTED contract). Extraction is NECESSITY-ONLY and safety-gated per needle — NEVER emit a needle a match could lack (an alternation with one needle-less branch gets NO gate), NEVER skip the per-needle safety predicate, NEVER prefilter decoded content, and NEVER add a synthesized render path without adding its raw marker to `synth_marker_finders` (`matcher::synth`, src/commands/search/matcher/synth.rs - the ONE shared file every taxonomy family edits, which is why it has a file of its own) — when no safe needle set exists, fall back to running the regex on the raw bytes (still pre-JSON, just no cheap short-circuit).
- **Live files are read with plain reads, never a map.** A memory map of a file another process TRUNCATES faults with SIGBUS on the first page touched past the new end, and no `Result` catches it. Claude Code rewrites a transcript in place (a rewind tombstone truncates and rewrites the tail; an armed local GC rewrites on compaction - ledger MISC-020), so every reader that runs REPEATEDLY against a live transcript - `wait`'s per-poll increment, the `status`/`wait` tail window - goes through `parse::read_range` / `parse::read_tail` (positional reads: a shrink returns fewer bytes), and `wait` discloses a shrink and moves its baseline. The one-shot full scans keep the mmap of the performance contract below; never add a polling or long-lived mmap reader.
- **`unsafe_code = "deny"` crate-wide, ONE exception.** `deny` (not `forbid`) is deliberate: it bans every `unsafe` block EXCEPT the single audited mmap site in `src/parse/` (`#[allow(unsafe_code)] unsafe { Mmap::map(&file) }`), which is irreducibly unsafe and SPEC-mandated. `forbid` cannot be locally overridden so it would refuse to compile mmap. Any OTHER `unsafe` without an explicit allow is a hard error. Do NOT add a second allow.
- **No crate-level `#![allow(dead_code)]`.** Every allow is TARGETED and carries its reason at the attribute; a crate-wide one would mask real dead code. THREE are named HERE, because each is a surface that exists before its readers do and a reader hunting dead code would otherwise take it for rot: `model::Record`/`Block` fields deserialized for tolerance but not yet read; the DOCUMENTATION surface of `model::inventory` (the `Entry` columns no gate reads plus `disk`/`value`/`UNSHAPED`), which exists so the generated SPEC/SKILL tables and the drift tests have one source; and `search::Basis::Inferred`, the one arm of the `for` edge's honesty column no family carries yet - every edge on the tree is read off a key or off the parent link, and the word stays so a family that computes a join has one waiting rather than a reason to call its join a key. It is per-VARIANT, never on the enum, so it can never widen over `Key` and `Position`. The skeleton's other three - `search::Section`, `record_text::for_edge` and the six FAMILY ARMS of `search::HitFact` - are gone: each family's own facts fill them, and a seam allow outlives its reason the moment its first user lands. The rest are the ordinary kinds and say so where they sit - a tolerant model field, an accessor only the tables and drift tests reach, a helper no surface has wired yet, one platform-conditional. `cli::show_keys` carries NO allow: all seven `Family` variants are constructed on the render path.
- **Exit codes** (main.rs): `Ok(()) -> ExitCode::SUCCESS` (0); any `Err -> eprintln!("csift: error: {err:#}")` (full anyhow chain, alternate `:#`) + `ExitCode::FAILURE` (non-zero, no custom numeric codes) — with ONE documented exception: `wait`'s timeout exits **124** (the GNU timeout convention; a monitor's timeout is a normal outcome scripts branch on, rendered via `std::process::exit` after the output flushes). Do not extend 124 to any other command or outcome. De facto (informational): a clap USAGE error exits 2 (clap's own convention), a csift `bail!` exits 1 — never build on the split, the contract is 0-vs-non-zero. **A WRITE ERROR on stdout is an ORDINARY error and takes that same arm** (SPEC §7k): a command piped into a reader that went away (`| head`, a closed pipe) reports `csift: error: Broken pipe (os error 32)` and exits non-zero, where a `println!` ABORTS the process with 101 and a std panic message, a backtrace only under `RUST_BACKTRACE=1` — which is why every `crate::text::Out` is flushed EXPLICITLY at the end of a render rather than left to `BufWriter::drop`, whose drop swallows the error and would exit 0 on a stream the consumer never received (the silent-failure law above). The rule is the WRITER that makes the failing write, never the subcommand, and it is worth stating that way because no list of subcommands stays true: a `println!` still aborts. There is no carve-out inside a buffered surface - EVERY byte it writes goes through its `Out`, its scope and duplicate banners included, so one query has one answer whatever its scope (the banners were `println!` sites above the writer, which made `search` text, `files` and `verbatim` abort on a scope wide enough to print one and report on a scope that was not). Measured over 47 distinct argv rows on a one-session-plus-subagent fixture and 15 more on a fixture holding two copies of one session: 24 and 11 rows REPORT, 21 and 3 ABORT, and the rest write no stdout at all (`plan` with no binding, `ack`, and `show` where it refuses on unmerged copies). The surfaces that print every byte with `println!` and keep the abort are `list`, `stats` in both projections, `recover` in all six forms measured (the default restore, `--patches`, `--salvage`, `--coverage`, `--list-backups` and the JSON projection), `status`, `wait`, `whoami`, `send`, `msg`, `deliver`, `show --branch-points` and `search`'s `-c` / `-l` / `--count-by` terminal printers. NOT errors (exit 0): `verbatim --slice` out-of-range prints nothing; a ZERO-match `search` still exits 0 (a DEFINITIVE absence, NOT an error) and prints its NORMAL empty-result output on STDOUT (`no matching exchanges` in text; the `kind:"header"` + `kind:"summary"` envelope lines in json) — only the `csift: 0 matches …` self-diagnosis rides STDERR (see §5). HARD non-zero: `show` address-miss (an explicit line/uuid/turn resolving to no record → `bail!("no such record(s): …")`) — but the FETCH DOMAIN is wider than the taxonomy (the 1:1 law): an addressed record `classify` models NO leaf for (an `isMeta` pseudo-turn matching no harness marker, a text-less block record, an attachment payload type or a system subtype no family table names) renders as ONE UNMODELED unit (`? unmodeled: <type[/subtype|/attachment.type]>` plus a key=value excerpt of the line's own top-level keys in text, `"label": null` + `"labels": []` + `"unmodeled"` in JSON, driven by `Hit.class: Option<Class>` and a `HitFact::Unmodeled`) instead of bailing, so an address never reports a parseable line as absent; a line that is no JSON object at all (a torn line) is still a hard miss pointing at `--raw`. A SCAN never emits an unmodeled unit — the fallback is gated on `address.is_some()`, so no census, `-t` result or count moves; the address ALSO admits its own NAMED lines past the §7d prefilter, scoped to those lines. `recover` partial/no-history (`bail!` pointing at `--salvage`/`--patches`/`--coverage`); `image --id` no-match or ambiguous `#N` (`bail!`).

---

## 5. Module map (`src/` — a base layer at the crate root, one subcommand family per module under `commands/`)

Every former >600-line single-file module is a DIRECTORY module with a PATH-NAMED root: `src/<m>.rs` beside `src/<m>/` (the root carries the module doc, shared imports, `mod` decls + `pub(crate) use` re-exports — every `crate::<m>::item` path still resolves), children are focused submodules (~200-500 lines, each opening `use super::*`), and a module's unit tests live in `src/<m>/tests.rs` (shared fixture helpers) + `src/<m>/tests/<feature>.rs` (feature-named files that mirror the source children — never numbered slices). **`mod.rs` is BANNED crate-wide** (the legacy layout): enforced twice, by clippy `mod_module_files` and by the pre-commit structure gate. **`src/` holds the BASE LAYER only** — `main.rs` plus `cli`, `model`, `parse`, `path`, `subagent`, `bash`, `chardiff`, `text`, `time_window`, `timez`, and the `#[cfg(test)]` `testfs` — and every subcommand family lives under `commands/` (`src/commands.rs` + `src/commands/`). The split is by layer, not by direction: four base modules deliberately reuse one helper apiece from a command module (`cli` builds `turns` config types, `path` asks `elicitation` whether a target is a sidecar, `subagent` reuses `files`' mutation extractor, `model::narration` reuses `image`'s base64 decoder). **ONE edge runs COMMAND to COMMAND**, and it is deliberate for the same reason the others are - a second implementation would be a second answer: the search engine's exposed-keys family reads `commands::image::{record_pdf_pages, basename, PdfPages}`, so the `agent.tool.result` unit's PDF page range and `image`'s per-row page number come out of one function and cannot drift (§3.6). A new cross-layer or cross-command edge is added the same way: it exists to keep ONE rule in one place, and it is named here. Dispatch conventions are unchanged apart from the prefix (`Command::Foo(args) => commands::foo::run_foo(&args)`; variant->module still not 1:1 — `list` lives in `commands/session/`, `verbatim` in `commands/turns/`). HARD structure limits (husky-enforced, §7): <=600 lines per `.rs`, <=20 `.rs` per folder (a paired module root `name.rs`+`name/` counts with its directory, not against the parent), <=16 subfolders per folder, <=5 levels below `src`/`tests`; per-function cognitive complexity <=25 (clippy.toml); no `mod.rs`. **A cap that binds is a REFACTOR GATE**: re-manage the layout, never raise a cap and never squeeze a file in.

```
main.rs            # binary entrypoint: parse_argv -> install --claude-home override -> dispatch -> ExitCode
                   #   declares only the base layer + `commands`

# ── the BASE LAYER, at the crate root ─────────────────────────────────────────
cli/               # clap surface: argv (the normalize_argv machinery), root (Cli + Command),
                   #   selectors (-t/-T label machinery), formats, one *_args file per
                   #   command family - search_args is a PAIRED ROOT, the flag surface
                   #   beside search_args/gates, which holds the inventory-derived leaf
                   #   sets and the SearchArgs predicates that read a selector against
                   #   them (the candidate gate and the 7f whole-file gate key on those,
                   #   so they are one place) - search_help + search_help/ (a PAIRED ROOT: the
                   #   after_help body is the byte-join of intro, its closing notes, and
                   #   one column-0 region per taxonomy family - each family owns one
                   #   file, and an empty one renders nothing), show_keys
                   #   (the `show` record-row JSON keys as a table with a FAMILY column,
                   #   which `show --help` renders rather than carrying as a sentence)
path/              # home (CC-exact cwd encoding + config home), project_dirs (>200 prefix scan),
                   #   scope, trap (@trap self-id), targets (--sessions-from), resolver (the
                   #   @-grammar: collect_targets/scan_top_level/resolve_prefix_uniqueness),
                   #   agent_subtree (the @<agent-id> dispatch: the owning sessions, the
                   #   topological subtree, the prefix fallback), dedup (the duplicate
                   #   merge: the relation test, the winner rule and the report every surface
                   #   discloses, section 2), ids,
                   #   routing (@<Name>@<Team> -> the transcript id), settings (the five-scope
                   #   cascade csift models, section 3.12) + settings_slots (the deliver-slot census)
model/             # markers, record + record/ (a PAIRED ROOT: `Record` itself, with the
                   #   `message` object, the content BLOCK enum, the EXPOSED KEYS - what a
                   #   record states about the call behind it, one flattened boxed struct -
                   #   and lenient, the tolerant decoders those keys deserialize through),
                   #   predicates (is_genuine_user &c.), exchange
                   #   (AUQ/opens_turn/reconstructed text), mutation (FileOp), grouping (turns),
                   #   automation (pulse model), peer,
                   #   compaction (the SummarizeIndex: the boundary's mode learned by
                   #   pairing with the summary that follows it, built window-independently),
                   #   delivery (the record-level delivery override mirroring the
                   #   harness's one request-path drop predicate),
                   #   inventory (the SHAPE inventory: one row per on-disk shape on the
                   #   five axes, its leaf, its gate and who sees it - the table the
                   #   candidate gates and the SPEC/SKILL surfaces are derived from),
                   #   taxonomy (Role/Class::ALL) + taxonomy/: paths (ALL + path()),
                   #   facets (category/CATEGORIES/role/llm_visible),
                   #   classify (the role.category.leaf DISPATCH) + classify/: support (the
                   #   shared predicates, re-exported from the model root), the five
                   #   MECHANICAL family tables bookkeeping (top-level line types),
                   #   system (type:"system" subtypes), attachments (payload types),
                   #   thinking (block shapes) and injections (isMeta templates),
                   #   channel (the agent.communication.channel leaf + its direction),
                   #   resume (the resume repair pair: the isMeta prompt
                   #   predicate + the `<synthetic>` placeholder recogniser + resume_paired),
                   #   direction (Record::direction, the from/to a comm record carries),
                   #   narration (thinking-signature tag decoder),
                   #   chain (the SURVIVAL AXIS: Claude Code's own conversation-chain
                   #   rule - nodes/relink/leaf/walk/branches, + budget: the CROSSING
                   #   BUDGET - how many compaction boundaries the walk may step over, why it
                   #   stopped where it did, and the ONE disclosure every surface prints -
                   #   + view: the axis answered in
                   #   one surface's own record index space, for the spine-splicing commands;
                   #   nodes also carries the ChainNode, the ONE shape a full record
                   #   and a narrow spine row are both read through, and the
                   #   UuidIndex - the build's own uuid map handed back rather than
                   #   dropped, which is what `ClassifyCtx::line_of_uuid` resolves through)
parse/             # lineage (the depth-1 walk for the narrow top-level fields a
                   #   whole-file question needs without a payload parse: sessionKind,
                   #   continuedInSessionId and the snake/camel session-id PAIR for
                   #   `list --lineage`, slug and timestamp for `plan --audit`),
                   #   lines (mmap + tolerant role matchers [the ONE unsafe mmap site], lazy parse),
                   #   parallel (rayon scans + malformed accounting, incl. the
                   #   two-output scan_lines_parallel_split the single-stream scan is built on),
                   #   readers (RevLines, head/tail),
                   #   spine (the chain-structural fields of a line the prefilter drops, as the
                   #   narrow SpineRow - the byte walk `spine_record` for a scan with
                   #   no validating pass, `spine_from_record` for a demoted record,
                   #   and `line_type_and_spine`, which folds the same fields into the
                   #   validating parse `stats`' line-type census already runs)
subagent/          # ids (the id-trio helpers), types, discover, meta (+JournalCache), lifecycle
                   #   (frozen-lane detection), spawn (ParentSpawnIndex/global index), topology,
                   #   routing (the teammate name+team meta index behind @<Name>@<Team>)
bash/              # the two Bash LEXICAL families, both fed by a tool_use `input.command`
bash/mutations/    # entry, heredoc, mask, commands, outputs, redirect, cwd (CwdAt/Resolution
                   #   tracking + resolve), interp (write-idiom analysis), classes (fmt:/pkg:/
                   #   extract: markers), anchors (content-anchor classifier: the
                   #   deterministic read/write shapes) - the lexical bash parser
bash/danger.rs     # the dangerous-removal PREDICTION, keyed on the Claude Code generation
                   #   that would have run it (§3.9)
bash/danger/shape.rs    # which checker the decomposition would reach, and why
bash/danger/lexical.rs  # `hnt`, the generation 1 and 2 lexical classifier (13 regexes),
                        #   and the text layer every generation runs its input through:
                        #   the substitution strips, the two masks and the private-use
                        #   stand-in alphabet they map onto
bash/danger/out.rs      # `out`, the 2.1.261 rewrite (token walk, find scan, nested shells)
bash/danger/removal.rs  # `_9`, the structured checker: 3 decidable arms, 2 that need the fs
bash/danger/argv.rs     # how a command becomes the argv that checker reads
bash/danger/census.rs   # `Bno`, the substitution census, its >64 bail and the bounded fixpoint
chardiff.rs        # the bounded char-level Myers walk behind the draft-distance line
testfs.rs          # `#[cfg(test)]` only: the `Scratch` guard EVERY unit-test temp fixture
                   #   claims its tree through - a per-process-instance token in the name,
                   #   `create_dir` so a collision is loud, removal on drop. The e2e
                   #   harness's rule (`tests/cli/harness/root.rs`) mirrored, because that
                   #   is a separate crate a unit test cannot import. A fixture helper
                   #   returns the GUARD (or it beside the path): dropping it inside the
                   #   helper deletes the tree under the test.
time_window.rs     # --since/--until parsing (shared by all WHEN consumers)
timez.rs           # REQUIRED shared time helpers
text.rs + text/    # REQUIRED shared helpers: excerpts, banners, envelope, the range grammar
                   #   (a PAIRED ROOT) + out - the ONE buffered stdout `Out` every
                   #   row-dumping surface writes through, and `write_ordered`, the bounded
                   #   ordered-parallel row pipeline `search` renders its exchanges in
                   #   (section 7k; the same "one implementation" reason applied to HOW
                   #   output reaches the terminal rather than to what it says)

# ── the SUBCOMMAND FAMILIES, under src/commands/ ──────────────────────────────
commands.rs        # `pub(crate) mod` for the thirteen families below
commands/session/  # `list`: rows, run (DEFAULT_LIST_CAP flood guard), render, and
                   #   summarize + summarize/ (a paired root: the head/tail summary, the
                   #   clone probe + origin join and the `--lineage`
                   #   whole-file pass, with clear - the `/clear` lineage JOIN, the
                   #   one link found by matching a wrapper instant against a sibling's
                   #   cost checkpoint rather than by reading a field)
commands/search/   # types + types/ (a paired root: Hit/Exchange/SearchOutcome, and facts -
                   #   the two extension points `Marker` and `HitFact`, the six
                   #   per-family arms, the one shared `for` edge and `attach_facts`),
                   #   matcher + matcher/ (a paired root: the pattern side - the
                   #   regex, the literal prefilter and the needle extraction - and
                   #   synth, the SYNTH-MARKER registry plus the section 7f stage-2
                   #   machinery, which is the one shared file every family edits),
                   #   census
                   #   (-c/-l/--count-by + zero-match diagnosis), run (run_search), rows (the
                   #   borrowed file-order row space: Kept, Row and the two merges -
                   #   the index space every chain answer is addressed by), scan + scan/
                   #   (a paired root: the per-file pipeline, and gates - the candidate
                   #   keeps and their needles), turns_match + turns_match/ (a paired
                   #   root: turn reconstruction, and pairing - the file-level tool join),
                   #   hits + hits/ (a paired root: the record emission engine, and
                   #   blocks - the per-BLOCK loop and the sink both halves emit through)
                   #   + hit_facts + hit_facts/ (a PAIRED ROOT: what is stamped from the
                   #   RECORD alone - the ONE STAMPING SITE `family_facts`, the unmodeled
                   #   unit and its shape, the compaction pair's fields, the survival
                   #   answer, the address - beside provenance, what needs the record's
                   #   PLACE: the per-turn root table and the cell a turn's two passes
                   #   share, the engine's own two edges, and the backfill that stamps
                   #   both onto a built hit),
                   #   record_text + record_text/ (a paired root: the shared resolution and
                   #   the matchable-text law, seam - the RENDER seam `family_render` with
                   #   its dispatch table and the one `for_edge` builder, promoted - the
                   #   non-message renders the engine kept - and one file per taxonomy
                   #   family carrying that family's `render()` and `facts()`),
                   #   render + render/ (a PAIRED ROOT: the TEXT surface - glyphs, labels,
                   #   header tokens, banners, and the stream those rows sit in - beside
                   #   exchange, ONE exchange's block (the UNIT the ordered-parallel
                   #   pipeline renders, with the record line `show` shares and the
                   #   precomputed image-hint slot) and notes, the two closing
                   #   disclosures), render_json (the --format json projectors + the
                   #   envelope; its exchange row is the pipeline's unit too)
commands/show/     # addr (line/uuid/turn specs + caps), run (raw/rendered fetch), render,
                   #   branch (--branch-points fork facts)
commands/stats.rs + stats/
                   # one-scan per-session aggregates (a PAIRED ROOT: the scan and the
                   #   aggregation at the root, render - the merged scope totals and the
                   #   two projections - in the child)
commands/agents/   # run (filters/windows/teammate hint), render (text tree), json (flat rows)
commands/files/    # types, run, mutations (structured + bash heuristics), rollup, render
commands/recover/  # types, run (batch driver), backups (--list-backups store listing),
                   #   bash_anchors (per-turn content-anchor gates + suppression),
                   #   scan (+opaque accounting), events (extraction),
                   #   carriers (toolUseResult/attachment + freshness signals), buffer (SparseBuffer),
                   #   replay, diff (LCS/unified), render (disclosure + merge), restore, report, timeline
commands/plan.rs + plan/
                   # plan forward/reverse resolution (reuses whoami's detection + guidance);
                   #   audit (--audit: edits joined against corpus bindings), facts (the
                   #   binding facts: the slug's change points, the bound file's
                   #   existence, plan text with no binding, the slug against the file's
                   #   birth instant)
commands/live.rs + live/
                   # `status` + `wait` (the live-truth pair): background (types/lens/report) +
                   #   background_scan (needles/ingesters/join/stat), last (the newest prompt +
                   #   reply excerpts), activity (the wait census), tasks (the harness task list),
                   #   registry (sessions/<pid>.json
                   #   + ps pid probe), tail (bounded tail state machine), children
                   #   (subagent tails + incremental journal), verdict (six-verdict join),
                   #   conditions (--until grammar), status, wait (poll loop), render
commands/live/channel/
                   # the CHANNEL. Data layer: types (message + closed enums + ids +
                   #   STEER_EVENTS, the ONE delivery-event list every surface reads),
                   #   paths (the sidecar layout, lane-id validation, atomic append/rewrite),
                   #   envelope (chunk render + the detector + header parse), outbox, inbox,
                   #   ledger, marker (armed/<lane>.json), slots (the temp-dir slot chain),
                   #   reconcile (intent vs fact). Commands: send + send_render (the receipt's
                   #   two projections) + caller (caller classification, receiver probe, gate
                   #   verdicts + the settings disclosure every verdict names) + policy (the
                   #   routing table, a PURE fn over
                   #   gathered facts); deliver + deliver_plan + deliver_emit + hook_input +
                   #   recipe; msg (msg + ack); reach/ (facts, predict, peers, render) behind
                   #   whoami's lane sections, --to and --peers
commands/turns/    # `verbatim`: config, units, run, build, richness, select (the keep/collapse
                   #   decision, the fold placeholder + its previews, and the
                   #   same-prefix re-send fold), cost (the render GEOMETRY + budget cost model
                   #   the planner shares with the renderer - one list of lines per marker, so
                   #   summed-cost == summed-emitted), planning (plan_session), render, scope, json
commands/image/    # refs (base64 probes), selection (#N), run, convert (transcode), render
commands/elicitation.rs
                   # transparent elicitation-sidecar merge (no subcommand)
commands/whoami.rs # `whoami`: CLAUDE_CODE_SESSION_ID detection, false-positive-safe; the
                   #   lane sections / --to / --peers stay THIN here
                   #   (engine: commands/live/channel/reach)
```

**E2E tests** live in ONE integration binary `tests/cli/` (main.rs declares the modules): `harness.rs` + `harness/` (the `Home` isolation harness in `home.rs` + the shared fixture builders in `fixtures.rs` — the CROSS-command helpers live HERE and a feature file depends on no SIBLING; a PAIRED ROOT may carry the fixtures its OWN children read through `use super::*`, which is what `search/survival.rs` and `duplicates.rs` do). Per-SUBCOMMAND modules own that command's tests (`search/` split by aspect — basics/filters/census/headers/taxonomy/classify/output/scope/channel; `agents/`, `verbatim/`, `recover/`, `files/`, `targeting/`, `live/`, `list/`, `show/`, `image/`, the `channel/` (send/deliver/msg/whoami) and `stats/` (scope/aggregates/usage/survival) as feature dirs; `plan_whoami`/`plan_facts`/`whoami_lane` as single files), and CROSS-COMMAND sweeps get their own root files: `argv.rs` (flag ordering, exit conventions), `contracts.rs` + `contracts/` (range grammar, timestamps, honest empties, the cross-command survival contracts), `spanning.rs` (span-switch/scope-banner/cap uniformity), `elicitation.rs` (EVERY sidecar-merge test across surfaces), `duplicates.rs` + `duplicates/` (one session id under two project directories: the root carries the fixtures and the session shapes, `spanning.rs` the disclosure on the surfaces that span sessions, `agent_targets.rs` the `@<agent-id>` half). A new e2e test goes in the module whose FEATURE it exercises, never a grab-bag file. The golden baseline stays at `tests/turns_pre_feature_baseline.txt`.

**`search` flags from the verified audit** (cli.rs `SearchArgs`, search.rs): `-c/--count-only` prints ONLY the integer total `exchanges.len() + dropped_by_cap`; `-l/--sessions-with-matches` prints ONLY the distinct OWNING session ids (uncapped, stderr-noted on a `--max-count` drop — the pipe into `--sessions-from -`); the JSON summary's `transcript_ids` stays the per-transcript detail set (sorted, cap-100 + explicit `transcript_ids_truncated`) — DELIBERATELY named apart from `-l` (which emits the OWNING-session `parent_session_id`s), so the two "which sessions?" answers never collide. `--count-by <AXIS>` is a THIRD terminal mode (like `-c`/`-l`): a per-KEY CENSUS of the matched records along ONE closed axis (`label`|`tool`|`turn`|`session`|`pairing`|`model`|`attachment`|`version`|`result`; an empty pattern = a whole-scope census; the `attachment` axis IMPLIES the `--attachments` gate; `result` buckets tool results `ok`|`error` - pairing answers "did a result come back", result answers "was it good"; an errored result also renders an inline `[error]` and hit JSON carries `is_error`). On `label` each record counts under every leaf it carries THAT SURVIVES the active `-t`/`-T` (census keys pass the same `LabelFilter::selected` predicate that admits the record's views — a dual-labeled record never leaks its filtered-out twin into the keys; no filter ⇒ the full label set, and a leaf's count = how many records `-t <leaf>` would surface, via `search::label_census` — also the zero-match probe engine, which passes `LabelFilter::all()` since it exists to name what the DROPPED filter excluded); every OTHER axis counts each record ONCE and EXCLUDES records outside its domain (no tool/pairing/model), reporting the excluded count (`search::axis_census`). A multi-SECTION record (text+tool_use, a batched notification) emits one hit per section but is ONE census record (`record_groups`, search.rs). `set_pairing` rides the tool BLOCK through the comm views (a SendMessage/spawn tool_use → sent/signal, a subagent-return tool_result → inbox), so a frozen SendMessage is `pending` under ANY selector; record-text comm units (no tool_use_id) stay outside the axis. `turn` sorts ascending (histogram); the rest count-desc except `label` (richest-first). JSON emits `census` rows `{axis, key, records}` + a `{axis, matched_records, distinct_keys, excluded_records, …}` summary. `-T/--label-not` excludes labels (same selector grammar as `-t`, `LabelFilter` include-minus-exclude, richest-SURVIVING-view dedup, statically-empty combos bail). `--raw` emits matched records' VERBATIM jsonl lines (per-file mmap backfill onto `Hit.raw`; stdout pure, notes on stderr; sidecar hits omitted + noted). Every JSON hit carries `refetch` (the ready-to-run `csift show` command addressed at the hit's own `session_id`); in TEXT output a SUBAGENT hit line ALSO prints a `↳ csift show @<agent-id> --line N` continuation — the exact refetch with the CORRECT id, closing the text-mode ID-law hole where a parent-uuid + subagent-line would silently fetch the WRONG record (a line number is per-FILE); top-level hits stay terse (safe via the header's id-prefix token — a valid `@` target). `--siblings` is a ZERO-ARG flag with a FIXED policy (`sibling_cap`): message classes uncapped, thinking≤2 / thinking.narration≤1 / tool.use≤3 / tool.result≤3 / harness≤2 per leaf; the capped remainder renders an explicit `(+N more · csift show @<id> --line A..B)` pointer and rides JSON as `siblings_hidden` + `turn_lines`. Record FETCHING moved to `csift show` (search has no `--line`/`--uuid`; `AddressSet` + `fetch_records` remain in search.rs as show's engine). `--resolve-persisted` rewrites tool-result text pre-match (failures soft-noted; under `--raw` it affects matching only). PATTERN guardrails: leading `@` = hard error; uuid-shaped + no session target = stderr note.

**Empty-result self-diagnosis (the keystone, `search`).** A ZERO-match search is a DEFINITIVE absence, NOT an error — exit 0, with stdout carrying the NORMAL empty-result output (`no matching exchanges` in text; the `kind:"header"` + `kind:"summary"` envelope lines in json). The diagnosis rides STDERR: a line framing the result as `0 matches — a definitive absence (exit 0), NOT an error`, the active filters (`active_filters_str`), and — WHEN a `-t`/`-T` label filter was active — an ACTIVE PROBE: a re-scan of the SAME pattern + scope with the label filter DROPPED that NAMES the label(s) the pattern DOES occur under (e.g. `⚠ but "X" DOES occur — N record(s) under: agent.tool.use`). The probe cost is paid ONLY on a zero-hit + label-filtered query. JSON echoes it in the summary: `definitive_absence:true` + `active_filters` + `excluded_by_label` (`{records, by_label}`, null when no label filter). Structures: `search::EmptyDiagnosis` / `emit_empty_diagnosis` / `active_filters_str`. Rationale: the observed #1 slippage cause is a model reading an empty result as a SYNTAX error and abandoning csift — this makes absence machine-legible and self-correctable.

**argv normalization:** the entrypoint is `cli::parse_argv` (NOT `Cli::parse`): it runs `cli::normalize_argv` to reorder declared flags ahead of leading-`-` encoded-project positionals, fixing clap's `allow_hyphen_values` greedy-absorb bug (#3880). Flag discovery is zero-drift via `Cli::command()` introspection (action `Set`/`Append` take a value; `SetTrue`/`SetFalse`/`Count`/help/version do not). **The subcommand is located by SCANNING PAST any declared ROOT flags (+ their value tokens; a `--flag=value` inline form spans one token) rather than assuming `argv[1]` is the subcommand: read that way, a pre-subcommand GLOBAL flag disables normalization for the whole line, and `csift --claude-home DIR list @x --max-count 3` mis-parses - the trailing flag is swallowed by the PATH positional with a misleading "not a project target" error. Scanning past the root flags is what makes "flag order is free" and "`--claude-home` in any position" hold IN COMBINATION. So `csift list <ENCODED> --format json` and `csift --claude-home DIR list @x --max-count 3` both work.

---

## 6. How to add or modify a subcommand

1. **Args struct** in `cli.rs`: add `pub struct FooArgs` (`#[derive(Args, Debug)]`). Positionals that target sessions use `#[arg(value_parser = parse_project_target)]` so a `--`-typo errors cleanly. Put example-rich text in `#[command(after_help = "...")]`/doc comments (the `--help` is the LLM's manual - keep it usable from `--help` alone). If the subcommand spans subagents, add the span flag(s) + a `pub fn want_subagents(&self) -> bool` mirroring the existing ones (ten of them, one per span-taking args struct, across src/cli/list_args.rs, search_args.rs, show_stats_args.rs, image_plan_args.rs, live_args.rs, verbatim_whoami_args.rs and src/cli/files_recover_args/).
2. **Command variant**: add `Foo(FooArgs)` to `enum Command` (src/cli/).
3. **Dispatch**: add one arm to `run()` in `main.rs`. The real arm shape is `Command::Foo(args) => commands::foo::run_foo(&args)` (handler fn convention is `run_<subcommand>`: `run_list`/`run_search`/`run_agents`/...). **The dispatch MODULE need not be named after the `Command` variant** - the precedent is `Command::List(args) => commands::session::run_list(&args)` (main.rs): `list` lives in `commands/session.rs` (`pub fn run_list`, src/commands/session/); there is NO `src/commands/list.rs`. Variant->module is NOT 1:1; pick whatever module already owns the concern. Every handler returns `anyhow::Result<()>`, so `?`/`bail!` funnel to the single FAILURE arm; never `eprintln!`+exit yourself.
4. **Module**: host the handler under `src/commands/` (a new `src/commands/foo.rs`, or fold into an existing module as `list` did into `session.rs`); declare `pub(crate) mod foo;` in `src/commands.rs` if new (keep the list alphabetical). Never at the crate root: that is the base layer. The handler `run_foo` then resolves targets via the shared resolver:
   `let scope = SubagentScope::from(args.want_subagents()); let files = path::resolve_session_files(&args.paths, scope, path::Caller::Other)?;`
   - **Resolve via `&args.paths`** - that is the convention for `list`/`agents`/`files`/`recover`/`verbatim`/`image`/`plan` (src/commands/session/, src/commands/agents/, src/commands/files/, src/commands/recover/, src/commands/turns/, src/commands/image/, src/commands/plan.rs). The ONE exception is `search`: its first positional is `PATTERN`, so `SearchArgs` adds the SOLE `targets()` helper (src/cli/ - the only `fn targets` in the tree) that returns the path positionals only (`self.paths.clone()`, excluding the pattern); `search` resolves `&args.targets()` (src/commands/search/). Do NOT add a `targets()` method to a non-`search` struct (it has none -> compile error); use `args.paths`.
   - **Scope resolution.** Every default-on span command (`list`/`search`/`stats`/`files`/`recover`/`image`/`plan`) exposes `want_subagents() -> bool` (= `self.subagents || !self.no_subagents`) and resolves `SubagentScope::from(args.want_subagents())` (`true->WithSubagents`, `false->TopLevelOnly`). `verbatim` is the opt-in exception (`self.include_subagents && !self.no_subagents`; flag spelled `--subagents`). `SubagentScope` has only those two variants; there is no subagents-only mode.
   - `agents` passes `false.into()` because it LISTS subagents rather than spanning them.
   - The `path::Caller` arg is currently **INERT** - `resolve_session_files` does `let _ = caller;` (src/path/, "reserved for future subcommand-aware guidance"). Pass `path::Caller::Other` (or the matching variant) to stay future-proof, but do NOT expect it to change remediation text today; the doc's "Caller tunes messages" is forward-looking, not live.
   Read files via `parse::mmap_bytes` + the head/tail/parallel scanners; lazy-parse only candidate lines; **derive each row's id trio with the `crate::subagent` helpers** (`session_id_from_path` / `is_subagent_path` / `parent_session_id_from_path` - never read the stem, see section 3.7); render via `text::` excerpt helpers + `timez::` timestamps. Honor: no `unwrap`, report any cap's drop count, support `--format json` via a `json!` projector (section 4 JSON invariant - no `derive(Serialize)`).
5. **Tests**: add an end-to-end case to the matching FEATURE module under `tests/cli/` (one integration binary driving the built csift against synthetic transcripts; per-subcommand dirs and the cross-command root files are mapped in §5 — put a new test in the module whose feature it exercises, never a grab-bag file). **Isolation mechanism (load-bearing - reuse it, don't hand-roll a fixture scheme):** the e2e harness never touches the real `~/.claude`. The `Home` struct (`tests/cli/harness/home.rs`) makes a TempDir, creates `.claude/projects` under it, and `Home::write(rel, contents)` drops a fixture jsonl at `$HOME/.claude/projects/<rel>` (e.g. `<ENC>/<uuid>.jsonl`). Its `run`/`run_with_env`/`run_full` invoke the binary via `env!("CARGO_BIN_EXE_csift")` (the exact build cargo produced) with `.env("HOME", &self.root)` and `.env_remove("CLAUDE_CODE_SESSION_ID").env_remove("CODEX_COMPANION_SESSION_ID")` for deterministic `whoami` (a test that needs a session id sets it back via `run_with_env`). This works because `claude_home()` (src/path/) falls back to `$HOME/.claude` when no `--claude-home`/`$CLAUDE_CONFIG_DIR` is set. Start from the `populated_home()` builder (`tests/cli/harness/fixtures.rs`) - the canonical fixture, used in ~198 cases (`populated_home()`/`Home::new()` combined) - rather than inventing a new tree.
   Unit tests local to a module go in `src/<m>/tests.rs` (shared fixture helpers + `mod` decls, wired by a plain `#[cfg(test)] mod tests;` at the bottom of `src/<m>.rs` - **no `#[path = "..."]` attribute needed**) with feature-named children under `src/<m>/tests/` that mirror the source submodules; child files never depend on each other. A SMALL module may keep an inline `#[cfg(test)] mod tests { ... }` while it fits the 600-line cap. Run `cargo test`.

---

## 7. Commands + quality gate

```bash
cargo build                                  # debug (GATE: must succeed)
cargo build --release                        # optimised (thin-LTO, 1 cgu) for real scans
cargo run -- <subcommand> ...                # run a subcommand
cargo fmt --all -- --check                   # format gate
cargo clippy --all-targets -- -D warnings    # lint gate (warnings-as-errors)
cargo test                                   # unit + integration tests (also installs the hook on first run)
```
**Pointing csift at a fixture tree (not the real `~/.claude`).** csift resolves its data root (`claude_home()`, src/path/) with precedence `--claude-home <DIR>` > `$CLAUDE_CONFIG_DIR` > the OS home's `.claude` (`$HOME` on Unix, `%USERPROFILE%` on Windows — the split mirrors CC's own `os.homedir()`; a stray Git-Bash `HOME` on Windows is deliberately ignored), then reads `<root>/projects/<encoded>/*.jsonl` (`projects_root`). `--claude-home` is a clap **global** flag (`global = true`, src/cli/; installed in `main` before dispatch, main.rs), so it is honored by EVERY subcommand and `normalize_argv` lets it sit before OR after the subcommand: `csift --claude-home /tmp/fix list` and `csift list --claude-home /tmp/fix` both work. Use `--claude-home <dir>` (or export `$CLAUDE_CONFIG_DIR`) to run a manual repro against a synthetic projects dir instead of your live transcripts. All three positions are exercised by the `custom_claude_home_via_env_var_and_flag` e2e test. **Test-isolation trap:** `--claude-home` installs via `set_claude_home_override`, which writes a SET-ONCE process-global `OnceLock<PathBuf>` (`CLAUDE_HOME_OVERRIDE`, src/path/; `let _ = ...set(dir)` silently ignores later sets, src/path/). So an IN-PROCESS unit test CANNOT relocate the data root via the override - the first set wins for the whole test binary (all tests share one process). For path-resolution unit tests call the PURE `resolve_claude_home(flag, env, home)` (src/path/, factored out precisely so precedence is testable WITHOUT touching the OnceLock); for e2e use the `Home` harness, which spawns a fresh subprocess per test with `$HOME` set (`tests/cli/harness/home.rs` `run_full`). Don't write a flaky test that calls the global `claude_home()`.
**Pre-commit gate (cargo-husky).** On the first `cargo test`/`cargo build` after checkout, cargo-husky (dev-dep, `user-hooks` feature) installs `.git/hooks/pre-commit` from `.cargo-husky/hooks/pre-commit`. It runs, in order: the STRUCTURE GATE (hard limits: <=600 lines per `.rs` file [Markdown exempt], <=20 `.rs` files per folder, <=16 subfolders per folder, <=5 subfolder levels below `src`/`tests`) -> `cargo fmt --all -- --check` -> `cargo clippy --all-targets -- -D warnings` (which also enforces per-function cognitive complexity <=25 via clippy.toml + the `[lints.clippy] cognitive_complexity` warn) -> `cargo test`. A failure blocks the commit. No CI service runs - this hook is the ENTIRE gate. Edit the **source** hook (`.cargo-husky/hooks/pre-commit`), never the installed `.git/hooks` copy, then re-run `cargo test` to reinstall. Genuine-WIP bypass: `git commit --no-verify` (sparingly). Test layout: `tests/cli/` (the ONE e2e binary, feature modules + `harness/`), `tests/turns_pre_feature_baseline.txt` (golden), and per-module unit tests under `src/<m>/tests/`.

**Versioning + release discipline (LOAD-BEARING — the SKILL's staleness guard depends on it).** The version is TRIPLE-LOCKED and the three MUST move together in the SAME commit: `Cargo.toml` `version` ≡ `SKILL.md` `Surface: **vX.Y.Z**` header ≡ `csift --version`. This is not cosmetic: `SKILL.md` line 3 tells the LLM consumer "Surface vX must == `csift --version`; if an invocation you were confident about errors, your knowledge is stale — re-read this file", so it uses version==surface as its ONLY staleness detector. Shipping a changed surface under an unchanged version silently defeats that guard and puts two DIFFERENT binaries in the wild claiming the same version — a correctness bug for the primary (LLM) consumer, not a nicety.
- **What is a "surface change" (⇒ REQUIRES a version bump):** any change to a command, a flag, a flag's semantics, a default value, a JSON field name/shape, a row `kind`, output text a consumer keys on, OR the `--help` text (SKILL calls `csift <cmd> --help` "the authoritative flag manual", so help IS surface). Pure internal refactors, tests, comments, and non-user-facing docs are NOT surface changes and do not require a bump.
- **Which number (0.x policy — csift is pre-1.0, so per SemVer the MINOR carries breaking changes):** bump the **MINOR** (`0.Y.0`) for a BREAKING surface change — a removed/renamed command or flag, a renamed/removed JSON field, a changed default or exit semantics (a renamed subcommand, a renamed summary field, a removed flag). Bump the **PATCH** (`0.Y.Z`) for a NON-breaking surface change — an added flag, a corrected help string, a fixed output, a bug fix (a corrected help string is one). At 1.0+, breaking ⇒ MAJOR.
- **Tag discipline:** every released version gets an ANNOTATED git tag `vX.Y.Z` on its release commit (`git tag -a vX.Y.Z -m "…"`), and tags are pushed (`git push --tags`). The release commit is `chore(release): vX.Y.Z`. Never leave a shipped version untagged.
- **Changelog discipline:** `CHANGELOG.md` (repo root) carries ONE digest entry per released version, newest first (`## [X.Y.Z] - YYYY-MM-DD`), written IN the release commit — never after the fact. The SPEC §6 per-version ledger stays the authoritative long-form record; the changelog is its scannable digest, and the two must agree. Entries are contemporaneous: name flags/fields by their names AT that version (no forward references to later renames).
- **Introspection ledger audit (SCHEMA 2; a RELEASE GATE).** `INTROSPECTION.json` at the repo root lists every Claude Code behavior csift depends on, one claim per entry, and the README's "verified against Claude Code X" badge is admitted ONLY through it: `tests/cli/ledger.rs` (run by name in the pre-commit hook, and again under `cargo test`) refuses a badge version any claim's OPEN state lacks a receipt at. A claim carries its `code[]` sites at CLAIM level and one or two version-ranged STATES, exactly one of them open; each state carries the claim text for its range and ONE `verified` receipt, which is the last check at that build whose instrument names an ARTEFACT OUTSIDE csift's own tree. That classifier is gate rule 20 and it runs over EVERY check at the verified build, newest first: the selection is mechanical, so two measurements at one build are not a choice. BEFORE EVERY RELEASE a verification subagent per area re-runs each claim's instrument against the CURRENT Claude Code binary + live corpus and rewrites that state's receipt - **mechanical replacement of the receipts is forbidden in every case**. A `drifted` verdict CLOSES the state's range and opens the next one, in the same release. The full schema, the twenty-two gate rules and the six procedures (new behavior, moved code, rotted anchor, release audit, closing an open leg, retirement) are in §7.1.
- **Release flow (do ALL, in order):** (0) run the introspection ledger audit (above) at the Claude Code version the badge will name, rewrite each claim's open-state receipt, bump the README badge + `verified_claude_code` together; (1) bump the triple (Cargo.toml + SKILL header) AND add the version's CHANGELOG.md entry in one commit; (2) `cargo build --release && cargo install --path .`; (3) confirm `csift --version` == the SKILL surface header; (4) the pre-commit gate runs on commit (`chore(release): vX.Y.Z`); (5) `git tag -a vX.Y.Z`; (6) `git push && git push --tags`; (7) if the surface change renamed/removed a COMMAND, re-sync the global hook in lockstep (see §9 / the `csift-turns-slice.sh` coupling). A pushed release is never amended/force-pushed — a follow-up fix is its own PATCH bump + tag.

### 7.1 The introspection ledger (`INTROSPECTION.json`): schema 2 + procedures

**Why.** csift depends on hundreds of small facts about what Claude Code writes, and every Claude Code build can move one. The ledger pins each dependency as a CLAIM with the code that relies on it and the instrument that re-verifies it, so a release audit is a mechanical walk (one claim, one instrument, one receipt) and the README badge is a checked statement. The standing law behind it: every csift behavior is backed by OBSERVED Claude Code behavior, recorded as a claim in the SAME change that lands the behavior. "Based on a finding" is not a claim; a claim names what was measured, how, and with which counting rule.

**What schema 2 changed, and why.** Schema 1 grew a `checks[]` history - one appended record per release audit - and that history became the file: 1,856 checks behind 739 claims, 1,117 of them superseded by a later one on the same claim, all of them carrying the calendar dates, csift versions and audit-process words the prose law bars. A fact record does not need its own minutes. So a claim now carries one or two version-ranged STATES, each with ONE receipt, and the history lives in git. The two other shapes that moved with it: `code[]` sits at CLAIM level (csift's tree is ONE tree at any time, so where a fact is relied on is never a property of a Claude Code version range), and the prose law is a GATE (rule 14) rather than a habit.

**File shape (schema 2; plain JSON, 2-space indent, ASCII, trailing newline; LLM-facing: dense and regular, never edited by hand-wave; the `about` field restates the no-bulk-edit rule).**

```
{ "schema": 2, "about": "...", "verified_claude_code": "2.1.258", "claims": [ <claim>, ... ] }

claim = {
  "id":       "REC-038",            unique, never reused; "<TAG>-<NNN>" with TAG = the area's tag (below), NNN zero-padded.
                                    ONE id predates the shape and is KEPT: `QT-SYS-001`, minted when the leaf that became
                                    the `harness.system.*` family shipped. An id is a stable handle the SPEC ledger and the
                                    changelog cite by name, so renaming it would orphan those citations to tidy a string
  "area":     "record-model",       one of the 19 areas
  "code":     [ { "path": "src/commands/live/verdict.rs", "lines": "296-302", "snippet": "<verbatim excerpt>" }, ... ],
                                    CLAIM-LEVEL and >= 1 site; snippet copied byte-for-byte (indentation included); `lines`
                                    is 1-based inclusive and gate rule 4 requires the snippet to sit INSIDE it, so the range
                                    is recomputed whenever the code moves (mechanically, from the snippet - never the other
                                    way round). SIZE is the observed rule, not a quota: 1-15 lines carries nearly every site
                                    (a one-line function signature anchors perfectly well; a few match arms run longer).
                                    What matters is WHAT is quoted - a signature or a statement, something a rename or a
                                    rewrite would move. A lone doc-comment fragment anchors weakly: prose survives the
                                    refactor that invalidates the claim, so pair it with the code it describes. A test file
                                    may be a SECOND site, never the only one
  "compat_of": "<id>",              optional: this claim is the STALE half split out of <id> - an older Claude Code shape
                                    csift must still READ, kept forever with its own id and its own code sites
  "states":   [ <state>, <state> ]  ONE or TWO, ordered oldest first, exactly ONE of them OPEN (`to: null`)
}

state = {
  "from":     "2.1.170",            the earliest build the behaviour was OBSERVED at; never null
  "from_basis": "first_seen" | "parsed" | "verified" | "located",
                                    WHERE that floor came from, in one word, because the four sources are not equally
                                    strong and the number alone hides which one answered. `first_seen` is a build the
                                    behaviour was observed at; `parsed` is the same, read out of a value that also carried
                                    prose; `verified` is the earliest build a receipt ran at, which BOUNDS the floor
                                    without measuring it; `located` is the build the claim was PINNED at, a default that
                                    says nothing about when the behaviour began. Without the word a defaulted floor reads
                                    exactly like a measured one
  "from_after_verified": "...",     required when the OPEN state's `from` is ABOVE `verified_claude_code`: why a behaviour
                                    first observed on a NEWER build carries a receipt at the badge's build and a range
                                    above it. The shape is real, so it is allowed, and it owes its reason
  "to":       "2.1.257" | null,     null = open; a closed state's `to` is the last build it held at
  "behavior": "...",                what Claude Code DOES over that range: field names, values, shapes, counts
  "depends":  "...",                what csift does with that fact, and what breaks if the fact moves; for a deliberate
                                    ABSENCE ("csift never asserts X") say why the absence is the correct reading
  "instrument": "...",              how a stranger re-verifies from scratch: the command or probe, the corpus scope, the
                                    counting rule
  "probe":    { "kind": "bytes" | "corpus" | "csift" | "live" | "repo", "literal" | "command": "...",
                "scope": "...", "rule": "..." },
                                    the MACHINE-READABLE twin of `instrument`, and it is derived from the INSTRUMENT and
                                    from nothing beside it. `command` is a COMMAND or a PROCEDURE a stranger can execute,
                                    in that order of preference: a command line where the instrument gives one, and where
                                    it gives steps instead (`For each compact_boundary, join ...`) the steps, because a
                                    procedure a reader can follow is worth more than a command line invented to look
                                    machine-readable. Whichever it is, it is never truncated: a cut at a semicolon, a
                                    comma or a parenthesis names a DIFFERENT command than the one that ran, and a tool
                                    name carrying only flags is a noun in a sentence rather than a command at all. The
                                    KIND is decided by the ARTEFACT and never by the command word, so an instrument that
                                    walks a projects root with rg or jq is `corpus` however it is spelled, and one that
                                    reads the build with `strings` is `bytes`. The `scope` then names THAT
                                    artefact and gate rule 22 holds the pair together, because a kind and a scope
                                    derived apart drifted apart. `bytes` is for an instrument that reads the BUILD, and its `rule` says what such a
                                    probe is for: RE-LOCATION, never measurement - so where an instrument reads bytes AND
                                    censuses a corpus, the census wins, because gate rule 12 re-reads every producer hop
                                    already and the census is the half a stranger re-runs by hand. `repo` re-verifies what
                                    CSIFT does rather than what Claude Code does, which is why it can never be the
                                    measurement a receipt rests on; folding those into `corpus` would make that word mean
                                    "a filesystem read" and weaken every transcript row. `live` names a GESTURE nobody can
                                    run from a repository and is not a fallback bucket: an instrument that names none
                                    lands on `corpus` with its scope saying so. A `scope` that the instrument does not
                                    state reads `not stated: <what the instrument does say>` rather than a template - a
                                    field asserting a population nobody named is the defect, and the fixed prefix is
                                    countable. A `rule` is a COUNTING rule: what ONE observation is, and it says so in
                                    a countable noun rather than leaving a reader to infer one - `two invocations, two
                                    outcomes.` names nothing that can be counted twice. The instrument's own declared
                                    `Counting rule:` sentence is taken verbatim where it has one, and where that sentence
                                    names no unit the unit is read off the instrument and stated in front of it
  "producer_trace": "complete" | "partial" | "none" | "upstream",
                                    the PRODUCER leg: "complete" = three hops read in the shipped binary as verbatim
                                    excerpts at byte offsets - the TRIGGER (the entry that starts the path), the GATE (the
                                    branch that selects this fact in this shape) and the WRITER (the template, field or
                                    append that becomes the bytes); "partial" = a template or field found without its gate
                                    and trigger; "none" = nothing read, only inferred; "upstream" = the producer lies
                                    OUTSIDE the shipped binary BY CONSTRUCTION (the model or API side: a signature, a usage
                                    number, what the model types; the operating system: a process utility, a locale table;
                                    a native runtime binding) while the client-side treatment of the value is traced like
                                    any other fact - a search that failed is never upstream
  "specimen": "observed" | "none",  the SPECIMEN leg: a corpus count with a counting rule, a raw record, or a live trial;
                                    a self-written fixture is never a specimen
  "attribution": "end-to-end" | "producer-only" | "specimen-only" | "partial-producer" | "by-elimination" | "upstream",
                                    DERIVED from the legs (rule 7): complete+observed = end-to-end · complete+none =
                                    producer-only · partial|none + observed = specimen-only · partial+none =
                                    partial-producer · none+none = by-elimination · upstream+observed = upstream; "the only
                                    candidates are X, Y, Z" is not attribution, and by-elimination can never hold
  "upstream_reason": "...",         required when attribution is `upstream`: the producer DOMAIN, named
  "open_legs": [ "..." ],           one sentence per missing leg naming the instrument that closes it (required unless
                                    end-to-end or upstream; an end-to-end state carries NONE - an open leg is a leg that is
                                    open)
  "residue":  [ "..." ],            optional: a non-gap note for a later pass (a version question the historical builds
                                    would answer, a stronger specimen that would be nice, a later-build observation the
                                    fold would otherwise drop) - never a missing hop, never an unread site
  "producer_chain": [ { "role": "trigger" | "gate" | "writer", "name": "<function or template>", "offset": 158819054,
                        "excerpt": "<verbatim, <= 700 chars>", "note": "<what this hop does for the claim>",
                        "build": "2.1.208" (optional), "anchor_status": "absent" | "prefix-only" (optional) } ],
                                    `build` names the artifact the hop was READ IN when that is not the verified build - an
                                    older version, a Windows artifact - and rule 12 compares against THAT one. Without it
                                    such a hop is compared against a build nobody looked in, fails, and gets marked
                                    `absent`, which asserts something false
                                    the traced hops, present when producer_trace is complete or partial: the anchors a
                                    future pass re-reads first (a moved offset with the same excerpt = the same producer; a
                                    missing excerpt = the producer changed). More than three hops is normal when a fact has
                                    several writers. `anchor_status` (the same field on an `enumeration` entry) records that
                                    the excerpt could NOT be located, so gate rule 12 counts the hop and skips it instead of
                                    failing: "absent" = the excerpt is in the verified build at no offset at all;
                                    "prefix-only" = the excerpt's head matches at the offset and its tail does not. Both come
                                    with a `residue` line naming the hop and the instrument that re-locates it, and both are
                                    the standing worklist. An anchor that is byte-exact again carries no such field; the
                                    marks are never written by hand to quiet the gate. A hop with NO byte location carries no
                                    `offset` at all - an offset beside an EMPTY excerpt anchors nothing and rule 12 refuses it
  "writer_literal_floor": { "literal": "...", "lowest_present": "2.1.208", "highest_absent": "2.1.207", "ladder": "..." },
                                    optional: the writer hop's minification-stable literal byte-searched across historical
                                    native builds - the lowest build carrying it and the highest lacking it. A literal dates
                                    itself, not the behavior (a rename moves it; an older wording may carry the same fact),
                                    so this is a lead for the next pass, never a substitute for the state's `from`
  "enumeration": [ { "site": "<caller or occurrence>", "offset": 176355907, "excerpt": "<verbatim, <= 300 chars>",
                     "verdict": "<what this site does for the fact>", "build": ..., "anchor_status": ... } ],
                                    `build` and `anchor_status` mean here exactly what they mean on a `producer_chain`
                                    hop, and rule 12 reads both lists through one walk: an enumeration entry read in a
                                    Windows artifact or an older version names it, and one whose excerpt cannot be
                                    located carries the mark plus its residue line
                                    present when the state is a NEGATIVE ("nothing else writes X", "the only emitter is Y",
                                    "exactly five kinds"): the closure of a negative IS the enumeration - every occurrence of
                                    the function or field in the whole binary, each read, each with a verdict. An enumeration
                                    that stops early leaves the producer partial
  "verified": { "claude_code": "2.1.258",   the LAST check at that build whose instrument names an ARTEFACT OUTSIDE
                csift's own tree - a Claude Code build or executable, a byte offset in one, a corpus or projects root or
                jsonl population, a registry or task directory, a session target, a live trial - because the ARTEFACT is
                the test and never the command: `grep` over `src` is the same text pass as reading the file, while
                `grep -ab` over a shipped build is a measurement. A check naming only `src/`, `tests/`, a self-written
                fixture, csift's own documents or the claim's own text is a TEXT PASS: it decides nothing about Claude
                Code and can never be the receipt that backs the badge. LAST is the rule and it is applied uniformly - two
                measurements at one build are not a choice.
                "verdict": "holds" | "refined" | "drifted" | "unverifiable-here",
                "instrument": "<what actually RAN>", "observed": "<what it returned, with the numbers>",
                "rule": "<the counting rule the numbers obey>", "note": "<what changed, what was fixed>" }
                                    ONE receipt per state: the last pass at that state. An OPEN state's receipt is made at
                                    `verified_claude_code`; a CLOSED state's was made at the build its range ended on and is
                                    never re-made
}
```

Areas and tags: record-model `REC` · turn-boundary `TURN` · classification `CLS` · compaction `CMP` · elicitation `ELI` · subagents `SUB` · background-tasks `BG` · queue-and-telemetry `QT` · freshness-file-history `FH` · bash-shell `BASH` · plan `PLAN` · narration `NAR` · images `IMG` · windows `WIN` · path-encoding `PATH` · live-registry `REG` · live-tail `TAIL` · messaging `MSG` · misc `MISC`. Add an area only when a dozen-plus claims need it; pick an unused tag.

**Verdict semantics.** `holds` = observed exactly as the state says. `refined` = the behavior holds and the text gained precision (a number, a version floor, a newly seen shape); the behavior/depends edit rides the same commit. `drifted` = Claude Code no longer does what the state says. That is a correctness task for the SAME release, and under schema 2 it has a SHAPE: close the old state (`to` = the last build it held at) and open a new one carrying the current truth, or - when csift never matched the fact in the first place, so no build ever made the old text true - rewrite the one state and let git keep what it said. A rewritten text never leaves a `drifted` verdict behind: rule 9 refuses a ledger whose current word on a claim is "drifted". `unverifiable-here` = the instrument cannot run on this host (a Windows-only shape read on a mac, a corpus with zero specimens); `observed` names the reason and the state keeps its last decided text.

**States, and when to open a second one.** Two states is the ceiling: a claim is a fact, and a fact has a current reading and at most one superseded one. A SECOND state is opened when a build CHANGED the behavior and the claim must say both halves - the closed state's `to` is the last build the old half held at, taken from the ladder or the historical builds, never guessed. It is NOT opened for a text correction: a sentence that was wrong at every build never held over a range, so it is rewritten in place. When the OLDER behavior is one csift must still READ - a retired marker kept for older transcripts, a tool name no current build offers - and the two halves want their own code sites, split it into a STALE **backward-compatibility claim** instead: its own id, `compat_of` naming the parent, its own `code[]` citing the historical arm, and it is kept forever.

**Gate rules (`tests/cli/ledger.rs` + `tests/cli/ledger/{rules,states,legs,programs,probes,artefact,prose,tally}.rs` and the paired root `anchors.rs` + `anchors/{builds,marks}.rs`, which holds where a build is looked for and what a mark asserts; the pre-commit hook runs them by name before the suite, and each one fails BY NAME on a hand-built specimen under `tests/cli/ledger/specimens/`).** (1) `verified_claude_code` is a version triple and the README badge carries the same version. It is also asked BEFORE rule 2: bumping the badge ahead of the audit is the ordinary way to reach this gate, and rule 2 would then name every claim, so when NO open state carries a receipt at the verified build the gate reports the version ONCE, names the build the receipts do stand at, and rule 2's version arm stands down while its other arms keep running. (2) Every claim's OPEN state carries a `verified` receipt made AT `verified_claude_code` with a verdict from the closed set; a CLOSED state's receipt is not re-made. (3) A decided verdict (`holds`/`refined`/`drifted`) on an OPEN state carries a non-empty (instrument, observed) pair that is UNIQUE across the ledger - a bulk replacement leaves identical pairs and fails here; `unverifiable-here` names its reason in `observed`. (4) Every code site exists, its snippet is found verbatim (whitespace-normalized) in that file, AND the snippet lies inside the declared `lines` range, which may not exceed the snippet's own line count by more than two - so a refactor that moves code fails the gate instead of orphaning the claim, and a range cannot be padded to the whole file (`1-99999` contains everything and clamps, so containment alone would pass it) to point a reader at something else. (5) The README carries the mutation-score badge. (6) Every claim cites at least one code site. (7) Every STATE carries an `attribution` from the closed set and, unless end-to-end or upstream, a non-empty `open_legs`; a TRACED leg carries the hops it claims - an OPEN state declaring `producer_trace: complete` names at least one `producer_chain` hop WITH an offset, and one declaring `partial` names at least one hop at all, because `complete` means three hops read at byte offsets and `partial` means a template or field was FOUND, so either with nothing recorded asserts a reading the file does not hold and leaves rule 12 nothing to re-read (three states declared `partial` with an empty chain; the honest word with nothing read is `none`, which with an observed specimen derives the same specimen-only attribution); an end-to-end state carries no open leg at all; when the leg fields are present the attribution equals their derivation; an `upstream` state names its `upstream_reason`; a by-elimination state never carries a `holds` verdict. (8) The README's ledger tally block (between the `ledger-tally` markers) equals the ledger's per-attribution counts OVER OPEN STATES and carries the rule-12 anchor line; the whole block is regenerated, never typed. (9) No OPEN state's verdict is `drifted`. (10) No FIELD carries an unconsumed text instruction (a `TEXT ...` line) or a record of a rejected rewrite: both mean a refuted text is still shipping, and both are consumed only by rewriting the claim. The scan covers `open_legs`, `residue` AND `verified.note` - scanning the legs alone was scanning the one place the instructions were not, and fourteen of them sat in the other two. (11) Every state below end-to-end carries both leg fields, so rule 7's derivation always runs. (12) Every BYTE ANCHOR OF AN OPEN STATE still carries its own excerpt: for each `producer_chain` hop and each `enumeration` entry with an `offset`, `bytes[offset .. offset+len(excerpt)]` must EQUAL the excerpt in the build the hop was READ IN - the one `verified_claude_code` names, unless the hop names its own in `build`. That optional field is what stops a Windows artifact and an older version from being compared against a build they were never read in and then marked `absent`: 33 such marks asserted an excerpt was nowhere, about a build nobody had looked in. A BUILD NOBODY HOLDS IS NOT A PASS, though: counting such a hop made `build` a way to go green by typing a version, and setting an excerpt to garbage beside an uncached build name did exactly that. So the count-and-pass posture belongs to the host that holds NO build at all - a container, the Windows guest, a downstream `cargo test` - where no anchor of any kind can be decided; on a host that holds the VERIFIED build, a hop naming an artifact the host cannot read is UNREAD rather than unreadable and is named by the rule, with the artifact to cache or the build to re-anchor in. The printed line keeps its `not cached` count, which reads 0 wherever the rule passes. The comparison is on BYTES rather than on a UTF-8 round trip, because a few excerpts were captured from regions that are not valid UTF-8 and stored one code point per byte; both readings are tried. An EMPTY excerpt is refused BEFORE the comparison - it matches at every offset, so an offset beside one (offset 0 above all, the placeholder for "no byte location") was a silent free pass - and so is an offset at or past the END of the build it is compared against, which is a defect in the LOCATOR rather than in the excerpt and is refused BEFORE the eliding and the marks, both of which would otherwise count it skipped (three enumeration offsets addressed a predecessor build and were compared against the verified one). A `build` NAME is itself checked, on any host, because it decides which artifact a hop is read against and a name no host holds is counted `not cached`: it is a Claude Code version - a triple whose major is 1 or 2 - optionally followed by `-<platform>-<arch>` in lowercase, so `9.9.9` cannot quiet a rotted anchor by naming a build that never existed. And a MARK is a claim about a byte LOCATION, so a marked hop carries an `offset` and that offset is not 0: four census notes carried `absent` at 0, the placeholder for having no location, and asserted their excerpt was nowhere in a build they had never addressed - and so is an excerpt carrying a run of FOUR OR MORE DOTS, which is a lossy render of non-printable bytes rather than a quote of them: it can never be byte-exact, and because it contains `...` the elided arm counted it skipped, so 76 anchors checked nothing at all. The remedy for one is re-cutting the excerpt to the clean run around its literal. AN ELLIPSIS EXCUSES THE TAIL AND NEVER THE HEAD: carrying `...` says nothing about whether an excerpt is a quote, since minified JavaScript's spread operator wears the same three dots and most `...`-bearing excerpts are byte-exact at their own offsets anyway - so what is left after the comparison misses really has elided something, and the run BEFORE the first ellipsis is held to the offset exactly as `prefix-only` holds a head. Without it the whole elided bucket was compared against nothing: replacing an already-elided excerpt with wholly fabricated text moved no figure and the gate exited 0, over 230 of 7,625 anchors. An excerpt that OPENS with its own ellipsis quotes nothing at its offset and is refused by name. Three anchors are counted rather than compared WHOLE - an ELIDED excerpt, held to its head instead, and one marked `anchor_status` `absent` / `prefix-only`; anything else fails naming the claim id, the hop index, the offset and the first differing byte on both sides. A MARKED hop is decided BY ITS MARK, before the eliding: the five counts are a PARTITION, and a hop marked `absent` whose excerpt also carried an ellipsis with a head still matching at its offset was counted in the elided bucket and under its own mark at once. No hop in the file is shaped that way, which is why the arm ORDER has to carry the rule rather than the data - and the same order gives one defect one line, without which a marked prose excerpt is named by the classifier and by the mark check both. **A MARK IS A CLAIM, NOT A PASS**: every mark is itself verified against the build, or marking a hop would be a way to quiet the rule by typing a word. `absent` must mean the excerpt occurs at NO offset in the build; `prefix-only` must mean that PLUS at least one leading byte matching where the offset points; an empty excerpt can never be marked, and neither can one carrying a run of four or more dots nor one that reads as PROSE - an excerpt whose every whitespace-separated token is a plain word, five or more of them, is a DESCRIPTION of the bytes rather than a quote of them, so it occurs in no build by construction and decides nothing (`a string-table copy of the property name in the non-code region` shipped as one). That refusal reaches an UNMARKED excerpt too, which is how census prose wearing an ellipsis anchored nothing and passed; a NUMERAL counts as a word in it, because prose carrying one number is still prose, while a list of bare numbers is a census result and has its own remedy. It is asked AFTER the byte comparison, and so is the dot run, because a quote really can carry four dots and a build string really can be an English sentence - an error message, a prompt - and an excerpt that IS the bytes is byte-exact whatever it reads like. A dot-joined identifier list is one token, a census of byte offsets carries no letters, and a quoted literal carries code punctuation, so none of the three is prose. The "occurs nowhere" half of every mark runs as ONE Aho-Corasick pass over the build rather than one search each - sound as a short-circuit, since any occurrence would make that pass report a match, and the exact per-mark path runs only when something did match. Byte-exactness is decided BEFORE eliding, deliberately: minified JavaScript is dense with `...`, so an excerpt carrying one is usually byte-exact anyway and counting it exact is the stronger reading. A CLOSED state's anchors were exact at the build its range ended on: they are COUNTED, never compared, and the tally line names them (`closed-state anchors N, not checked`). The verified build is `$CSIFT_LEDGER_BINARY`, else `<home>/.local/share/claude/versions/<verified_claude_code>`; a hop's own build is `$CSIFT_LEDGER_BUILDS/<build>/claude`, else that same native layout, and nothing else - a verification cache belongs to the operator who keeps it, so the repository names an environment variable rather than a path. A host that does not hold a build (a container, the Windows guest, a downstream `cargo test`) prints one line naming the path and PASSES - an anchor is a property of a build, not of this repository. The rule prints its tally, and rule 8 requires the README to carry the same numbers AND asserts that the five counts partition the total, which is the arithmetic the page states. HOW MANY `...`-bearing excerpts are byte-exact anyway is a measured property of the shipped file and of the build, so the walk DERIVES it as a subset of what it already decided and rule 8 holds the README's sentence to that count; it is stated in no other document, because a figure typed in one goes stale the moment an anchor is relocated - this one moved by 154 and was still being read back out of four of them. Those two measured figures - the badge's version and the anchors line - are checked against the ledger here in the gate rather than against anything the page says about itself, so on a host with no build the line is read BACK off the README and compared to the counts the ledger alone answers for. (13) A claim carries one or two states, exactly one open AND LAST - the list is a timeline, and ordering by `from` alone does not force it, because a closed state can open at the same build as the open one. Every state names a `from` that is a Claude Code version and a `from_basis` from the closed set, a closed state names its `to`, the states are ordered by `from`, and the OPEN state's range opens at or below `verified_claude_code` UNLESS `from_after_verified` says why not - a behaviour first observed on a NEWER build has a receipt at the badge's build and a range above it, and that shape is stated rather than assumed. (14) THE PROSE LAW, as a gate over every text field of every state - see below. (15) Every state names its `behavior`, its `depends` and its `instrument`, and its `probe` carries a kind from the closed set PLUS the field that kind is answered by, a non-empty `scope` and a non-empty `rule` - a probe with a kind and nothing else is a placeholder wearing a schema. (16) A CLOSED state's receipt is shape-checked too: a build no later than its own `to`, a verdict from the closed set, a non-empty instrument and observed. It is never re-made against a later build, but a range nothing ever stood behind is not a range. (17) No field hands a RETIRED selector spelling to csift as an ARGUMENT. The shipped binary hard-errors on `harness.meta` and its eight leaves, on the flat `thinking`/`tool`/`tool-response` spellings, and on `harness.schedule.continuation`, so a probe naming one cannot run at all; a residue line saying what a leaf USED to be called is history, which is what a superseded reading is for, and the test is on the argument position rather than on the bare word - which is exactly what `tool` needs, because `-t tool` is the retired flat path while `--count-by tool` is a valid census axis and always was, so that one position excludes that one spelling. (18) The file's own shape: `schema` is 2, `verified_claude_code` is a version, the claim list is non-empty, and every claim carries a unique non-empty `id` and a non-empty `area`. These were bare assertions in the driver, which is how a check ends up with no specimen behind it. (19) An instrument that carries a PROGRAM carries one that PARSES: every heredoc in an `instrument` or a `probe.command` is handed back to its own language - python `compile`, `bash -n`, `jq -n` - and a failure names the claim, the field, the line the heredoc opened on and the interpreter's own message. A heredoc's INDENT is its block structure, so a prose pass that normalises whitespace runs can leave every word of a sentence intact and every program in it dead, and no other rule here would see it: seven shipped that way, along with five `probe.command` copies of them. An interpreter this host does not hold is COUNTED and skipped, exactly as rule 12 skips a build the host does not hold - whether a program parses is a property of the program, and a machine without jq is not evidence about it. The rule prints its tally beside rule 12's. A `jq -n` run EXECUTES its program, so only a failure whose first line names a syntax or compile error counts; anything else is the program asking for input the rule deliberately does not give it. (20) An OPEN state's receipt names an ARTEFACT OUTSIDE csift's own tree. The classifier the selection rule rests on lives in the GATE (`tests/cli/ledger/artefact.rs`) and not only in the pass that writes the file, because a rule applied solely by the thing it grades is not a rule. Its shape is three tables and one lead test: the ARTEFACTS (a Claude Code build or a byte offset in one, a corpus or projects root or jsonl population, the harness's own sessions and tasks directories, a session target, a live trial or a named host), the SELF-WRITTEN FIXTURE, which is never a specimen and makes a check a text pass even where it names a transcript, the REAL ARTEFACT a fixture cannot be, and a check that DECLARES itself a text pass in its opening clause, which is one whatever it goes on to mention. Each arm carries ONE token of slack between the words of a name, because every miss found so far was the artefact spelled with a word in the middle - `shipped 2.1.258 build` names what `shipped build` names, `live 2.1.258 trial` what `live trial` names, `the whole Claude Code 2.1.258 file` the build under another noun, and `a Windows 11 ARM64 host` and `the sessions directory` the host and the state directory spelled as English rather than as a path. Widening those five moved FIVE receipts, so 650 of the 739 open states take the last check at the verified build rather than 645, and all 95 messaging claims do rather than 93. TWO artefacts were missing from the table outright, and both are among the commonest instruments in the file: a csift RUN pointed at something (`csift files <project> --by timeline`) reads transcripts off disk, but the table reached one only through the `@` target shapes, so a run aimed at a project directory or a named transcript read as prose; and a count over the WHOLE BUILD is the build arm under another spelling. The run arm needs the TARGET - a subcommand named inside a sentence about what csift does is a noun - and the two together moved two more receipts, to 652. (21) A `bytes` probe names a literal the BUILD carries. The probe's whole content is the literal a later pass re-locates, and rule 15 only ever asked whether the field was non-empty: four shipped counting ZERO in every build their state names - two cut at a length that runs past where the build stops agreeing, two carrying a ` ... ` elision INSIDE the literal, which is not a byte string at all - so each was a probe nobody could run, which is the one thing a probe is for. The build is a SET, because a state names up to three (its receipt's, its range's floor, a closed range's ceiling) and a rewrite between two of them is often exactly what a `bytes` probe is there to date: BASH-038's target regex was rewritten at the build its range opens at, so the verified build carries it zero times and that is the FACT. A literal absent from the builds READ is named only when every build the state names was read; one still uncached is a reading this host cannot take, counted and skipped as rules 12 and 19 skip what they cannot read, and the rule prints its own tally. A CLOSED state's probe is read too, which is where this rule and rule 12 part company: an anchor is a byte LOCATION and a later build moves it, while a probe's literal is a STRING whose presence in the builds a closed range names is decidable wherever this host holds them - and one shipped counting zero at every build of its own range, first occurring at the build the NEXT state opens at, so it dated that event rather than its own. (22) A probe's KIND and its SCOPE name the SAME artefact. The kind is decided by what the command reads and the scope is derived from the instrument, and nothing asked whether the two agreed: thirty-eight states carried `corpus` beside a scope reading `the shipped Claude Code binary the state names`, and one carried `bytes` beside `the top-level *.jsonl of one projects root`. Each half was defensible on its own, which is exactly how a self-contradicting pair ships - a reader asking the file which probes read a build got one answer from the kind and another from the scope. The test is on the two kinds whose artefact is FIXED: `csift` names a run, `live` a gesture and `repo` this tree, and any of the three may name either noun without contradicting itself, so widening the rule to them would report vocabulary rather than a defect. It STOPS at the scope and never reads the `command`: a scope is a noun phrase and a word list decides it, while a command is a program, and classifying one by the words in it has the same false-negative shape the kind-and-scope pair had - a census walk that happens to say `binary` would read as a build read, and a build grep piped through python would read as neither. What holds the COMMAND is the procedure law above (it names the artefact its kind reads and RUNS as written) plus rule 15, which refuses a probe whose kind arrives without the field that answers it. It runs over EVERY state, open and closed alike, for rule 21's reason - a probe is a STRING naming an artefact, and whether it names the right one is decidable wherever the state is readable.

**The prose law, and what rule 14 can and cannot decide.** The ledger is a clean fact record keyed by Claude Code version only, written for a machine reader. A count is stated with its counting rule and its scope, a version fact as a Claude Code version, and an open leg names only the instrument that closes it. The commit and the release carry the when and the which-csift; the ledger carries the what. Rule 14 bars three families MECHANICALLY, because each can only be the process that produced a sentence rather than the fact it states: a CALENDAR DATE or a long month name (a corpus census is reproducible from its counting rule and its scope, never from the day it ran); a CSIFT VERSION (`0.x.y` - which csift build read a fact is the commit's business, and a Claude Code version is `1.x.y` or `2.x.y`, so the two never collide; the trailing boundary carries a third arm for the full stop a SENTENCE ends on, because `[^\w.]` refuses the `.` and `$` cannot see past it, which leaves `0.10.0.` unmatched); an AUDIT-PROCESS WORD (`verifier`, `tracer`, `scribe`, `adjudication`, `kicked`, `today`, `yesterday`, `audit`, `audited`, `re-audit`, `planted fake`, `adversarial`, and every PLURAL of those plus the agent noun `auditor` - a word list that stops at the singular is a list with a hole in it, and `adjudications` once shipped 67 times in one bulk receipt sentence while `adjudication` was already barred; `auditing` and `attempted`, the two process VERBS the list was missing, while the countable noun `attempt`/`attempts` stays because `one discovery attempt per poll` is a counting rule and `attempting` stays because the build's own edit-validator message says `Read it again before attempting to write it`; and `round`, which led 85 carried readings as `round two of the attribution push` and named the pass in nineteen more sentences besides); and FIRST PERSON, a sentence with a person as its subject being about the reading rather than about the build - `I` plus ANY word, because English verbs are not a closed set and a list of them let `I then`, `I caught`, `I actually`, `I made`, `I now` and `I declare` through, with `my`, `we`, `our` and `us` as bare tokens whose boundary admits a closing quote, since an apostrophe ends a pronoun as readily as a full stop does. The `audit` arm spares csift's OWN spellings by SHAPE - a flag (`--audit`), a path (`src/commands/plan/audit.rs`), a source file - because the older guard spared any match ADJACENT to `.`, `/` or `-` and thereby spared `kicked-back`, `re-verifier`, `outside the audit.` and `re-run today.`. `round` is spared by shape too, and on what FOLLOWS it: a value ROUND-TRIPS and a probe's arithmetic ROUNDS a ratio, so `-trip`, ` trip` (the hyphenated and open spellings of one compound, which a single sentence writes either way) and `(` are csift's own and everything else is the pass. **What rule 14 deliberately does NOT ban, and why the list is shorter than the law reads:** `session`, `lane` and `rejected` are csift's own domain vocabulary - a top-level session, a frozen lane, a rejected tool use - so banning the token would destroy meaning rather than narrative; those words are barred in their audit SENSE, which no regex decides, and they are written out by hand. The first-person arms are MECHANICAL but narrow, and the narrowness is measured: these strings quote minified JavaScript where `we`, `We` and `wE` are ordinary identifier names, so a case-folded `\bwe\b` over every string value of the shipped file reads 746 hits and exactly 2 of them have whitespace on both sides, both inside a `code[].snippet`, which quotes csift's own source and which the rule does not scan (counting rule: one hit per non-overlapping match over every string value). The arms are therefore case-SENSITIVE and anchored on whitespace both sides - a pronoun is a standalone English word, an identifier sits against `=`, `(`, `.` or `;` - and where an identifier really is spelled `we`, the remedy is the backticks it should always have carried. An `excerpt` is EXEMPT at every level, and so is `probe.literal`, every member of `writer_literal_floor` - its `literal` is a build's own bytes and its `lowest_present`, `highest_absent` and `ladder` are the versions that literal was counted across, a measurement written in Claude Code versions and nothing else - and any BACKTICKED span: they reproduce the build's own bytes, where `kicked:!1`, `"today"`, a model's November release date and a beta interned as `server-side-fallback-2026-06-01` are the build's words and not the ledger's. THREE conditions bound that exemption. The backticks must BALANCE - an unclosed one, which an excerpt cut mid-token upstream leaves behind, makes left-to-right pairing exempt PROSE instead of a literal - the span must be SHORT, at most 400 characters on one line, since a 2.2 KB run between two backticks is prose that happens to sit there and one shipped a bare `today` behind the exemption, and it must not READ as prose: 400 characters is a lot of English, so a sentence carrying a date, a process word and a first-person clause fits inside the bound comfortably. A literal is not made of words - a command is flags and paths, an excerpt is minified syntax, an output sample is columns and numbers - so the test is whether PLAIN WORDS carry the span: five or more of them AND more than half its tokens. BOTH HALVES EARN THEIR PLACE, AND NEITHER SPAN COUNT BELONGS HERE: how many spans the predicate scans is a property of the shipped FILE rather than of the predicate, so a figure typed into a document moves whenever the file does and nobody re-derives it. What the halves DO is the fact worth carrying - five or more plain words alone already scans nearly every span the majority half scans, and what the majority removes is the family of literals that happen to carry five words, such as a quoted sample of csift's own output line, half of it columns and paths, whose date is a record's instant rather than the day anything ran. Everything a pass WROTE is scanned.

**The sweep law: a prose rewrite never reaches into quoted text.** The ledger's fields quote commands, regexes, sentinels and verbatim build bytes, and inside those a comma, a semicolon and a colon are CONTENT: a comma cut out of `[.type, .isVisibleInTranscriptOnly]` makes a different jq program, and a semicolon cut out of `for(let o=1;;o++){` makes the quote a lie about the bytes. So a rewrite pass masks every backticked span, brace and bracket literal, quoted run, byte offset, Claude Code version and function call before any rule runs and restores them after - a rule may still match ACROSS a span but never INSIDE one - and a quote kind whose characters do not BALANCE is skipped entirely rather than paired left to right. A date removed from a sentence is REWRITTEN to state the build or the count, never cut with its neighbour, and two counting rules distinguished only by their dates are renamed for what they DO rather than left both called "the rule". The check is mechanical: a difflib pass over every before/after pair, where a DELETE span that is nothing but punctuation and sits between two UNCHANGED runs is a corruption, and the count must read 0. **A THIRD arm reads the NUMBERS.** A state's `behavior` says what Claude Code does and its receipt's `observed` says what the instrument returned, and every other rule here reads the two apart - so a noun carrying a number in both fields could carry two DIFFERENT numbers and nothing noticed. The sweep pairs them and must read 0. It is narrow on purpose, because the wide form reports a coincidence of vocabulary rather than a defect: a TWO-WORD noun phrase, never a bare noun (`record`, `line` and `section` are the whole file's vocabulary, and comparing those across two populations read 156 rows of which almost none were disagreements, against 21 for the phrases); a HEAD that is a noun, since `record whose` and `result written` are a phrase that ran past its noun into a verb; and a SHARE agreeing with either of its own members, because `243 of 244 summaries` and `244 summaries` are one measurement stated two ways. EIGHTEEN states carried one. Every one is the same rule counted over a different population - a corpus that grew between the two readings, one project directory against two, a whole projects root against a single file - so the fix is a `residue` line stating BOTH readings and naming neither as wrong, since the pass re-ran neither census: the receipt is the reading the state was last verified at, and the behavior's sentence keeps the population its own words name. The sweep then passes through its RECONCILED arm - a residue naming both numbers - rather than through a silenced one. **WHITESPACE is content on the same terms.** An instrument carries a heredoc and a heredoc carries a program, so the leading indent of a line and any interior run of two or more spaces are masked with the other protected spans - BEFORE them, so a backticked span holding one keeps it too - and restored byte for byte by the same unmask. A run the RULES create, where a deletion leaves two spaces around the word it removed, is not masked and is still collapsed, which is the whole of what the squeeze was ever for. The detector's whitespace arm is keyed on the difference the same way: a span that is nothing but spaces or tabs, sitting between two UNCHANGED runs, coming out NARROWER than it went in. It must read 0. A width histogram will not do - it cannot tell a run the pass squeezed from a run it removed along with the annotation that run separated, and eleven of the latter read as damage until the arm was keyed on the difference instead.

**The observer-exclusion law: a live-root figure is a WHOLE-ROOT reading at its instant unless it says otherwise.** A census over `~/.claude/projects` reads the transcript of the session taking it, and its subagents, and everything they quoted - the reading writing itself into its own population rather than anything Claude Code does - and the root is appended to while it is walked, so the same rule never reads the same number twice. THE CONVENTION: a figure over the live root that carries no exclusion clause was taken whole-root at its instant, the measuring session's own tree included, and that is how a reader takes every one of them. The root GROWS while it is walked, so a re-reading is normally ABOVE it - normally, and never as a law: Claude Code rewrites a transcript IN PLACE (a rewind tombstone truncates and rewrites the tail; the armed local GC rewrites on compaction - section 4, MISC-020), so a whole-root LINE count can fall between two readings while the file count holds, and "at or above, never below" is a promise the data does not make. THE OBLIGATION, on a rule written from here on: name that status, and give the EXCLUDED reading beside the whole-root one wherever the exclusion moves the number the claim rests on. It moves by a lot. A compaction-boundary census over the top-level transcripts of the live root loses one transcript and about a tenth of its boundaries when that tree is dropped - ONE transcript, and by far the largest single contributor - while the count of boundaries naming no record their own file carries is invariant under the exclusion (MISC-043, MISC-046); one claim's single "apparent match" of a retired answer marker was three records inside that tree, where a subagent's tool_result quoted the literal at the head of a table, and zero on both roots once excluded (ELI-004); and the file-and-line size of the root itself drops by the whole of a fleet session's sidecar (REC-140 through REC-151). AND THE EXCLUDED READING IS AN INSTANT'S READING TOO: the exclusion removes the measuring session's own tree and NOTHING ELSE, while every other Claude Code session on the machine appends to the same root as the walk runs - so the excluded half moves between two readings as well, by the lines those sessions wrote, and it is a receipt at its instant exactly like the whole-root one beside it. Neither half is a CHECK on anything: a rule that re-measures one of them across two runs and reads the difference as a change in Claude Code is measuring the machine's other sessions. A rule over a SNAPSHOT root needs none of this, because nothing appended to it while it was read. The numbers themselves live in the claims rather than here, for the reason the law states: a live-root reading is an instant's reading, so a figure quoted in this file goes stale the moment the root grows.

**Attribution law (why rule 7 exists).** A behavior is pinned only when BOTH legs are in the file: the producer (the writer in the shipped binary: a template string, a function, a branch condition) and a specimen (what it wrote, observed on disk or live). "The only candidates are X, Y or Z", "presumably", "it appears", a story that fits the shape, or csift's own code are not attribution: when a later build moves the real producer, such a state lets the next pass conclude that nothing changed. So a pass that cannot close a leg writes it into `open_legs` and keeps the verdict at `refined` or `unverifiable-here`; upgrading `attribution` is itself a measured act, recorded in the receipt that did the tracing. **The closure rule:** when a fact's closure condition is an enumeration, the conclusion is to exhaust it - grep every call site in the whole binary, read each, list them (`enumeration`); a bounded sample, "the only candidate", or an argument from absence leaves the producer partial. When a hop REFUTES the state's text, the text is rewritten in the same change, never softened into a note. When a leg is a version question ("which build first wrote this shape"), the instrument is the historical builds themselves: native builds are fetched by version from the distribution bucket the installer names, and a fact is bisected on a distinctive string literal (minified identifiers change between builds; templates and field names do not). **The stability condition:** a state below end-to-end is stable only when the reason is STRUCTURAL and stated as a field (`upstream`, a live instrument named in `open_legs`, a Windows or remote host), never a search that stopped.

**The fold law: a dropped check owes its facts.** Collapsing the history is not licence to drop what the history measured. A dropped check can be the only record of a measurement, of a version-floor ladder, of a refutation whose refuted sentence still ships, or of a drift. So every dropped check is asked, TOKEN BY TOKEN, and the TOKEN CLASSES are the whole of whether that question can be answered - a fact spelled in a class the model cannot see is dropped in silence. SEVEN classes: a COUNT with its units, a SHARE (`50 of 61`, which carries what neither number carries alone), a Claude Code VERSION, a BYTE OFFSET, a QUOTED LITERAL read as its inner text rather than its typography, a CLAIM ID, and a BARE IDENTIFIER in camelCase, PascalCase or snake_case at seven characters or more, which is how a Claude Code field, function or value is named in prose most of the time. Reading only the first four lost 199 identifier facts and 90 shares over 95 claims. FOUR things are deliberately NOT facts, because carrying one fills `residue` with a sentence nobody needs: the ledger's own SCHEMA WORDS, which are the record's vocabulary rather than anything about Claude Code; a backticked span from a LINE whose backticks do not balance, which is a mis-pair rather than a literal (the balance is a per-line property, since a literal never spans a newline); a bare YEAR, which is unmatched precisely because the prose law removed the date around it; and a claim's own id. Whether anything a check carries survives anywhere in the state is asked TWICE, once where the fold runs and once over the file as it will ship, because a measurement table landing in between can replace the very receipt the first answer rested on; the sentences holding a token with no survivor are carried into `residue` with their counting rule, and a drifted check's refutation is carried whether or not its numbers are. A drop is NAMED in the change that makes it, never called "superseded" without naming the superseding fact. A token NO sentence of its check holds is named too, one line per claim: every one of those is a quoted fragment the source cut mid-span, and a fold that could not say so would be guessing at where a sentence ended. A carried sentence that is itself a TEXT INSTRUCTION is never carried as one - gate rule 10 refuses it and rightly, since an instruction is consumed by rewriting - so it is RESTATED as what it measured, with the point the source sentence is cut at named rather than guessed past.

**Procedures.**
- *A new behavior lands* (a feature, a fix, a newly modeled record shape): its claim lands in the SAME commit - `code[]` sites, and ONE open state whose `from` is the earliest build the behavior was observed at, with behavior, depends, an instrument a stranger can rerun, its `probe` twin, the two legs, and a `verified` receipt at the current `verified_claude_code` carrying the receipt of the measurement you actually made (the corpus census, the binary string count, the live trial).
- *A refactor moves cited code*: the gate fails naming the claim ("snippet not found verbatim in <path> - the code moved - fix the site", or "snippet is not inside <path> lines A-B" when only the range drifted); update `code[].path` and `lines`, and the snippet only when the code itself changed (a visibility keyword, a renamed binding). `lines` is recomputed MECHANICALLY from where the snippet sits, never guessed and never widened to make the range fit. No state is touched for a move.
- *An anchor rots* (rule 12 fails, or a new build is verified): the offset is a locator and the excerpt is the evidence, so the pass is mechanical and one-directional. FIRST ask which build the hop was READ IN - if its own text names one other than the verified build, the fix is the `build` field and not a mark, and a mark asserting an excerpt is nowhere is false about a build nobody looked in. Then search that build for the excerpt: present exactly once, set the offset to it; present more than once, take the occurrence nearest the old offset and add a `residue` line naming the hop and the count; present nowhere, mark the hop `anchor_status` with the residue line naming the instrument that re-locates it - `prefix-only` when the excerpt is ABSENT EVERYWHERE and at least ONE leading byte still matches at the recorded offset, `absent` when none does. The prefix test is exactly that, any non-empty run, with NO length threshold, because the data is bimodal and a threshold would be invented rather than measured: counting every offset-carrying hop at Claude Code 2.1.258, of the excerpts absent everywhere 186 share zero bytes at their offset and 84 share at least one, and only 6 of those 84 share fewer than four. NEVER rewrite an excerpt to make an anchor pass - that is editing the evidence, and it is exactly the move the gate exists to catch. A hop that has no byte location loses its `offset` instead of keeping one beside an empty excerpt. AN EXCERPT IS RE-CUT rather than re-anchored when it was never a quote of the bytes, and there are THREE such shapes. A LOSSY RENDER: an excerpt carrying a run of four or more dots was captured by printing every non-printable byte as `.`, so it is a picture of the bytes and can never equal them. PROSE: a sentence describing the bytes, which the mark rule refuses; re-cut it to the run it describes and move the description into the `verdict`, where prose belongs. A CENSUS RESULT: the list of offsets a literal occurs at is the entry's FINDING rather than a quote of any of them, so it moves into the `verdict` too and the excerpt becomes the run the entry's own offset carries. Re-cut it to the longest run AROUND its own literal that decodes cleanly and carries no such run, move the offset to that run's start, and verify the new excerpt by re-reading it at the new offset. The cut costs visible context - a window that showed the anchor plus its table neighbours becomes the table entry alone - and that context was never evidence: it was unverifiable by construction.
- *A release audit* (release flow step 0): one verification subagent per area, fresh context, read-only on the repo. It reads each claim's OPEN state, RUNS the instrument against the current Claude Code binary and the live corpus, and REWRITES that state's receipt with the instrument that ran, what it observed (numbers), the counting rule, the verdict and a note; refined and drifted verdicts come with the text edits, and a drift closes the state and opens the next one. One receipt per claim; the uniqueness rule catches copy-paste, the discipline catches the rest. When every claim's open state carries a receipt at version X, bump `verified_claude_code` and the README badge to X in the same commit, then triage: every drift is resolved before the release commit. An audit is the moment to add claims for behaviors the previous pass missed.
- *Closing an open leg*: run the instrument the leg names - trace the missing hop in the shipped build, or capture the specimen - then, in ONE edit to that state: rewrite the `verified` receipt so its `observed` carries the reading, record the hop in `producer_chain` (or the specimen's count and counting rule) so rule 12 has something to re-read, delete that line from `open_legs`, and raise the two leg fields and the `attribution` the derivation now gives. Never raise it on wording alone: `attribution` is derived from the legs by rule 7, so an upgrade with no new hop and no new specimen fails the gate, which is the point. A `complete` trace owes at least one hop WITH an offset and a `partial` one at least one hop at all - the honest word for a leg nobody closed is `none`, and with a specimen observed it derives the same specimen-only attribution without asserting a reading the file does not hold. When the instrument RUNS and the leg stays open (the shape has no specimen on this host, the producer is upstream by construction), the leg is rewritten to name what would close it and where, and the state keeps `refined` or `unverifiable-here`.
- *Retiring a claim*: never delete silently. Keep the entry, rewrite its open state's note to name the successor claim (or the removed feature), and drop the code sites the gate cannot find.
- *Privacy*: no session ids, no external or user paths, no person or product names, no reference to any implementation other than the shipped Claude Code binary; instruments cite synthetic fixture shapes and corpus COUNTS, never transcript content.
- *Reading it*: `jq -r '.claims[] | select(.area=="live-tail") | "\(.id) \(.states[-1].behavior)"' INTROSPECTION.json`; the open state is `.states[] | select(.to==null)`. Claim ids are stable handles for commit messages, CHANGELOG bullets and SPEC ledger entries.

---

## 8. Conventions

- `PascalCase` types, `snake_case` items, one module per concern.
- **Structure limits are law (husky-enforced)**: <=600 lines per `.rs` file (200-400 is the sweet spot; smaller only when natural), <=20 `.rs` files per folder, <=16 subfolders per folder, <=5 subfolder levels below `src`/`tests`, per-function cognitive complexity <=25 (clippy.toml). Markdown is exempt. Split BEFORE the gate forces you to.
- Comments capture WHY / a non-obvious constraint, not what the code already says.
- **Commit messages follow Conventional Commits**: `type(scope): subject` — types in use: `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `chore`, `style`; `!` after the type/scope marks a BREAKING surface change (pairs with the §7 version policy: pre-1.0, breaking ⇒ MINOR). Subject in the imperative mood; the body says what changed and why in plain engineering terms; messages are pure ASCII.
- `Cargo.toml` lints: `unsafe_code = "deny"`, `missing_debug_implementations = "warn"`, clippy `all = warn` with a few pedantic allows. Deps pinned by `^`-range + `Cargo.lock`.
- Output discipline: errors -> stderr (`csift: error:` + full chain); data -> stdout; `--format json` is the machine contract (one object per line where it makes sense).
- ASCII-only source/docs unless a test fixture intentionally exercises multi-byte (then keep it neutral: emoji / accented Latin). **In a `.rs` COMMENT a non-ASCII character is a QUOTATION of csift's own rendering and nothing else**: the glyph exactly as csift prints it, inside a backtick span - `` `parent ⇨ self` ``, `` `SUBAGENT <hex>  ·  parent SESSION <uuid>` ``, `` `agent.tool.use ▹ agent.tool.result` ``, a lone `` `◂` ``. A glyph never writes the comment's own typography or its logic (`A → B` for "becomes", `n ≤ 3`, `≈2×`, `§6`, a `…` for "and so on", `·` between prose items, `•` bullets, a `── heading ──` rule): one character then means a quotation in one line and an arrow in the next, and nothing in the character says which, so those read `->`, `<=`, `~2x`, `section 6`, `...`, `,` or `/`, `-`, `-- heading --`. The ALLOWLIST is DERIVED, never typed, and it has TWO sources because a help page reaches the terminal from two shapes: every non-ASCII character inside a string or char literal of a non-test source file under `src/` (outside `tests/`, outside a file named `tests.rs`, and outside an inline `#[cfg(test)]` region, whose literals are test INPUTS and reach no terminal), plus every non-ASCII character in a `///` doc block on a clap `#[arg(`/`#[command(` item, which clap renders verbatim as that item's help - together exactly what csift prints (a render, a banner, a marker, an error string, a help page). The walk DECODES each literal, because a character can reach the terminal through an ESCAPE and sit nowhere in the source bytes (`\u{b7}` is one separator, `\u{2014}` one em dash, `\u{2192}` one arrow), and a decoded code point that reaches NO terminal is excluded WITH ITS REASON: the Unicode whitespace class the bash shape parser matches a command against, and the private-use stand-ins the bash masks substitute for a shielded separator and strip again before anything is printed. The em dash is one of those printed glyphs (an absent instant renders as `` `—` ``), so it sits inside a quotation like any other character and is reported in prose by the same rule. The rule governs COMMENTARY: a doc comment on a clap `#[arg(...)]`/`#[command(...)]` field is the help page's own text, printed verbatim, so there is nothing in it to quote and it is outside the rule. A line whose backticks do not BALANCE opens a span that would run to the end of the line, so a comment line with an odd count carrying any non-ASCII character is reported too: an unclosed span is not a quotation. What the gate DECIDES is the CHARACTER (allowlisted) and the SPAN (balanced), and nothing beyond those two: whether a span quotes a rendering is the reader's check, so `` `A ⇒ B` `` passes on the characters alone and what a review looks for is the PRINTING SITE. The pre-commit hook holds the allowlist and the rule, and names the file, the line and the character it rejects. Markdown keeps the house style; string literals (deliberate output glyphs, multi-byte fixtures) are the only other non-ASCII.
- README/promotional prose: em dashes rare (single digits for the whole page; commas, colons, parentheses do the work), sentence lengths mixed, bullet skeletons varied - not every bullet a bolded lead + balanced elaboration. One punchy summary line per document, not per section.

---

## 9. What NOT to do

- **Don't introduce BM25 / embeddings / semantic search.** Regex only.
- **Don't `unwrap`/`expect` in library paths**, and **don't silently truncate** - always report the drop count.
- **Don't parse a whole 200MB file** when a head/tail read answers the question; don't lose mmap+memchr+prefilter+tail+rayon.
- **Don't add a second `unsafe` allow** beyond the audited mmap site; don't switch `deny` to `forbid` (it would refuse to compile mmap).
- **Don't write anything but the csift-channel sidecar** (§4 writers law) - not a transcript, not the team mailbox, not the messaging socket, not the session registry, not a settings file, not the harness task list. `send`/`deliver`/`ack` are the only writers and `<session-sidecar-dir>/csift-channel/` is the only place; csift never installs its own hook (`deliver --recipe` prints the block, the user pastes it) and never performs an official send (it prints the call).
- **Don't route by a teammate NAME where an id exists.** The routing form `Name@Team` CAN collide (two same-named teammates in one team share it) and the transcript form `a<Name>-<16 hex>` never does, so csift keys targeting, lane files and the envelope's `to` on the TRANSCRIPT form and keeps the routing form beside it for the official call only. A surface that prints a teammate prints BOTH.
- **Don't trust most-recent-mtime for `whoami`**; don't guess when `$CLAUDE_CODE_SESSION_ID` is absent.
- **Don't reverse a path encoding** (lossy); don't assume a workflow subagent's env `$CLAUDE_CODE_SESSION_ID` is its own id (it is the parent's).
- **Don't blindly trust this doc's field list** - real jsonl evolves; re-verify against `~/.claude/projects` and EXTEND the model tolerantly rather than tightening it into a crash.
- **Don't bump a dependency major** without an explicit reason.
- **Don't edit `.git/hooks/pre-commit` directly** - edit `.cargo-husky/hooks/pre-commit` and re-run `cargo test`.
- **Don't add semantic/numeric exit codes** - 0 / non-zero only, full chain to stderr; the single exception is `wait`'s documented timeout code 124, never to be extended.
- **Don't ship a SURFACE change (a flag, output, JSON field, or `--help` text) without bumping the version triple + tagging + a CHANGELOG.md entry** - `Cargo.toml` ≡ `SKILL.md` surface header ≡ `csift --version` must move together, every release is tagged `vX.Y.Z`, and the version's CHANGELOG.md digest entry rides the release commit (§7 versioning discipline). A changed binary under an unchanged version defeats the SKILL's staleness guard.
- **Don't move the README's "verified against Claude Code" badge without the ledger audit** - `INTROSPECTION.json` must carry a per-claim check at that version first (§7), written claim by claim with the instrument that ran; never bulk-replace the check records, never edit a check's `observed` to make the gate pass.
- **Don't edit `CLAUDE.md`** - it's a symlink; edit `AGENTS.md`.
```
