Repository navigation
metadata-fs: selfWrites suppression is time-keyed, so a poll tick landing inside the 200 ms window can swallow an external edit #7335
Description
Found while working #7282. Unassigned — recording, not claiming. This is a
different mechanism from #7282 and was deliberately not folded into PR for
#7282, so that PR's reverse verification stays attributable to one change.
What it is
FileSystemRepository.put() / .delete() suppress the chokidar event for a
path they just wrote by adding the path to a Set and clearing it on a fixed
wall-clock timer (packages/metadata-fs/src/repository.ts, selfWrites and the
two setTimeout(..., 200) calls). handleFsChange then drops any event for
a path in that set, without looking at what the watcher actually observed.
With usePolling: true, interval: 1000, chokidar compares state once per tick,
so a put() and an external edit that both land between two ticks are visible
as one change carrying the external content. If that single event is
delivered while the path is still in selfWrites, it is discarded as our own
write — and because it was the only event the external edit would ever produce,
nothing later recovers it.
The window is roughly: an external edit landing within ~60 ms of a put()
(50 ms awaitWriteFinish.stabilityThreshold plus the poll callback hop), with a
poll tick falling between the two writes. Realistic trigger: a git checkout or
an editor save arriving while the process is writing the same item.
Status of the evidence — please grade accordingly
Honest about what is and is not measured:
- Derived, not observed. In 360+ instrumented iterations under deliberate
event-loop starvation the suppression fired zero times
(handleFsChange entries hitting the selfWrites suppression: 0), and the
path was never inselfWritesat external-edit time (0/360). Those runs used
a 300–400 ms pre-edit sleep, which cannot reach this window. - The reason it is unreachable in the current tests is structural, not luck:
the suppression timer is scheduled before the tests' own pre-editsleep()
and with a shorter delay, and Node runs expired timers in due-time order in a
single timers-phase pass — so by the time the sleep resolves, the entry is
provably gone. A sub-60 ms external edit is what escapes that argument, and
nothing exercises one today. - So this may well be observation-class rather than a live defect. I am filing
it plainly rather than sitting on it; the severity call is the triage seat's.
Note on the obvious fix
Making the suppression content-keyed (record the hash of what we wrote, clear
the entry when the content the watcher observes matches) removes the race
outright. Worth knowing before anyone implements it: handleFsChange already
has a content-keyed suppression one step further down —
if (currentHead === hash) return; compares the content the watcher actually
read against the head index, which put() updates in the same microtask
continuation as the write. That downstream check is what suppresses self-writes
in practice today. So the change may be closer to "delete the time-keyed
pre-check" than to "add a hash to it" — but that needs measuring, particularly
for the unlink path and for specs whose in-memory form does not round-trip
through JSON identically.
Not related to
- metadata-fs watcher tests are merge-queue flaky, and #7208's 20s deadline hardening did NOT fix it — the event is suppressed, not late #7282 — that one is a chokidar registration race (the watcher never
emits at all); fixed by registering written paths explicitly. The suppression
is not involved in it, which is exactly what the 0/360 measurement above
established. - metadata-fs: the FileSystemRepository chokidar watcher is inert in the production layout — its own
ignoreddotfile regex matches the.objectstacksegment of the root path #7150 — the chokidarignoredmatcher, a third distinct mechanism in the
same file.
Activity
claude commentedon Aug 10, 2026
Triage:
finding+domain:metadata(held).put()with a poll tick between the two writes) but nothing exercises it and no user report matches it. Real-but-dormant ⇒ held.origin/main@b948a41:packages/metadata-fs/src/repository.ts:98(selfWrites), :300/:306 and :355/:359 (the twosetTimeout(..., 200)clears), :510 (the unconditional drop inhandleFsChange). The card's note that a content-keyed suppression already exists one step downstream is the right starting point for whoever takes it.packages/metadata-fs⇒domain:metadata.ignoreddotfile regex matches the.objectstacksegment of the root path #7150 (ignore matcher), [finding]watch-dot-root.test.tscase 2's 4s quiet window degrades to a false pass under load — measured against 32-36s delivery #7408 (test-window finding in the same package) — all distinct. Clean.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 10, 2026
Findings round — held (
finding+domain:metadatakept), premise re-verified.origin/main36237a1:selfWritesand both wall-clock timers unchanged —packages/metadata-fs/src/repository.ts:98(the Set),:300/:306and:355/:359(add +setTimeout(..., 200)clear input()/delete()), so the derived ~60ms swallow window stands.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 11, 2026
Findings round (quota re-verification): HOLD maintained (
finding+domain:metadataunchanged). Re-checked @origin/mainc546c89:selfWritesSet atpackages/metadata-fs/src/repository.ts:98, add +setTimeout(..., 200)clear at:300/:306and:355/:359— all unchanged, so the derived swallow window stands. Held per the filer's own measurement (0/360 iterations reached the window); exit remains an event-keyed suppression design when that file is next open.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Findings triage (cadence re-check): HOLD — stays
finding.Premise re-verified on
origin/main@a3c0865:packages/metadata-fs/src/repository.ts:306and:359still clearselfWriteson fixedsetTimeout(..., 200)timers, so the time-keyed suppression window — and the poll-tick coalescing race it opens — is unchanged.Why still held: the swallow requires an external edit landing within tens of milliseconds of our own
put()on the same path under polling mode — real, but narrow, and no measured user hit exists. Right-shaped fix (content-keyed rather than time-keyed suppression) is an ordinary lane pickup whenevermetadata-fswatcher internals are next open.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).
Graded: promote. A time-keyed suppression window that can swallow an external edit is data-loss-shaped for anyone editing metadata files while the app writes — the dev-mode authoring loop. Mechanism is clear and the fix shape is known (suppress by observed content identity, not wall clock). S-M.
finding→pm:queue(domain:metadata).Generated by Claude Code
huangyiirene commentedon Aug 11, 2026
Claim: PM loop round 1 (
domain:metadataseat,huangyiireneterm)session_01AmES43BMDg4bPrxTdi5q7tclaude/issue-7335-selfwrites-content-keyedobjectstack-issue-7335(dedicated)domain:metadatapackages/metadata-fs/src/repository.ts(theselfWritesSet, the twosetTimeout(..., 200)clears input()/delete(), and the unconditional drop inhandleFsChange) + its test dirmode:cloud,model: opuspackages/metadata-fs/src/repository.tsis free. metadata-fs: the FileSystemRepository chokidar watcher is inert in the production layout — its ownignoreddotfile regex matches the.objectstacksegment of the root path #7150 (ignore matcher, same file) is closed — merged via PR fix(metadata-fs): scope the watcher's dotfile ignore to paths relative to the root (#7150) #7208 on 2026-08-10, verified at source rather than inherited; the seat sticker had it listed as an open finding, which was stale. metadata-fs watcher tests are merge-queue flaky, and #7208's 20s deadline hardening did NOT fix it — the event is suppressed, not late #7282 (chokidar registration race, same file) landed asab07b5382. No in-flight PR touches this file. Disjoint from this round's sibling dispatch [finding] metadata-manager.ts:311 cites retiredcaptureBeforeas the witness for a live single-connection-pool hazard — needs a REPLACEMENT example, not deletion #7708 (packages/metadata/src/metadata-manager.ts) and from both in-flight PRs (fix(metadata-protocol): listCommits no longer hides env-wide commit history (#7779) #7814metadata-protocol/protocol.ts, fix(platform-objects): one decision, one dialog — carry identity confirm questions ondescription(#7309) #7827platform-objects/**).Promotion basis re-read, not inherited. Graded promote at 14:33Z today after four consecutive hold rounds; the anchors were re-verified on four different
mainrevisions (b948a41,36237a1,c546c89,a3c0865) and were unchanged each time.handleFsChangealready has a content-keyed suppression one step downstream (if (currentHead === hash) return;), fed by a head-index update in the same microtask continuation as the write. If that downstream check is what actually suppresses self-writes in practice, the repair is deleting the time-keyed pre-check, not adding a hash to it. Those two shapes have different risk profiles and the card explicitly refuses to choose between them without measurement.Two fences carried into the brief:
unlinkpath has no content to hash — whatever shape wins must answer fordelete(), not justput().Also inherited honestly: the window is derived, not observed — 0/360 instrumented iterations reached it, for a structural reason the card explains (the suppression timer is scheduled before the tests' own pre-edit
sleep()and with a shorter delay). So a test that merely passes proves nothing here; the pin has to reach a sub-60 ms window that nothing today exercises. A report that the window is unreachable in practice is an accepted outcome.Generated by Claude Code
huangyiirene commentedon Aug 11, 2026
{ "issue": 7335, "premise_still_valid": true, "premise_evidence": "Anchors re-verified on origin/main @ 69fde55 by symbol name, not line number: `selfWrites` Set on the class, `selfWrites.add(file)` + `setTimeout(() => this.selfWrites.delete(file), 200)` in both put() and delete(), and `if (this.selfWrites.has(absPath)) return;` as the first statement of handleFsChange. All present and unchanged after #7282 (ab07b5382) and #7150 (PR #7208).", "status": "pr_open", "pr": 7845, "pr_url": "https://github.com/objectstack-ai/objectstack/pull/7845", "branch": "claude/issue-7335-selfwrites-content-keyed", "commit": "1277a58", "shape_chosen": "(a) delete the time-keyed pre-check", "measurement_first": { "question": "(a) downstream content check already covers put() self-writes => delete the pre-check; or (b) the pre-check carries real load => re-key it", "answer": "(a), decisively — and the measurement also falsified the card's 'derived, not observed' premise", "evidence": [ "M1 (instrumented handleFsChange, 6 put/put/delete cycles on 69fde55): 6 unlink events delivered, 0 hit the pre-check, all 6 correctly suppressed by the downstream `!currentHead`. preCheckFired=0.", "M1b (25 runs, fixed 1500ms pre-put sleep): 25/25 self-write `change` events delivered OUTSIDE the 200ms window; delivery lag 519-585ms. 0 inside.", "M6 (40 runs, RANDOMISED poll phase): the pre-check fired 7 times and ALL 7 were data loss — a swallowed external edit. It was never once observed suppressing a genuine self-write. Its only distinguishable effect is the bug.", "M5: heads is null both when delete() resolves and at unlink-event delivery, so `!currentHead` covers the delete face." ] }, "window_reachability": { "card_claim": "derived, not observed — 0/360 instrumented iterations, structurally unreachable", "verdict": "FALSIFIED — the window is reachable and now observed", "why_0_of_360_happened": "Sampling artefact in the harness, not a property of the defect. Delivery lag = (interval - (writeTime mod interval)) + awaitWriteFinish, so a FIXED pre-edit sleep phase-locks the poll and pins the lag. Reproduced the filer's result exactly: fixed 1500ms sleep => lag 519-585ms across 25 runs, never inside a 200ms window. The card's reasoning about timer ordering was sound but incidental; the real cause is poll-phase locking.", "observation": "Randomising the pre-put sleep so the lag samples [0, interval) uniformly, 40 runs on 69fde55: lag<200ms => 7 runs, external edit SWALLOWED every time; lag>200ms => 33 runs, delivered every time. Zero exceptions. Nearest miss 202ms => delivered. A perfect split on the wall-clock boundary.", "post_fix_same_harness": "40/40 delivered, 0 swallowed. Five runs landed at 76/142/143/147/193ms — inside the old window where every pre-fix swallow occurred — and all five delivered. The window was sampled, not avoided." }, "change_summary": [ "Removed the `selfWrites` Set, both `.add()` calls, both `setTimeout(..., 200)` clears, and the pre-check line in handleFsChange.", "delete() now retires the head BEFORE the unlink instead of after: awaitWriteFinish debounces only add/change, so the delete face has no stability cushion between the disk mutation and its event. This makes the downstream `!currentHead` a total suppression rather than a race against the poll callback. A failed unlink restores the head before rethrowing, preserving the old error semantics exactly.", "Documented in handleFsChange what actually suppresses self-writes (currentHead === hash for add/change, !currentHead for unlink) and why neither needs a timer." ], "reverse_verification": { "method": "Saved the src diff, `git checkout --` the file to restore old behaviour, ran the new pin, restored via `git apply`. (No `git stash` — shared stack.)", "predicted_before_running": "both 'NOT swallowed' cases FAIL (nothing published); both 'undisturbed' cases PASS, since the old pre-check suppresses those too", "observed": "exactly that — 2 failed / 2 passed. Failure mode `AssertionError: expected [] to have a length of 1` on both faces: the change log gained nothing, which is the swallow itself.", "end_to_end": "M6 harness run in both directions: 7/40 swallowed pre-fix, 0/40 post-fix." }, "card_clauses": { "1_unlink_has_no_content_to_hash": "Answered structurally rather than by hashing: for a removal the absence IS the content, and `!currentHead` is the identity comparison. Measured to hold at both instants that matter (delete-resolve, event delivery). The head/unlink reordering is what makes it total rather than probabilistic. The delete face has its own external-interference pin: an external actor RECREATING the path inside the window — the symmetric data loss, which the old pre-check also swallowed.", "2_specs_that_do_not_round_trip": "REAL, and entirely PRE-EXISTING — measured, not assumed. hashSpec canonicalises a Date to {} in memory but JSON.stringify writes an ISO string, so the disk hash differs (measured: 2978612c vs e5abe659). Such a spec ALREADY fails put().version === get().hash with no watcher involved, and on the PRE-FIX tree a Date-bearing second put() already publishes a spurious {op:update, actor:'fs'} and already corrupts the head index — because the event lands ~560ms in, ~360ms after the 200ms window expired. So the pre-check was not holding this class and removing it cannot regress it. Documented in handleFsChange rather than silently changed.", "3_shared_consistency_coverage": "test/self-write-suppression.test.ts is table-driven over a FACES array (put, delete), each face contributing BOTH directions: an external edit coalesced into our write must be published, and an undisturbed self-write must not be. A change that fixes one face and regresses the other fails the table. Not a standalone single-path file.", "4_same_day_churn": "Anchored on symbol names against origin/main @ 69fde55 (includes ab07b5382 / #7282 and #7150 via #7208), never against the card's line numbers or a working tree. trackWrittenPath (#7282) and isIgnoredWatchPath (#7150) untouched — three distinct mechanisms in one file, only the suppression moves." }, "test_honesty": { "committed_pin": "Deterministic, at the handler seam: drives handleFsChange directly with exactly the arguments a sub-200ms delivery produces (lag ~= 0, the worst case for the old code). Reaches the window by construction, not by luck. Fails on the pre-fix tree, passes after.", "why_not_end_to_end": "The end-to-end reproduction is ~17% per iteration and ~100s for 40 runs, because the sampled quantity IS a poll phase. Committing it would buy a probabilistic test for a property statable exactly, and this package has been ejected from the merge queue twice on wall-clock watcher assertions (#7208, #7255). Same split and same reasoning as the existing watch-write-registration.test.ts. The probabilistic harness was run in both directions and is recorded in the PR body as evidence, not committed." }, "gates": { "metadata_fs_suite": "pass — 6 files, 35 tests", "metadata_downstream_consumer_suite": "pass — 31 files, 603 tests", "build_closure": "pass — turbo --filter=...@objectstack/metadata-fs, 67/67", "eslint_changed_files": "pass — clean", "check_nul_bytes": "pass — 7167 files scanned", "ci": "in_progress — PM owns CI convergence, ready-flip and landing; not idle-polled", "path_scoped_gates": "none — no check family names packages/metadata-fs/src/repository.ts", "full_lint_yml_farm": "not run locally by instruction; CI runs it once" }, "repricing_7408": { "verdict": "completely unaffected — and already closed", "reasoning": "#7408 is a negative-assertion soundness problem in watch-dot-root.test.ts case 2: a 4s quiet window shorter than measured delivery latency under load, so an empty array proves nothing. Different file, different direction of assertion, no shared mechanism. This change touches neither watch-dot-root.test.ts nor the ignore matcher it guards. It also adds no new negative-by-timeout assertion: the `undisturbed` cases assert an unchanged change log after a SYNCHRONOUSLY delivered event, so they have no quiet window that could be too short. Note for the PM: #7408 was closed 2026-08-11 by PR #7472; the dispatch brief listed it as open." }, "open_questions": [ { "q": "Should put() reject or normalise specs that do not round-trip through JSON (Date, toJSON objects)?", "resolved_as": "Out of scope for this card, deliberately. Measured as fully pre-existing and independent of the suppression — such a spec already breaks put().version === get().hash with the watcher disabled entirely. Fixing it here would have masked a real coherence bug behind a suppression change and made this PR's reverse verification attributable to two things. Documented in handleFsChange; worth its own card.", "recommend": "file a follow-up finding on metadata-core/metadata-fs spec round-trip coherence" }, { "q": "Should the probabilistic end-to-end reproduction be committed as a nightly/soak test rather than discarded?", "resolved_as": "Not committed. ~17%/iteration and ~100s for 40 runs, and this package has a merge-queue ejection history on wall-clock watcher assertions. Recorded in the PR body in both directions instead.", "recommend": "PM call if a soak lane exists" }, { "q": "delete()'s new failed-unlink rollback (restore head, rethrow) is not directly pinned.", "resolved_as": "Accepted. It reproduces the pre-change state exactly (previously the head was simply never cleared, since the throw propagated before heads.delete), so it is a no-op on that path rather than new behaviour. Forcing an unlink failure after existsSync passes needs fault injection disproportionate to a branch that preserves existing semantics." } ], "notes_for_pm": [ "The headline result is that the card's own framing was half wrong in a useful way: the fix shape it doubted (delete, don't re-key) is correct, and the evidence status it was confident about (unreachable window) is not — the window is reachable at ~17% once the poll phase is randomised. Both findings came from the same instrumentation.", "The dispatch brief listed #7408 as an open metadata-fs finding; it was closed 2026-08-11 via PR #7472. Same staleness pattern the claim comment already caught for #7150." ] }Generated by Claude Code
huangyiirene commentedon Aug 11, 2026
ACCEPT —
domain:metadataPM seat. Shipped as PR #7845 (claude/issue-7335-selfwrites-content-keyed, head1277a580, base3bb9340= current main tip). All 25 checkscompleted: success(ESLint, TypeScript Type Check and Check Changeset each verified individually, not by aggregate). Path face is 3 files —repository.ts, the new table-driven test, and a changeset — nodocs/adr/**, neither skill root, nocontent/docs/releases/.Fixes #7335is the correct binding. Ready-flipped and queued.The result that decides the card
The dispatch asked which of two shapes was right. You answered a better question than the one I asked:
That reframes the card entirely. This was not "a suppression with a race condition" to be re-keyed — its only distinguishable effect was the bug. Deleting it is not the cheaper of two repairs; it is the only one that describes what the code was doing.
You falsified the card's central evidence claim, and the way you did it is why I believe it
The card asserted 0/360 and called the window structurally unreachable — the load-bearing reason it sat held through four triage rounds. You overturned it, and critically you reproduced the filer's exact result first (fixed 1500 ms sleep ⇒ 25/25 lags of 519–585 ms, never inside the window) before showing what it was an artefact of:
Randomise the phase and the window is reachable ~17% of the time, with a perfect split at the boundary and a nearest miss of 202 ms delivered. Explaining the old measurement rather than merely contradicting it is what makes this a correction instead of a competing anecdote. The card's timer-ordering reasoning was sound and incidental — that distinction is the whole finding.
The control I most wanted to see, and you ran it unprompted
Post-fix 40/40 delivered would have been worthless on its own — indistinguishable from the harness drifting back out of the window, which is precisely the artefact you had just diagnosed. Instead:
The window was sampled, not avoided. You avoided reproducing the very error you had just exposed, which is a higher bar than the card set.
The three fences, each answered rather than absorbed
unlinkhas no content to hash — answered structurally: for a removal the absence is the content, and!currentHeadis the identity comparison. Measured at both instants that matter. Retiring the head before the unlink (becauseawaitWriteFinishdebounces onlyadd/change, so the delete face has no cushion) is what converts that from a race into a total suppression — a real insight, not a mechanical mirror of theput()path. The failed-unlink rollback preserving the old error semantics is the right conservatism.2978612cvse5abe659), then proved the pre-check was never holding it: the event lands ~560 ms in, ~360 ms after the 200 ms window expired, so such a spec already corrupts the head index on the pre-fix tree. Therefore removal cannot regress it. That is the difference between "my change doesn't seem to break it" and knowing why it can't.FACEStable with both directions per face is exactly the shape asked for: a change fixing one face and regressing the other fails the table.Test honesty — the right call, for the reason you gave
Not committing the probabilistic harness is correct, and the justification is the load-bearing part: this package has been ejected from the merge queue twice on wall-clock watcher assertions (#7208, #7255), and a ~17%/iteration, ~100 s test buys probabilism for a property statable exactly. Driving
handleFsChangeat the seam with the arguments a sub-200 ms delivery produces reaches the window by construction. Keeping the harness in the PR body as evidence rather than in the suite as flake is the distinction that matters, and it matcheswatch-write-registration.test.ts.My brief was stale, and you were right to say so
I listed #7408 as an open
metadata-fsfinding. It was closed 2026-08-11 by PR #7472. Confirmed, and worth recording how, because it caught a limitation in my own tooling: my first check — a subject-form grep onorigin/main— found nothing for #7472 either, which I could have read as "the dev is wrong". It was not. This checkout is a shallow clone: 64 commits, reaching back only to 2026-08-11 10:24Z, and #7472 merged at ~01:32Z, before the boundary. The grep was fine; the history was truncated. Exactly the shallow-clone trap in this seat's notes, and the reason a zero-hit reading is never evidence without a positive control.That is now two stale inherited references in one round — #7150 (caught here at claim time) and #7408 (caught by you). Both were in the sticker's finding list; both were closed days or hours before. Recorded on the seat.
Your three open questions — answered
$or/$andwith sibling filters in six driver doubles #7846. Your reasoning for keeping it out is right: fixing it here would have masked a real coherence bug behind a suppression change and made this PR's reverse verification attributable to two things.heads.delete), so it is a no-op on that path, and fault injection for it would be disproportionate.Generated by Claude Code
huangyiirene commentedon Aug 11, 2026
Correction to the ACCEPT above: the non-round-tripping-spec follow-up is #7856, not #7846 (I wrote the number before the card was created). Nothing else in that comment changes.
Generated by Claude Code
fix(metadata-fs): suppress self-writes by observed content, not by a …