Skip to content

Entry-write retirement: the tape is the only session log - #995

Open
ReganBell wants to merge 11 commits into
mainfrom
entry-write-retirement-up
Open

Entry-write retirement: the tape is the only session log#995
ReganBell wants to merge 11 commits into
mainfrom
entry-write-retirement-up

Conversation

@ReganBell

@ReganBell ReganBell commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

The final step of the tape migration: turns stop writing session_entries. The tape (messages + stamped meta + mirrors) is the single write path; model context, rendering, search, forks, pins, and deliveries all read tape projections. The entries table becomes a frozen archive (reads only via explicit archive paths); its export + DROP is a later, separate step.

What this deletes: the read-time coverage heal, the replay.ts reconstruction fallback for model context, renderer/search/pins entries fallbacks, entries-vs-tape coverage cross-accounting, and the entries search-index writes. Fork seeding copies tape rows natively. clearSecurityTaint gains its tape-side counterpart (columns and mirror payloads on session_tape, in both stores). Outside-turn transcript writes (web deliveries, failure records) go through a shared appendEntryOutsideTurn that allocates seqs against the projection and stamps coverage. Also deliberately removed: the replay preamble — a session whose history cannot be reconstructed cold-starts with no synthetic context rather than a lossy preamble; that is intended, not an oversight.

Design decisions baked in from review

Fork-race fixture and roster-change aborts. The fork-race test fixture hooked the retired sessions.append, so it wedged once forks copy via appendTape/appendRenderImport; it now hooks appendTape on the first write to a session that is neither the source nor the known prior fork. Un-wedging it exposed a real masked regression: a turn aborted mid-flight by a project roster change returned refused without closing its tape span, leaving the trigger message permanently invisible (only bounded turns render). The terminal-failure recorder is extracted as recordTurnFailure and runs on both ProjectRosterChanged exits — but only when the aborted turn actually appended tape rows, so a turn-start roster refusal leaves the tape byte-identical (asserted by the quarantine roster-epoch test).

User-message loss on the terminal-failure path. The failure back-fill clear must not live at emit time: emit is a pure in-memory allocation, so a terminal failure between emit and the durable trigger write — including the trigger tape write itself failing — would skip the back-fill and lose the user's message. The clear lives in the turn's tape callback on the two durable user carriers: the bareText message branch, and annotation mirrors of non-overheard user entries (mirrorsUserEntry, shared in session-store and reused by both stores). Tests cover the window; the trigger-tape-write-fails-terminally test fails against an emit-side clear.

Archive-behind sessions: dedupe hardening + a hard sequencing gate, not an inline heal. Guarding appendEntryOutsideTurn with a heal would re-run the render-import machinery inside a delivery callback — heavy writes under a backfill lease, duplicating scripts/lib/tape-retirement.ts — for a state the deploy sequencing gate exists to make unreachable. What is enforced in code forever is that durable records never double-write:

  • The delivery dedupe scans (web-transcript recorder, run-failure recorder) read the tape itself (tapeRecordedEntries over mirrored annotation rows) in addition to the rendered view — a written record dedupes whether or not it renders. Mixed-state tests create the archive-ahead precondition and pin one durable record across repeated drains, decorator restarts, and repeated recordRunFailureEntry calls.
  • assessRenderImport refuses a session whose tape carries rows stamped past the archive's last entry (skip reason tape-ahead, --force included), so a backfill run can never write an anchor after live tape rows and cut live turns out of rendered history.
  • The transcript source's archive probe is restructured: a projection whose covered seq reaches latestEntrySeq serves without touching the entries archive; only the ambiguous window (mid-turn tails, genuinely archive-behind tapes) pays a one-row archive probe.

On an archive-behind session, an outside-turn record is still written unservable (it dedupes but does not render) — accepted because the operator's sequencing gate makes that state unreachable in production.

Tool-error rendering survives the cutover. The SDK toolResult row deliberately carries no error flag (errors are agent inputs), so post-cutover every failed tool would render as a success and the curated extras (error, found:false, status, quarantined) would be gone. recordResult stashes an errored result's curated payload by callId on the tool context ref; pi's message_end writer stamps the toolResult row with the emitted seq and follows it with an entry-mirror annotation carrying that payload; the projection treats the mirror as authoritative for stamped rows (draft suppressed, seq-slot arithmetic exact, model-facing replay payload untouched). Old unstamped rows keep drafting; foreign harnesses already mirror every entry. Pinned by a projection test (model-facing row says success, rendered entry says isError:true, byte-equal to the legacy transcript) and an agent-tools transport test.

Also fixed along the way: the PG stamped-user-turn EXISTS saw its own just-inserted row, so the live-turns counter never moved for stamped rows (fixed + tested); first PG test for the tape-side taint-clear SQL; projectedSessionHistory logs when it falls back to the entries archive despite non-empty tape rows (the silent-stale-context state).

Reconciliation with index-only chat search (#993)

#993 moved live search indexing to the entries write path (write-through on session_entries writes) and deleted the end-of-turn syncSearchIndex. This PR removes runtime entries writes, so that write path can no longer see live turns — combined naively, new messages would never be indexed. The union keeps both designs' intent:

  • Reads stay index-only, and the store-level write-through remains for archive writes and backfills — unchanged. Search rows stay insert-only, deduped by (session, seq) — Make chat search fast and reliable using only its dedicated index #993's no-tombstone model is not changed by this PR.
  • The tape-fed syncSearchIndex returns as the live indexer. Where it runs: the turn's lease-release sites (the main turn finally, the background-run early release, and the security-screen park's own release), searchable outside-turn writes (appendEntryOutsideTurn, so a web-transcript delivery is indexed at write time), and fork creation (the copied history is searchable at fork time, not at the fork's first turn). A sync failure or an unservable projection is recorded to the error log, not swallowed. The one exit that cannot sync is a force-released lease (crashed worker / reaper sweep) — the session catches up at its next turn exit or via the backfill; live catch-up is filed as Search index catch-up for force-released sessions (worker/reaper) #1001, and fencing the test-only SessionStore.append as Retire SessionStore.append or fence it as test-only #1002.
  • Because a parked turn leaves its tape span open, syncSearchIndex additionally indexes the contiguous run of stamp-final rows — every stamped user carrier (overheard and delivery carriers included, so ambient channel traffic cannot wedge the tail) and stamped entry mirrors — that directly extends settled coverage. A gap occupied by unsettled drafts stops the run, so the index stays prefix-complete and a later settle still indexes the drafts. This preserves Make chat search fast and reliable using only its dedicated index #993's pinned contract that a message parked on an approval is searchable immediately.
  • The backfill script reads both sides — the frozen archive and the tape projection — so tape-era gaps are detectable and repairable; an unservable tape is reported loudly instead of skipped as covered.
  • The tape-index migration lands as sessions/store/0016-tape-seq-indexes-v1, leaving the shipped 0012–0015 ids and checksums untouched.

MERGE GATES — do not merge yet

  1. Operator migration sequencing. The fleet backfill (render-import of archive-only sessions) and the tape/entries parity gate run separately by operators. The backfill must be complete — with any skipped sessions individually dispositioned as dead — and the parity gate must be green on the operator's fleet before this deploys. The heal is deleted: a session that turns while archive-behind renders degraded until re-imported, and assessRenderImport refuses it afterwards (tape-ahead). A parity run started before deploy must not be read after it (post-cutover turns change what the report means).
  2. Fresh-context review. This port resolved real conflicts against post-convergence main (notably Make chat search fast and reliable using only its dedicated index #993); it needs a review pass from a context that did not produce it.

Verification

885 tests green across the affected suites (orchestrator, projects, turn-bookkeeping, session-fork, replay, revisions, tape/search/delivery/mirror suites, context, exemplars, authz, memory-provider, aws-role-cutover) plus PG store 55/55 against a real Postgres 16 (0016 migration, taint-clear SQL). The reworked backfill was smoke-verified against Postgres: a seeded coverage=-1 tape-only session reports its missing tape messages, indexes them, and re-runs as covered. Typecheck, eslint, oxlint, knip, prettier all clean.

🤖 Generated with Claude Code

ReganBell and others added 11 commits September 8, 2026 15:10
Turns stop writing session_entries. The tape (messages + stamped meta +
mirrors) is the single write path; model context, rendering, search,
forks, pins, and deliveries all read tape projections. The entries table
becomes a frozen archive with reads only via explicit archive paths.

Deleted: the read-time coverage heal, replay.ts reconstruction fallback
for model context, renderer/search/pins entries fallbacks, entries-vs-
tape coverage cross-accounting, the replayPreamble, and the entries
search-index writes. Fork seeding copies tape rows natively.
clearSecurityTaint gains its tape-side counterpart. Outside-turn
transcript writes go through a shared appendEntryOutsideTurn that
allocates seqs against the projection and stamps coverage. Errored tool
results render from a curated tape mirror so isError survives the
cutover.

Reconciled with the index-only chat search change: live turns no longer
write entries, so the write-through trigger cannot see them; the
tape-fed end-of-turn syncSearchIndex returns as the live indexer, with
index-only reads and the store-level write-through (now serving archive
writes and backfills) unchanged. The tape-index migration lands as
sessions/store/0016 to leave the shipped 0012-0015 ids untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ends

With runtime entry writes retired, the write-through path cannot index a
live turn, and a turn parked on an approval leaves its tape span open —
its trigger would stay unsearchable until the turn completed. The
end-of-turn sync moves to the lease-release sites every turn path flows
through, and syncSearchIndex additionally indexes the contiguous run of
stamp-final rows (stamped user carriers and entry mirrors) that directly
extends the settled coverage. A gap occupied by unsettled drafts stops
the run, so the index stays prefix-complete and a later settle still
indexes the drafts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…luded

The stamped-finals tail skipped overheard rows, so a trigger parked
behind ambient channel traffic sat past a permanent gap and was never
indexed until the turn settled. The tail now derives finals through
userDraft — the same carrier shapes the settled projection renders — so
overheard and delivery carriers advance the cursor and index the same
text they later render with. Channel-shaped park test added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es loud

The flagged-input park writes a tainted user mirror and a settled
turnEnd, then releases its lease on a path the main turn-exit sync never
sees — a flagged first message in a fresh session stayed permanently
unsearchable. All turn-exit sync sites now go through one helper that
records a sync failure or an unservable projection to the error log
instead of swallowing it silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A web-transcript delivery recorded outside any turn was invisible to
search until the session's next turn exit, which may never come. The
shared appendEntryOutsideTurn now runs the search sync after writing a
searchable entry type; system records (turn failures, revisions) skip
it. TranscriptAppendSessions widens to carry the two index methods the
sync needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The backfill's detector and verifier read only the frozen entries
archive, so a session whose tape carried unindexed searchable messages
reported covered and could never be repaired. The script now reads both
sides: it indexes the archive rows and the tape projection's rows
(store-level dedupe makes the union safe), verifies archive completeness
and that indexed coverage reaches the projection's last searchable seq,
and reports an unservable tape loudly instead of skipping it quietly.
Smoke-verified against Postgres: a seeded coverage=-1 tape-only session
reports '2 missing tape messages', indexes both, and re-runs as covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ojection

The memorable provider's capture mined store.getEntries at turn end, so
after the entry-write cutover it would capture nothing, silently (its
tests stub the loader). The wiring now serves the capture window from
projectedSessionHistory, which also covers pre-cutover sessions via the
archive fallback. The AWS-cutover durable-redaction assertion had gone
vacuous the same way — it now reads the projection and asserts the
transcript is non-empty before checking it for sentinels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The forked session's history was invisible to search until the fork's
first turn, whose exit sync then paid for indexing the whole copied
history. The fork path syncs while it still holds the fork lease, so the
copy is searchable the moment the fork exists. Abandoned-session
catch-up (force-released leases) and fencing SessionStore.append as
test-only are filed separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The open-span search tail required a top-level role on message rows, but
claude and opencode carriers nest it (message.role / info.role), so a
parked coarse-harness trigger was never indexed. Gate coarse rows the way
the settled projection does: a stamped entrySeq plus a bareText or
overheard meta marks a user carrier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant