Refuse an append the record moved under, so a fenced run loses nothing - #70
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 13b of the fifteen
SPLIT_PLAN.mdon #48, the second half of the plan's PR 13. Branches frommainafter #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.tspinned it as the last one.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.How SQLite promises it
One statement, because the assertion and the seq are the same fact:
The insert asserts the record's last seq and takes the next one. A writer the record moved under inserts nothing.
Sql.runreports 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
readSinceturned out to beThe plan paired
appendAfterwithreadSince, "a read from a cursor that sees every writer, which SQLite indexes". The SQLite storage'sfindEntriesalready 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.tsnow runs the one split scenario on both storages and holds each to what it promises:memory, the paused host loses the write it held — the two violations the fence allows, unchanged;sqlite,violations()is empty and the held delivery isfail, notok.Two test wrappers had to learn the new append, and that is worth a reviewer's eye:
gatedOpenerandtappedOpenerproxyappendCustomEntry, 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 takeappendAftertoo, and only when the session actually has it — a proxy that offers a method the target lacks makesfenced()say yes and the call explode.What this closes
planning/backlog.mditem 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 checkis green, 421 tests.pnpm chaosis green, 850 tests.🤖 Generated with Claude Code
https://claude.ai/code/session_01SJjjR3S6iwqEbDXsRDJgiq
Generated by Claude Code