Skip to content

Refuse an append the record moved under, so a fenced run loses nothing - #70

Merged
andreisavu merged 2 commits into
mainfrom
claude/pr13b-append
Sep 11, 2026
Merged

Refuse an append the record moved under, so a fenced run loses nothing#70
andreisavu merged 2 commits into
mainfrom
claude/pr13b-append

Conversation

@andreisavu

Copy link
Copy Markdown
Contributor

PR 13b of the fifteen SPLIT_PLAN.md on #48, the second half of the plan's PR 13. Branches from main after #69. One commit.

The one idea

A run paused with a write in flight comes back to a name another run has taken. Its write lands past the fence, void, and the run acknowledges it. That was the one loss the fence allowed, and split.test.ts pinned it as the last one.

  • A session may offer appendAfter. The log hands it the position its read left: the entry lands next to that position, or the storage writes nothing and says the record moved. A run fenced while its write waited is refused before it acknowledges.
  • The refusal is definite, not a doubt. Nothing landed, so a client may deliver again under the same key. The history checker holds a refused delivery to the record zero times, which is stricter than the "at most once" it allows a write in doubt.
  • A storage that cannot promise it does not offer it, and the log appends the way it always did. The fence still voids what such a storage takes.

How SQLite promises it

One statement, because the assertion and the seq are the same fact:

INSERT INTO entries (...) SELECT ?, ?, ...
WHERE (SELECT COALESCE(MAX(seq), 0) FROM entries WHERE session = ?) = ?

The insert asserts the record's last seq and takes the next one. A writer the record moved under inserts nothing. Sql.run reports no rows, so a read back says which happened — and that is why the entry's id is its own and never the position. Two runs that expect one seq derive one id, and the read back could not then tell one run's entry from the other's. That bug was live in my first draft: the paused host's refused write "succeeded", because the read back found the other run's entry sitting at the id it had derived.

What readSince turned out to be

The plan paired appendAfter with readSince, "a read from a cursor that sees every writer, which SQLite indexes". The SQLite storage's findEntries already is that: WHERE seq > ? against the primary key. The plan's point was that Pi's repositories scan, and that is a cost, not a correctness gap. Nothing to build.

The tests that pin it

split.test.ts now runs the one split scenario on both storages and holds each to what it promises:

  • on memory, the paused host loses the write it held — the two violations the fence allows, unchanged;
  • on sqlite, violations() is empty and the held delivery is fail, not ok.

Two test wrappers had to learn the new append, and that is worth a reviewer's eye: gatedOpener and tappedOpener proxy appendCustomEntry, so a SQLite write would have slipped past the gate that makes the split, and past the tap that counts writes for the crash sweep. Both now take appendAfter too, and only when the session actually has it — a proxy that offers a method the target lacks makes fenced() say yes and the call explode.

What this closes

planning/backlog.md item 35, fully. What still stands is recorded there: a storage that cannot refuse still loses the one held write, and Pi's JSONL storage reads its own memory so the fence does not reach it at all.

Checks

pnpm format && pnpm check is green, 421 tests. pnpm chaos is green, 850 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SJjjR3S6iwqEbDXsRDJgiq


Generated by Claude Code

A run paused with a write in flight came back to a name another run had
taken. Its write landed past the fence, void, and the run acknowledged
it. That was the one loss the fence allowed, and `split.test.ts` pinned
it as the last one.

A session may now offer `appendAfter`. The log hands it the position its
read left: the entry lands next to that position, or the storage writes
nothing and says the record moved. A run fenced while its write waited is
refused before it acknowledges, so the write it held is no loss. The
refusal is definite, not a doubt: nothing landed, so a client may deliver
again under the same key, and the history checker holds a refused
delivery to the record zero times.

The SQLite storage offers it in one statement. The insert asserts the
record's last seq and takes the next one, so the assertion and the seq
are one fact, and a writer the record moved under inserts nothing. The
entry's id is its own, and never the position: two runs that expect one
seq derive one id, and the read back that says which happened could not
then tell one run's entry from the other's.

A storage that cannot promise the refusal does not offer it, and the log
appends the way it always did. The fence still voids what such a storage
takes.

`split.test.ts` runs the one split on both storages and holds each to
what it promises: memory loses the write it held, SQLite loses nothing.
Backlog item 35 closes, and `docs/agent.md` §5 and `docs/durability.md`
§5 say which storage promises what.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJjjR3S6iwqEbDXsRDJgiq
`FencedSession` names what a session offers when its storage can refuse
an append the record moved under. The core asked for it and never handed
it out, so a host writing a storage of its own could not implement it.
It is exported beside `SessionOpener`, which every such host already
reaches for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJjjR3S6iwqEbDXsRDJgiq
@andreisavu
andreisavu merged commit a7419b2 into main Sep 11, 2026
7 checks passed
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.

2 participants