Skip to content

fix(tools): key read tracking canonically and record the version a write wrote (#524, #528) - #539

Merged
atlas-from-plumb merged 6 commits into
mainfrom
atlas/fix-524-528-read-write-records
Sep 30, 2026
Merged

atlas-from-plumb merged 6 commits into
mainfrom
atlas/fix-524-528-read-write-records

Conversation

@atlas-from-plumb

@atlas-from-plumb atlas-from-plumb commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #524
Fixes #528

Two holes in the session's read record, the state that the "changed since you read it" guard, strict mode and the same-mtime expected_mtime check rely on.

#524: a read recorded under one spelling was invisible to a write through another

Defect. Suppose a file is read through one spelling, a peer then changes it, and the session writes it through another spelling. The spellings can differ by a symlinked parent, by macOS /tmp versus /private/tmp, or by case on a case-insensitive volume. write_file's default guard let the overwrite through and discarded the peer's change. Strict mode refused the same flow with "has not been read".

Cause. ReadTracker keyed entries on filepath.Clean of the path as spelled. The write lock, WriteTracker and the undo store key on lockPathKey, which resolves symlinks and folds case where the volume folds it. So at the write the lookup missed, and changedSinceSessionRead reads "never read" as "not stale". The persisted read_tracking.path rows had the same spelling dependence.

Fix. Record, Mtime, recorded, Hydrate and the persist sink all key on lockPathKey. lockPathKey touches the filesystem, so it is resolved outside the tracker lock. Hydrate re-keys every row, so rows persisted by an older daemon under spelled paths come back canonical. When two spellings of one file collide, the later read wins whatever order the store returns them in: that is the newest version the session is known to have seen. internal/cli needs no production change, because both rehydration paths (rehydrateReads, rehydrateReadsForAgent) and the shard seeding go through Hydrate. Its only change is one new test file.

#528: after a write, plumb recorded whatever the path held by then

Defect. If an outside process writes the file between plumb's rename and the post-write bookkeeping, its content is recorded as this session's version. The session's next write then passes both guards over a change it never saw.

Cause. WriteDeps.recordWritten ran stat and hashed the path again after the write. The per-path lock excludes only plumb's own writers. WriteTracker.Record also ran stat on the path, so read_file's concurrent-edit note and file_status's last writer shared the hole.

Fix. safeWrite now returns writeResult.written, built by a new stagedSnapshot. It holds the SHA-256 of the bytes written and the mtime from an fstat of the staged temp file, taken after its last write and fsync and before the rename. A rename moves the inode with its mtime, so this is the target's version at the moment the rename lands. An fstat failure fails the write before the rename. recordWritten(ctx, path, v fileSnapshot) records v into both trackers and no longer touches the path.

Every caller passes the version it wrote:

  • write_file
  • edit_file, including apply_partial
  • find_replace
  • copy_file
  • transaction_apply, including its verified fail_on_new_errors rollback
  • rename_symbol and move_symbol, through their plans
  • the symbol-edit tools
  • undo_edit
  • the fail_on_new_errors revert

Where plumb holds no bytes: rename_file. It moves an inode, so before the rename it takes a snapshot of the source through one descriptor (readSnapshot) and records that version. A later change at the destination differs from it, so the guards refuse rather than trust it. If the source can't be read, the version is unknown. recordWritten then records the write but invents no read state: a zero-mtime record would make every later write look stale.

Verification

  • Regression tests first, red on origin/main for the stated reason:
  • Positive controls, green:
    • The session's own writes alternating spellings are never flagged. This test was also red on main, which refused the write through the alias.
    • Strict mode accepts a read made through an alias and still refuses after a peer change.
    • The own-write chain write → edit (strict, no re-read) → transaction → rename → overwrite records exactly the on-disk version at each step.
    • An unreadable-source rename records the write but no read state.
    • read_file's concurrent-edit note still fires after an outsider.
  • CI-faithful run: GOWORK=off GOTMPDIR=$PWD/.testcache go test ./internal/tools/... ./internal/cli/... ./internal/sessionstate/... ./internal/paths/... -count=1 passes.
  • Race: go test -race over the affected internal/tools tests, and the Persist|Shard|Repin|LogicalAgent tests in internal/cli, passes.
  • Lint and checks: golangci-lint run ./internal/tools/... ./internal/cli/... (v2.13.2) reports 0 issues. make check-size check-brief check-changelog passes.
  • Mutation testing: 16 of 16 hand-applied mutants were KILLED. Each mutant compiled, the focused test set passed first on the unmutated tree, and the tree was restored and confirmed clean afterwards. Plumb's mutation_test was refused for over 30 minutes because another agent's run held the daemon's mutation slot, so these were applied by a script on the committed tree.

Review round 1 (folded in)

  • B1 (recordWritten re-reads the path after a write; an outside writer's content is recorded as the session's #528, blocking): edit_file's reply mtime.
    • The mtime: line was a fresh os.Stat of the path, taken after the post-write diagnostics wait.
    • An outside write in that wait gave the agent an expected_mtime that let its next write clobber the change.
    • The reply and apply_partial's header now print writeResult.written, the version that was also recorded. apply_partial's header is now rendered after its post-write pipeline, as the ordinary reply is, so one test pins both. Output order is unchanged.
    • No other tool's reply prints a post-write mtime or SHA. The remaining current-mtime lines are in refusals and file_status, which report the file's present state by design.
    • Tests: write_reply_version_test.go. An outsider lands before the reply, the reply shows plumb's mtime, and a write guarded by it is refused, for both replies and both outsider modes. The positive control checks the reply mtime round-trips and a peer write afterwards is refused.
  • N1 (Read tracking keys on the spelled path; an alias spelling makes the 'changed since you read it' guard fail open #524): the old-row tie-break in Hydrate.
    • A row stored under its canonical key now outranks a spelled row, which only an older daemon writes. File mtime decides only between two rows of one kind.
    • cp -p and rsync -t move the mtime backwards, and the stale row used to win.
    • Test: TestReadTracker_HydrateCanonicalRowOutranksSpelledRow.
  • N2 (recordWritten re-reads the path after a write; an outside writer's content is recorded as the session's #528): when the staged file's mtime is read.
    • stagedSnapshot now calls Lstat on the staged name after Close and before the rename, instead of fstat on the open descriptor. Some filesystems (SMB, drvfs) stamp the mtime at close.
  • Before and after:
    • The two new regression tests were red against the pre-review code, in the edit_file mtime-advances and hydrate cases. The apply_partial case was not red then, because its header was rendered before any hook could run; that is why the header now renders later.
    • After the fixes, the CI-faithful go test over tools, cli, sessionstate and paths passes, as do -race, the integration-tagged tools tests, lint (0 issues) and make check-size check-brief check-changelog.
  • Mutation testing with plumb's mutation_test: 6 of 6 mutants were killed.
    • B1: edit_file reply re-stats the path, and apply_partial header re-stats the path. The header mutant first SURVIVED, which led to the render-order change, and was then killed.
    • N1: the canonical rank is dropped, and the last row wins among rows of one kind.
    • N2: the staged mtime is off by 1 ns.

🤖 Generated with Claude Code

atlas-from-plumb and others added 6 commits October 1, 2026 04:22
ReadTracker keyed a read on filepath.Clean of the path as spelled, while
the write lock, WriteTracker and undo store key on lockPathKey (symlinks
resolved, case folded where the volume folds case). A file read through
one spelling (a symlinked parent, macOS /tmp vs /private/tmp, a case
variant) and written through another had no read record at the write, so
changedSinceSessionRead failed open over a peer's change and strict mode
failed closed ("has not been read").

Record, Mtime, recorded, Hydrate and the persist sink now all use
lockPathKey, so every consumer — the write guards, strict mode,
edit_file's not-found diagnosis, and the persisted read_tracking rows —
agrees on one key. Rows written by an older daemon under spelled keys are
re-keyed on Hydrate; when two spellings of one file collide, the later
read wins regardless of store order. Key resolution runs outside the
tracker lock because it touches the filesystem. No internal/cli
production change is needed: both rehydration paths go through Hydrate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison
…th (#528)

After a successful write, WriteDeps.recordWritten stat'ed the path and
hashed it again to refresh the session's read record. The per-path lock
excludes only plumb's own writers, so an outside process that wrote the
file between plumb's rename and that hash had its content recorded as
this session's version. The session's next write then passed both
"changed since you read it" and the same-mtime expected_mtime check over
a change it never saw — the write-side twin of the read race fixed in
#520. The write tracker's mtime (read_file's concurrent-edit note,
file_status's last writer) had the same hole.

safeWrite now returns the published version in writeResult.written: the
SHA-256 of the bytes it wrote and the mtime from an fstat of the staged
file after its last write and fsync. The rename moves that inode, mtime
included, so this is the target's version when the rename lands.
recordWritten takes that snapshot and records it into both trackers;
it no longer touches the path. Every caller passes what it wrote:
write_file, edit_file (incl. apply_partial), find_replace, copy_file,
transaction_apply (and its verified rollback), rename_symbol and
move_symbol (via their plans), the symbol-edit tools, undo_edit, and the
fail_on_new_errors revert.

rename_file writes no bytes, so it snapshots the source through one
descriptor (readSnapshot) before the rename and records that: any later
change at the destination differs from it, so the guards refuse rather
than trust it. If the source cannot be read, the version is unknown and
recordWritten records the write but no read state, instead of guessing.
An fstat failure on the staged file fails the write before the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison
…rename (#528)

The #528 tests pinned only the read record. Add the write tracker's half
(read_file must still warn that the file changed after plumb's write when
an outsider lands in the window), and the unreadable-source rename: the
move is recorded as a write but no read state is invented for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: giant-bison
…ted rows by keying, stat the closed temp

B1 (#528, blocking): edit_file's reply printed `mtime:` from a fresh
os.Stat of the path, taken after the post-write diagnostics wait (seconds
with await_diagnostics). An outside write in that wait handed the agent
the outsider's mtime; passed back as expected_mtime it matched the file,
and changedAtSameMtime could not second-guess it because the recorded
read sits at plumb's mtime, so the next write clobbered the change. The
reply, and apply_partial's header, now print the version plumb wrote
(writeResult.written) — the same one recordWritten recorded. No other
write tool's reply prints a post-write mtime or sha; the remaining
current-mtime lines are in refusals and file_status, which report the
file's present state by design.

N1 (#524): Hydrate chose between an older daemon's spelled row and a
canonical row by file mtime. mtime-preserving tools (cp -p, rsync -t)
move mtime backwards, so the stale spelled read could win and the guard
then refused a file the session had read, on every restart. A row stored
under its canonical key now outranks a spelled one (only an older daemon
writes those); file mtime still decides between two rows of one kind.

N2 (#528): stagedSnapshot took the mtime by fstat on the still-open temp
file. Filesystems that stamp mtime at close (SMB, WSL drvfs) would record
an mtime trailing the published one and refuse the session's own next
write. It now Lstats the staged name after Close and before the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: wild-vista
…ne (#528)

mutation_test showed the apply_partial half of the reply fix unpinned:
reverting its header to a re-stat of the path survived, because the
header was rendered before the first post-write hook, so no test could
land an outside write where the old re-stat would see it. Render the
header after the post-write pipeline, as edit_file's ordinary reply is;
output order is unchanged. Both replies now face the same window, and
TestEditFile_ReplyMtimeIsTheWrittenVersion pins both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: wild-vista

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two independent review rounds. Round 1's blocker (edit_file's reply mtime re-read the path) is fixed, and round 2 found nothing blocking. Every fix is proven by mutation.

@atlas-from-plumb
atlas-from-plumb enabled auto-merge (rebase) September 30, 2026 21:25

@golimpio golimpio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approve after updating with main (#541).

@atlas-from-plumb
atlas-from-plumb merged commit a760bb3 into main Sep 30, 2026
9 checks passed
atlas-from-plumb added a commit that referenced this pull request Sep 30, 2026
…ted rows by keying, stat the closed temp

B1 (#528, blocking): edit_file's reply printed `mtime:` from a fresh
os.Stat of the path, taken after the post-write diagnostics wait (seconds
with await_diagnostics). An outside write in that wait handed the agent
the outsider's mtime; passed back as expected_mtime it matched the file,
and changedAtSameMtime could not second-guess it because the recorded
read sits at plumb's mtime, so the next write clobbered the change. The
reply, and apply_partial's header, now print the version plumb wrote
(writeResult.written) — the same one recordWritten recorded. No other
write tool's reply prints a post-write mtime or sha; the remaining
current-mtime lines are in refusals and file_status, which report the
file's present state by design.

N1 (#524): Hydrate chose between an older daemon's spelled row and a
canonical row by file mtime. mtime-preserving tools (cp -p, rsync -t)
move mtime backwards, so the stale spelled read could win and the guard
then refused a file the session had read, on every restart. A row stored
under its canonical key now outranks a spelled one (only an older daemon
writes those); file mtime still decides between two rows of one kind.

N2 (#528): stagedSnapshot took the mtime by fstat on the still-open temp
file. Filesystems that stamp mtime at close (SMB, WSL drvfs) would record
an mtime trailing the published one and refuse the session's own next
write. It now Lstats the staged name after Close and before the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumb-Session: wild-vista
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants