Skip to content

safeWriteText's staging-name guard and jsonFileRecovery's leftover prefixes are mutually exclusive #2350

Open

Description

@easonLiangWorldedtech

Two write-side contracts in this repository currently disagree about the name of a staging file, and the disagreement is unresolvable from inside a PR that has to satisfy both.

The two contracts, as they read today

  1. The guarded-write primitive refuses any caller-supplied temp path whose basename does not match its own staging convention — src/services/file-safety/safeWriteText.ts (present on PR branches, absent at main 73931fe8be):
// Ownership convention: a caller-staged file must carry the staging name this
// library's own writers use (.new_<ts>_<rand>.tmp). An arbitrary regular file
// beside the target (e.g. credentials.json) passes every check above, yet
// renaming it onto the target publishes an unrelated file's content and deletes
// its original name. Only files created under the staging convention may be
// published through this parameter.
if (!/^.new_.*.tmp$/.test(path.basename(supplied))) {          // :405
	throw new StagingPathError(
		`Staging file must carry the staging name convention .new_*.tmp, got ${path.basename(supplied)}`,
		supplied,
	)

The behaviour is pinned by its own test: safeWriteText.spec.ts:1696 it("rejects a caller-staged file that does not carry the staging name convention"), asserting the message at :1705.

  1. Main's JSON recovery recognises leftovers only by a target-prefixed name — src/utils/jsonFileRecovery.ts:9-19:
function getTempFilePrefix(absoluteFilePath: string, kind: "new" | "bak"): string {
	return `.${path.basename(absoluteFilePath)}.${kind}_`
}

function isReplacementLeftover(absoluteFilePath: string, entry: string): boolean {
	return (
		entry.endsWith(".tmp") &&
		(entry.startsWith(getTempFilePrefix(absoluteFilePath, "new")) ||
			entry.startsWith(getTempFilePrefix(absoluteFilePath, "bak")))
	)
}

Why they are mutually exclusive for a caller-staged file

For target settings.json the two accepted shapes are:

who accepts it basename verdict
recovery only .settings.json.new_1730000000.tmp rejected by the primitive — basename does not start .new_
primitive only .new_1730000000_a1b2c3.tmp invisible to recovery — does not start .settings.json.new_

So a caller that stages a file beside the target can satisfy the primitive or be recoverable, not both. This is not theoretical: passing main's own recovery-recognised staging name through the primitive fails its guard with Staging file must carry the staging name convention .new_*.tmp, got .settings.json.new_…tmp (safeWriteText.ts:406-409), which is how 48 tests failed in a conflict resolution that tried to keep main's name for main's recovery.

The reconciliation being used in #1911, and the gap it leaves

The resolution on #1911 stages .new_<basename>_<ts>_<rand>.tmp — anchored so the primitive accepts it, and target-scoped so recovery can never unlink another target's in-flight staging file in the same directory — and extends isReplacementLeftover with a third accepted prefix derived from the target basename. Two things follow that a maintainer should decide on rather than inherit:

  1. Recovery's accepted set grows from two prefixes to three. It is pinned by tests for the new prefix plus a negative test that a bare .new_anything.tmp is still not collectable, but the boundary is now a policy choice, not an accident.
  2. Direct safeWriteText callers are still not covered. A unit that writes text through the primitive creates .new_<ts>_<rand>.tmp names that are not target-scoped, so recovery still will not garbage-collect its leftovers after a crash. Guarded writes in the same series that go through that path therefore leave files a restart cannot clean up.

Asks

No code change is proposed here; this is the record of a contract conflict measured while resolving merge conflicts, filed so the decision has one home.

Activity

  1. easonLiangWorldedtech commented on Nov 8, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Series note carried here from #1913 (comment 6717029801), because #2350 decides which primitive main
    keeps and that decision is what makes this fix land main-side or in eight lanes.

    Measured at #1913 head 8fa7d858c: platform-unit-test (windows-latest) failed with 12 failures in
    main's src/utils/__tests__/safeWriteJson.interruption.test.ts (blob c9c5993f847b… at 73931fe8), all
    expected 'WRITE_COMPLETED\n' to contain 'EXIT_AT_REPLACEMENT_RENAME' at its line 187, while ubuntu was
    green. Cause measured on pinned node v22.23.4: the spec's kill point is whole-path equality (child line
    40); a lane that grafts path safety publishes to a realpath-resolved target (fs.realpath expands an
    NTFS 8.3 alias, path.resolve preserves spelling); on a runner whose TEMP is an alias the injection
    never matches and every row in the file fails. Reproduced locally by pointing TEMP/TMP at
    C:\work\ZOO-CO~1\TMP-AL~2\LONG-D~1 (12 failed, CI's assertion verbatim) and back at the long spelling
    (12 passed).

    Consequence for the naming/decomposition decision: the fix's canonical home is main-side. Today it exists
    in two lane copies (1fb5fcc47 on #1912, f739a5fa3 on #1913) and every lane that takes the seam must
    carry it or be red on windows until main does. Merge order decides which text becomes canonical.

  2. easonLiangWorldedtech commented on Nov 8, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Requirement for the guard-core writer, recorded from the #1914 refresh merge (split unit U5-B: branch vps2f-1914-u5b @ 120cb057a9f0, builds on #1913's head 416179d17de8).

    The guard-core writer has to carry two refusals. Neither is a rival design; both are the consumer-side application of contracts already ruled on, and I am recording them here because dropping them was the alternative.

    1. Identity-based refusal on the staging path. A caller-supplied staging path is compared with the target by (dev, ino) read as bigint stats (file identities on NTFS/ReFS exceed Number.MAX_SAFE_INTEGER, so a rounded number either hides an alias or rejects a valid staging file). Only ENOENT may skip the comparison; EACCES/ELOOP/ENOENT's neighbours mean the comparison could not be made, and that is itself a refusal. Why it cannot be a log line: an alias that reaches the commit leaves the failure handler unlinking the only remaining copy of the content, so a failed write deletes the file it was meant to protect. This is Advisory lock key must be identity-backed, not realpath text: an NTFS 8.3 alias and its long form take different locks #2356's canonical-after-write identity rule applied on the consumer side.
    2. Rights-fail-closed publish on win32. A target that exists but whose DACL cannot be captured (icacls /save fails, including after writing a partial dump), or whose access rights cannot be read, must yield DaclInspectionError carrying phase and targetPath before anything is committed - not a warning beside a committed file. Publishing without knowing the target's access rights can widen who reads the file, which is the same reasoning behind the _copyTargetMode ruling in fix(task-persistence): delete under the canonical lock key (U9, #1375) #1917.

    Measurement behind the "silently lost" claim: target itself and DaclInspectionError each return 0 matching lines tree-wide at 33e9200900bb (the commit the refresh merged into), so under guard-core's shape the absence would have looked like agreement.

    Both arrive with tests that exercise the refusal paths: the alias test, the non-ENOENT reason test, the failed-save test (partial dump unlinked, no rename, one icacls attempt), and the inspection test. The inspection test injects platform: "win32" and stubs icacls, so it executes on either hosted leg rather than only on the windows one; the hosted windows leg passes safeWriteText.spec.ts. Two pre-existing tests that asserted the opposite behaviour were replaced rather than kept, and that is called out in the unit's commit message.

    Reversibility: if #2350 rules otherwise for either one, it comes out in a single commit on vps2f-1914-u5b.

  3. easonLiangWorldedtech commented on Nov 8, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    The generation map behind the safeWriteText/guardedWrite convergence (from the #1916 U8 resolution, vps2-f lane)

    safeWriteText exists in incompatible generations; guardedWrite exists in three. Which copy a branch carries is decisive for what can pair with what. Map as shipped on the stack today:

    Generation Text/json surface Who carries it
    era (2e00da1, mega-branch) retainedTarget option, ACL/DACL capture + windows publish branches, exports resolveLinkPathLockKey; json predates main's #2297 trace contract U8-era call-site cluster (ApplyPatch/Edit/SearchReplace/WriteToFile + specs, guardedWrite, dvp)
    74f5eb2 (#1914 head) cancellationGeneration lineage, backup option, no retainedTarget, no resolveLinkPathLockKey export; json carries SafeWriteJsonTraceStage + trace (what main-side taskMessages.ts consumes) #1914 as-is; #1916 final (711ada7) pinned here
    25a1ad6 (#2357 head) same lineage as 74f5 + canonicalizeForApproval (U7 containment) #2357 as-is
    U8-era guardedWrite (711ada7) GuardStaleVersionError CAS contract + create/update/edit kinds; publishes with { backup: true }; canonicalDirKey/resolveLinkPathLockKey transplanted as module-local helpers #1916; #1915 resolution copies the same adapted surface

    Pairing rule (adopted, not advisory): json + its specs and text + its specs travel as ONE generation, always. A branch may adapt a generation's consumers; it may not mix one generation's text with another's json and call the result converged.

    The experiment that proves the rule: on #1916 I built the chimera - era safeWriteText trio + 74f5-era safeWriteJson quartet - to keep the era surface the era call-site cluster expects. It passed tsc --noEmit, the full ubuntu unit legs locally, and every misc shard locally and on ubuntu CI. It failed only on platform-unit-test (windows-latest) misc shards: 15 tests in safeWriteText's windows-only branches (3 backup-stat rows, 12 interruption kill-timing rows expecting EXIT_AT_REPLACEMENT_RENAME), because the era windows ACL/publish branches re-route the fs-mock expectations the 74f5 specs encode and shift interruption kill timing. (Code QA run 42459507387, windows job 128665547657.) A reviewer looking at the diff would have attributed that red to the change; it was the pairing. Pinning json+text together at 74f5 and renaming the 43 mock-argument expectations (retainedTarget: true -> backup: true) turned the same suite green on every platform.

    Convergence consequence (#2357 review input): the convergence target is the 25a1a generation, and the sequencing is a human review call, not another push. The U8 tree is queue-proof for it: guardedWrite needs only backup/resolveLockKey/resolvePublishTarget/PostCommitDurabilityError - all present in the 25a1a generation - and its era-only helpers are already module-local, so once #2357 lands the guardedWrite import swap is mechanical. Taking 25a1ad6 into U8 before #2357 lands was rejected deliberately: it would import the U7-era containment shape mid-stack and re-open pairings already merged (U1-U5).

    Numbers stated with their bases so they can be compared: local changed-executable-line recount vs the pre-merge branch base 2e00da1 gives types 15/500, webview 401/500, extension 1190/500 on 711ada7; the CI mutation gate computes against the merge base (main, 73931fe) and its reason line reads extension 1797/500 - both over-cap, one advisory class either way.

  4. added a commit that references this issue on Nov 8, 2026
  5. easonLiangWorldedtech commented on Nov 8, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Follow-up to the four-generation table above — the CI discriminator fired on the windows leg and it refined the json row, so the table needs this addendum for anyone reviewing the convergence:

    What failed. 711ada709 (then-final #1916) failed 2 rows on the windows platform-unit misc shard with the reason line AssertionError: promise resolved "undefined" instead of rejecting at safeWriteJson.test.ts:675 (run 42461215177, job 128670729075). The same row family was already red on 74f5eb287's own windows CI — run 42448302148, job 128631851822, 3 failed rows, same file. That rules generation mixing out: json and text were pinned to the same generation there (74f5eb2), and the chimera's 15-row era-text class was gone. The defect is inside the 74f5 generation's test double: the backup-stat injection keyed on os.tmpdir()'s spelling of the leftover path while the writer stats the canonical form of the same file — on windows realpath spells them differently, so the injection missed and the write resolved instead of rejecting.

    The fix and its provenance. 025fb7d0d (#2359 head) carries safeWriteJson.ts byte-identical to 74f5eb287 (verified via git diff) with a hardened safeWriteJson.test.ts (+31/−9: injection keyed on the leftover's dev+ino instead of path spelling, and the fs.stat mock implementation restored in a finally because the OS recycles inodes). Its windows platform-unit passed this exact test against the identical implementation — run 42455872567, job 128654644966.

    What shipped. Test-only adoption across the three carriers, each named in its commit message with the copy source:

    All three now carry a byte-identical copy of that test file, and #2357/#2359 already carried it, so the whole stack holds exactly one copy. The pairing rule from the table above stands untouched — json and its specs travel as one generation; what changed is that the 74f5 json generation is now paired with its own windows-proven spec instead of the version with the path-spelling bug. A full pin bump to 025fb7d0d was rejected deliberately: it would drag #2359's safeWriteText.ts +64/−4 (and the rest of that unit's content) into ancestor branches across the unit boundary, while the json implementation is identical — test-only keeps merge order intact. The generation rows (era / 74f5eb287 / 25a1ad65f / U8-guardedWrite) and the four-generation pairing rule are unchanged.

  6. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Split plan for the 2357 head — six units, measured, not yet opened

    This issue is the plan's home rather than #1991 or #2357: #1991 is the guarded-write chain and never mentions 2357, and #2357 is the pull request under measurement. #2350 is the right home because it is the defect about the interaction between the plan's first two units — the writer's staging-name guard here (services/file-safety/safeWriteText.ts) and the recovery scan's leftover prefixes (utils/jsonFileRecovery.ts) — and that defect constrains the order below.

    The units. Built locally from the head tree onto this chain by plumbing (commit-tree + update-ref), nothing pushed, nothing opened. Boundaries were drawn on the measured import edges among the changed production files — safeWriteText ← safeWriteJson ← McpHub ← Task → ReadFileTool → indentation-reader, and jsonFileRecovery → fileLock — not by dividing the file list. Each unit's counted executable lines come from the gate's own selectFromGit against its own parent:

    # unit (ref refs/tmp/2357-cut/…) files counted lines vs its own parent verified alone
    u1 u1-writer 4b8398f453ee0a3542660935f208ce7425fe3fe1 safeWriteText.ts, its 2 specs, knip.json 448 yes: tsc exit 0, its own specs 2 files / 86 passed
    u2 u2-lockkey 5e9d5a188c2a1f3878bd7db36a95ef2f7c22912c fileLock.ts, jsonFileRecovery.ts, their 3 specs 88 yes: tsc exit 0, misc lane 3 files / 28 passed
    u3 u3-jsonwriter 473cacf7772af3287a49e88387f866c6ae93eef5 safeWriteJson.ts, its 3 specs 131 built, not yet verified
    u4 u4-mcp 72a6f5685e29d737567809a7eaaba8c8496c1991 McpHub.ts, McpHub.spec.ts 27 built, not yet verified
    u5 u5-task 3c4e158b9d943d1995948a810b4c106b992a18ad Task.ts, observationRegistry.ts, 3 specs 25 built, not yet verified
    u6 u6-read 0070f2be96a9819be7293034d002ae03a4b67fa4 ReadFileTool.ts, indentation-reader.ts, 3 specs 51 built, not yet verified

    The sum is 770, which is what the whole head reports from 5319fe91f, so nothing is double-counted or dropped. The disproved candidate — the whole head as one unit — is kept as a ref (refs/tmp/u5a-2357-unit-head = d6fa7117e07b70455da24e4722b47eb0b202fa6e), because 770 against a 500 cap is the fact that motivated the split.

    Merge order, and the constraint #2350 imposes. Quoting both files rather than paraphrasing the defect. Recovery recognizes a leftover by getTempFilePrefix, defined at utils/jsonFileRecovery.ts:9-11 as `.${path.basename(absoluteFilePath)}.${kind}_` and applied at :13-19 (entry.endsWith(".tmp") && (startsWith(…"new") || startsWith(…"bak"))), scanning the target's own directory at :33/:43 and under the lock at :155-171. The writer in this unit stages a different spelling: _tempName at services/file-safety/safeWriteText.ts:365-367 produces "." + prefix + "_" + Date.now() + "_" + rand + ".tmp" — no target basename — and :369-373 puts it inside a private per-write staging sub-directory, not beside the target. So the two spells are mutually exclusive in both respects: the name and the directory. That is #2350, restated with anchors.

    Whether the split can land that state depends on wiring, and the wiring is measurable: at this head there are two mentions of safeWriteText outside its own file and its specs — utils/fileLock.ts:53 (a comment pointing at resolveLockKey) and utils/safeWriteJson.ts:6 (import { resolveLockKey, resolvePublishTarget } from "../services/file-safety/safeWriteText") — and zero at the merge base. No production code in this diff publishes through safeWriteText, and safeWriteJson still stages its own leftovers with the recovery-visible spelling (safeWriteJson.ts:97-102, applied at :371 and :420), which is textually identical at the merge base and at this head. Therefore:

    • u1 and u2 may merge in either order relative to each other. u1 alone adds a writer nothing calls yet, so it creates no leftover for recovery to miss; u2 alone changes the lock and the recovery scan while the writer's staging spelling and the prefix scan stay identical to base, so it changes nothing that recovery can or cannot see.
    • u3 must merge after u1, because safeWriteJson.ts:6 imports resolveLockKey and resolvePublishTarget from it; u3 also must merge after u2, because it calls recoverInterruptedReplacement and locks through acquireFileLock/resolveLockKey semantics that u2 defines.
    • The safeWriteText's staging-name guard and jsonFileRecovery's leftover prefixes are mutually exclusive #2350 reconciliation is not carried by u1 or u2, and the split must not be read as fixing it: it stays latent through the whole chain because no unit in this plan publishes through safeWriteText. It has to land with whichever change first publishes through safeWriteText, by teaching recovery that staging spelling and sub-directory or by making the writer stage with the recovery-visible prefix beside the target. Saying the split preserves behaviour is only true on that condition, and the condition is stated here rather than assumed.
    • The canonical-keying fix measured for the alias-versus-referent row — the recovery scanner deriving its leftover directory from the canonical key instead of path.resolve's parent — costs +2 counted executable lines (86 to 88 in u2's terms; 770 to 772 on the whole head) and belongs to u2 at 88, where it has room against the 500 cap. It is not free at the later unit that also carries this file (1,283 to 1,285).

    One thing deliberately unassigned: src/eslint-suppressions.json, the 26th file in the diff. Its per-file counts belong to files owned by several units, so it is partitioned at split time and not dumped on one unit.

    Verification disclosure, because a mapped typecheck means nothing without it. The typechecks above ran in a worktree whose src/node_modules is a junction farm resolving through exactly one root, C:/work/Zoo-Code-fork/wt-r119-2235/node_modules, with 135 distinct packages. Of the units verified so far, u1 and u2 import no @roo-code package at all (u1's own files import only child_process, fs, fs/promises, os, path; u2's import fs, fs/promises, os, path, proper-lockfile), so the donor junction cannot be typechecking a different copy of the unit under test and their exit 0 counts as-is. u3 is in the same position (json-stream-stringify, stream, url, module, and the node builtins). u4 (@roo-code/types), u5 (@roo-code/cloud, @roo-code/telemetry, @roo-code/types, @roo-code/types/provider-identifiers) and u6 (@roo-code/types) do import workspace packages, so their verification has to run with the overlay that points @roo-code into the unit under test; a plain junction would type them against the donor, and an exit 0 from that would be the 25-phantom-error failure mode rather than a result.

    Nothing opens before this plan is agreed here. Pointer comments pointing to this issue have been left on #2357 and #2359.

  7. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Named obligation, unit u3, test-side only: vi.unmock("proper-lockfile") in src/utils/__tests__/safeWriteJson.test.ts is hoisted

    Posting it here so it cannot be lost in a lane queue: another lane inherits u3, and a queue entry in someone's scratch notes is not a durable home.

    The row. src/utils/__tests__/safeWriteJson.test.ts:404 calls vi.unmock("proper-lockfile") // Ensure the mock is removed after this test, inside the test that starts at :384 (vi.resetModules(), then vi.doMock("proper-lockfile", …) at :392, then the dynamic re-import at :398). Vitest says so itself, verbatim, during the pinned run of u3 (node v22.23.4, node_modules/vitest/vitest.mjs run --config vitest.misc.config.ts --no-file-parallelism utils/__tests__/safeWriteJson.test.ts utils/__tests__/safeWriteJson.interruption.test.ts utils/__tests__/safeWriteJson.lockKey.spec.ts, exit 0, 3 files / 69 passed | 4 skipped):

    Warning: A vi.unmock("proper-lockfile") call in …/src/utils/__tests__/safeWriteJson.test.ts is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.

    Why it is latent rather than broken. Hoisting means the call runs at module evaluation, before the vi.doMock at :392, so it currently undoes nothing: the test still gets the mocked module through :384 vi.resetModules() plus its own await import("../safeWriteJson") at :398, and the leak the comment intended to prevent is handled by the other tests' own vi.resetModules() at :887, :1136 and :1157. That is why the file passes today. The day vitest promotes the warning to an error, the file stops collecting and the whole misc lane on it fails.

    The file already contains the right shape, 55 lines later. In the compromise-lock test's finally block (:957-965) the author wrote the reason out: "Cleanup must run even when an assertion fails: a leaked mock registration or console spy changes later tests. vi.doUnmock is the non-hoisted counterpart of the vi.doMock above; vi.unmock is hoisted to the top of the file, so it would not undo this registration from here", and then does vi.doUnmock("proper-lockfile") at :963 followed by vi.resetModules() at :964. So the fix for :404 is that same pair — replace the hoisted vi.unmock with vi.doUnmock("proper-lockfile") + vi.resetModules() — and not what the warning suggests literally: moving the vi.unmock to the top level would hoist it above the vi.doMock at :392 and change what later tests in this file see, which is the opposite of what the inline comment at :404 intends.

    Cost under the gate, measured: packageForPath("src/utils/__tests__/safeWriteJson.test.ts") returns undefined (the gate's own function, scripts/stryker-diff.mjs:194), so a fix here adds 0 counted executable lines to whichever unit carries it — it is excluded as a test path, like the other 17 test entries in this diff.

    Ownership. The blob is 2995f26710ff… both at the 2357 head d6fa7117e07b70455da24e4722b47eb0b202fa6e and at refs/tmp/2357-cut/u3-jsonwriter = 473cacf7772af3287a49e88387f866c6ae93eef5 (same object id, checked by rev-parse on both), so the row travels with u3's spec file and nowhere else. It is test-side and costs no counted lines, so no owner decision is needed for it; it is not being fixed here, because u3 is not opening while this plan is unexecuted.

    How whoever takes it should verify it: re-run that same pinned command, and additionally re-run the two lock-sensitive tests it could disturb (utils/__tests__/safeWriteJson.test.ts alone with --no-file-parallelism, then the misc lane as a whole) — the point of the change is that mock registrations stop leaking between tests in one file, which is exactly the thing a single-test run cannot show.

  8. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Plan rows added by u5a-2359 (round 9), keyed to the cut that has to carry them. These are additions to the plan in 6735171826, not edits of it.

    Row 1 - u3-jsonwriter (refs/tmp/2357-cut/u3-jsonwriter at 473cacf7772a): the staged-file mode copy must fail closed before the publish. Priced +1 counted executable line, test at 0 (test paths are not charged by packageForPath). The decision is already taken and already on a pushed head: easonLiangWorldedtech/Zoo-Code vps2f-1914-u5a at 3901239982bc8fa3ad96996539b4845aaa25bb31 carries throw chmodError in _copyTargetMode plus the rewritten doc block, measured 5319fe91fb...390123998 = 1,284 where the same base against the previous head was 1,283. The cut inherits the decision, not the argument. Two facts the taker needs: src/utils/safeWriteJson.ts is the same blob f4edc7ee9c8c168da844730742a54f682bd00a70 at u3-jsonwriter, at 2357's head d6fa7117e0, and at 2359 before that commit, and u1-writer 4b8398f453ee carries a third version e54b8c51d3c34a7f1b3c7eb4b21f45c4bb9b9e90 - so the mode-copy fix exists in two open sibling PRs (2357 and 2359) in two states, and whichever merges first forces the others to drop their copy or merge with a stale function. The row that proves it is aborts before the publish when the staged file's mode cannot be set in src/utils/__tests__/safeWriteJson.test.ts (rejects, target keeps its previous content, no .new_ leftover); it goes red when the throw is removed, which I measured rather than asserted.

    Row 2 - u1-writer (refs/tmp/2357-cut/u1-writer at 4b8398f453ee): a never-observed ancestor gets its own error class, AncestorUnverifiableError. Cost intentionally left as "measure from its own merge base" - it is unprototyped and I will not put an unmeasured number in the plan. Ruling: do not reuse AncestorReplacedError (its name asserts observation-then-replacement); do not widen it with a reason field either. Export from wherever the other file-safety errors are exported and document both ancestor failures in the same block. This one row also covers the follow-up that discussion_r4509387068 waits on and the three McpHub.ts call sites at :2142, :2232, :2445 with the confineForMcpWrite declaration at :670 - named once here so four threads do not each spawn their own unit.

    Row 3 - the duplication itself, which is a plan-level fact rather than a unit's. These pull requests are siblings against main, not stacked: src/services/file-safety/safeWriteText.ts has a different blob at every head, while src/utils/fileLock.ts is identical at 2357 d6fa7117e0 and 2359 2c3f744f2 (0705f771ccac98167f71562ed418da4106b8b31d) and a different one at u1-writer (49807ea154d4), and jsonFileRecovery.ts is shared with u3-jsonwriter. Anything fixed on one sibling during review has to be enumerated against the others before the next unit is cut, or the plan ships the fix in one unit and a stale copy in another.

    Standing items, unchanged: the 770-vs-500 question on 2357 and the 1,284-vs-500 question on 2359 are both with a maintainer (split or reviewed narrow exclusion); nothing in this chain opens while this plan is unexecuted.

  9. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Row 4 for the plan (from 4509387063-thread PRRT_kwDOSK-bwM609gmS, the confined-JSON writer's lock key) — booked on u3-jsonwriter with both numbers, re-measured on this box rather than relayed.

    The shape: bind the JSON write to the lock key the caller already holds instead of re-deriving one inside the writer, via two statements plus a caller-supplied heldLockKey option that refuses rather than re-derives. Measured with the gate's own buildManifest, --unified=0, per-path diffs and sources from git show, against merge base 5319fe91fb0caf4aa4ec66aac68fa03744ebbc23 (the same base CI takes):

    u3-jsonwriter pin 473cacf7772a...473cacf7772a   667 counted executable lines (limit 500) -> buildManifest throws
    candidate fd27be57ed                              669 counted executable lines (limit 500) -> buildManifest throws
    

    The pin is already 167 lines over the limit on its own before this fix lands, and its per-file charge is safeWriteText.ts 448, fileLock.ts 62, jsonFileRecovery.ts 26, safeWriteJson.ts 131. The candidate moves only safeWriteJson.ts, 131 to 133. My loop and the gate's own sentence agree on both numbers, and the candidate commit is pinned as refs/tmp/u5a-2357-cand-heldlockkey … no: pinned as fd27be57ed (subject candidate shape for PRRT_kwDOSK-bwM609gmS: bind the JSON write to the lock key the caller holds), whose safeWriteJson.ts is blob a56d534cafb1e17026bb1a1a742691cd354803c8.

    What matters about those numbers: the fix is cheap and the unit it lands in is not. +2 against a 500 limit is nothing; +2 on a unit that already reads 667 is a rounding error on a head that needs its own decision first - the 667-vs-500 question on u3-jsonwriter (and the 770-vs-500 on 2357, 1,284-vs-500 on 2359) is a maintainer's call to split further or to grant a reviewed narrow exclusion, and no row in this plan should be executed as if it were the thing that unblocks the unit.

    Blob it carries. The pin's safeWriteJson.ts is f4edc7ee9c8c168da844730742a54f682bd00a70 - byte-identical to 2357's head d6fa7117e0, and a different blob from the d09092fab0d1 now at 2359's head 3901239982 (which carries the fail-closed mode copy) and from e54b8c51d3 at u1-writer and 7514b9242 at 1913's head. A heldLockKey patch written against the pin's copy is not the same patch as one written against 2359's: the pin's writer re-derives the key from absoluteFilePath in a three-attempt loop (:304-318), 2359's writer takes the path and resolves it once. Whoever takes this row writes it against the blob the unit actually carries, and the row above (Row 1, the mode copy) is the same situation in the other direction.

  10. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Correction to Row 4 (6739891079): one garbled clause and one missing ref. I wrote "the candidate commit is pinned as refs/tmp/u5a-2357-cand-heldlockkey … no: pinned as fd27be57ed" - drafting left in. The true statement: the candidate is fd27be57ed… exactly as u9 made it, and I have now pinned it on this box as refs/tmp/u5a-2357-cand-heldlockkey so it cannot be garbage-collected while the plan sits unexecuted. Both measured numbers in that row stand as written: 667 counted executable lines at the pin 473cacf7772a and 669 at the candidate, against merge base 5319fe91fb0caf4aa4ec66aac68fa03744ebbc23 with --unified=0, each one the gate's own buildManifest sentence rather than a hand count.

  11. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Unit note for the plan, from the 2368 ruling: the leaf-spelling helper is its own unit, and it is cheaper than the file it would otherwise live in.

    Measured on 2368's own tree with buildManifest, --unified=0, sources from git show, against merge base 5319fe91fb0caf4aa4ec66aac68fa03744ebbc23, formatted first and measured second: this PR's head 20343f818a reads 26 counted executable lines; the documentation correction alone reads 26 (+0); the shared helper src/utils/lockLeafSpelling.ts plus its call site and the new real-library row read 33 - 26 in fileLock.ts, unchanged, plus 7 in the new module. Put the same helper inside fileLock.ts and the file goes to 39, which matters only because 2357's u2-lockkey unit is where this fix would otherwise land.

    What each sibling owes if it wants the fix: one import and one call-site edit. The gate charges neither - adding the import to fileLock.ts left its count at 26, measured - so a sibling's price for this fix is its own diff's price plus zero. That is the difference between one shared fix and five copies, and it is worth naming in the same breath as the blob, because the branches do not carry the same file: fileLock.ts is 0ea669e1578c at 2368, 0705f771ccac at 2357 d6fa7117e0 and 2359 3901239982, 49807ea154d4 at u1-writer and at main (1910, 1911, 1911-prev, 1912, 1917), 6ca70045661c at 1913, 2ab8179b00cf at 1914, 9f7cad7653c3 at 2311 - six distinct blobs counting main's copy, five inside the series, counted over 12 named heads.

    Two things this unit does not claim, so nobody has to rediscover them: a leaf that does not exist yet is still spelled by the caller (there is no volume spelling to read, realpathSync.native throws ENOENT and the old behaviour stands), and a leaf that is a link whose target is gone does the same. And the Windows-only row that pins the converged case skips on the POSIX arm rather than passing - forced here it reported 14 passed and 1 skipped - so a green linux lane says nothing about it.

  12. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Four designs in one family, counted by blob rather than by file name — the sentence maintainers need before they read any of these PRs as versions of each other. Measured with git rev-parse <ref>:<path> and existence checked with git cat-file -e, over 14 named refs: main e487cd7b2a, 1910 303af45776, 1911 f2847c4298, 1912 9d420ad0c3, 1913 4132582f4d, 1914 7209a5f168, 1917 d82d63ef3e, 2311 d241d22e3, 2357 d6fa7117e0, 2359 3901239982, 2368 ff5fbc5870, plus the three pinned cuts u1-writer 4b8398f453ee, u2-lockkey 5e9d5a188c2a, u3-jsonwriter 473cacf7772a. main's copy is inside the count and is named as such.

    ref safeWriteText.ts safeWriteJson.ts jsonFileRecovery.ts fileLock.ts
    1910 2842acbeb817 e54b8c51d3c3 2350f306850c 49807ea154d4
    1911 8e185b28a9ce 632184a3c8f2 b843d182168d 49807ea154d4
    1912 cd715dcb9e57 414fd4dd8188 2350f306850c 49807ea154d4
    1913 523fa5aaf4a8 7514b924265c 2350f306850c 6ca70045661c
    1914 9701d4b3b688 740a20630b28 2350f306850c 2ab8179b00cf
    1917 0ed4a1970725 fbe8b07563aa 8db128ffecd4 49807ea154d4
    2311 (absent) 773ba57c06e0 (absent) 9f7cad7653c3
    2357 495595ebdcd2 f4edc7ee9c8c 1ad95dbdd8a9 0705f771ccac
    2359 f5acc5ea4018 d09092fab0d1 1ad95dbdd8a9 0705f771ccac
    2368 (absent) e54b8c51d3c3 2350f306850c cdd0ad1b282e
    main (absent) e54b8c51d3c3 2350f306850c 49807ea154d4
    u1-writer 495595ebdcd2 e54b8c51d3c3 2350f306850c 49807ea154d4
    u2-lockkey 495595ebdcd2 e54b8c51d3c3 1ad95dbdd8a9 0705f771ccac
    u3-jsonwriter 495595ebdcd2 f4edc7ee9c8c 1ad95dbdd8a9 0705f771ccac

    safeWriteText.ts: 8 distinct blobs across the 8 refs that carry the file at all — 1910, 1911, 1912, 1913, 1914, 1917, 2357 and 2359 each carry a different one (2842acbeb817, 8e185b28a9ce, cd715dcb9e57, 523fa5aaf4a8, 9701d4b3b688, 0ed4a1970725, 495595ebdcd2, f5acc5ea4018); main, 2311 and 2368 do not carry the file at all, and all three pinned cuts carry 2357's 495595ebdcd2 unchanged, so the cuts are re-versions of content already under review on #2357 rather than new work.

    fileLock.ts: 7 distinct blobs counting main's 49807ea154d4, 6 excluding it. main's copy is shared verbatim by 1910, 1911, 1912 and 1917 — the four PRs whose lock-key work is inside safeWriteJson instead (1911's is 632184a3c8f2, with jsonFileRecovery.ts b843d182168d). 1914 carries 2ab8179b00cf, and that is the blob 2357's u-series and 2368's identity key are built on: 0705f771ccac at 2357, 2359 and the three cuts, cdd0ad1b282e at 2368's head ff5fbc5870 (the leaf-spelling commit landed there), 6ca70045661c at 1913, 9f7cad7653c3 at 2311. Two rival fileLock designs, therefore, not two versions of one: path-identity work routed through safeWriteJson on main's untouched 49807ea154d4 (1911, 1912), and file-identity work inside fileLock.ts on 1914's 2ab8179b00cf (2357, 2359, 2368 and the u-series). A reviewer who reads them as the same change will ask the wrong questions of all four PRs.

    Where the shared leaf-spelling helper fits, restated so it is not mistaken for a sixth design: it is one module of 7 counted executable lines on 2368 at ff5fbc5870 (src/utils/lockLeafSpelling.ts), and each sibling that wants it owes one import line and one call-site edit — neither charged by the mutation gate, measured on 2368's tree with --unified=0 against merge base 5319fe91fb0caf4aa4ec66aac68fa03744ebbc23. A price measured against one blob is not the same patch against another, which is the whole reason this table exists.

  13. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Correction to comment 6740624778: my 1911 row was read off a PR number, and the head moved. This table replaces that row, and it cites commits instead of PR numbers from here on.

    src/services/file-safety/safeWriteText.ts, each id read with git rev-parse <commit>:<path> and existence with git cat-file -e, at the commit named beside it — not at a PR head, because PR heads move under a table like this one and nobody tells it:

    commit read at whose lineage safeWriteText.ts
    303af45776a1 1910 2842acbeb817
    9d420ad0c3ab 1912 cd715dcb9e57
    4132582f4de4 1913 523fa5aaf4a8
    7209a5f1681b 1914 9701d4b3b688
    3edac64036eb 2383 (branch fws/u9-1914-cut1-safe-write-file-lock, 1914's lineage) 9701d4b3b688 — same blob as 1914
    d82d63ef3e8c 1917 0ed4a1970725
    71691733c7a4 1911, retired head 8e185b28a9ce
    780966d8ae00 1911, previous head 64e3a2479d93
    5559f1f67097 1911, head it had when the correction was made 64e3a2479d93
    2e79e37ac0e7 1911's head right now (ls-remote refs/pull/1911/head and API pulls/1911 agreed at read time) e4fda9f0c777 — a third state of 1911's own file
    d6fa7117e07b 2357 495595ebdcd2
    fc2dc75ab3d1 2385 495595ebdcd2 — 2357's blob, re-versions of reviewed content
    4b8398f453ee pinned cut u1-writer 495595ebdcd2
    3901239982bc 2359 f5acc5ea4018
    e487cd7b2a49 main tip (absent) — cat-file -e fails, so main is inside the commit set and outside the blob count
    d241d22e3355 2311 (absent)
    ff5fbc58709b 2368 (absent)

    The count that survives, with its base and semantics attached: 10 distinct safeWriteText.ts blobs across the 14 of these 17 named commits that carry the file (2842acbeb817, e4fda9f0c777, 64e3a2479d93, 8e185b28a9ce, cd715dcb9e57, 523fa5aaf4a8, 9701d4b3b688, 0ed4a1970725, 495595ebdcd2, f5acc5ea4018). My published figure was "8 distinct blobs across the 8 refs that carry it" — that was measured at the commits I then had, and it is now an undercount on both axes: 1911 alone accounts for three of the ten because its head moved twice more since I read it, and 2383 and 2385 carry blobs of files 1914 and 2357 already carry. The number is only ever as good as the commit it was read at, which is the reason for this correction.

    A second error of mine in the same comment, corrected here rather than left standing. I wrote that fileLock.ts has "7 distinct blobs counting main's 49807ea154d4, 6 excluding it". Across these 17 commits it is 6 distinct blobs counting main's and 5 excluding it: 49807ea154d4 (main tip e487cd7b2a49, shared verbatim by 303af45776a1, 9d420ad0c3ab, d82d63ef3e8c, all three 1911 states and 2385's fc2dc75ab3d1), 6ca70045661c (4132582f4de4), 2ab8179b00cf (7209a5f1681b and 2383's 3edac64036eb), 9f7cad7653c3 (d241d22e3355), 0705f771ccac (d6fa7117e07b, 3901239982bc), cdd0ad1b282e (ff5fbc58709b). Off-by-one in my arithmetic, not in the ids.

    Three designs, one of them main's, and each one now names a commit. (1) main's own 49807ea154d4 at e487cd7b2a49 — and 2385 carries exactly that blob, verified at its head fc2dc75ab3d1, so the new unit does not move fileLock.ts at all; 1910, 1912, 1917 and every 1911 state carry it too, and their lock-key work lives in safeWriteJson.ts instead (1911's is 632184a3c8f2 with jsonFileRecovery.ts b843d182168d, read at the same 1911 commits above). (2) 1914's 2ab8179b00cf at 7209a5f1681b, carried unchanged by 2383's 3edac64036eb. (3) 2357 and 2359's own 0705f771ccac at d6fa7117e07b and 3901239982bc, which the pinned cuts inherit, plus 2368's leaf-spelling variant cdd0ad1b282e at ff5fbc58709b and 1913's 6ca70045661c. Four open lanes, one file, three lineages: read them as designs and the questions make sense; read them as versions of each other and they do not.

    And the mutation sentence that belongs under this table, because a blob table without it invites the wrong inference. Nobody has measured surviving mutants for any of these safeWriteText.ts blobs except the 1914 lineage's. As measured by the lead on this campaign's instrument: 1911's mutation-diff concluded success while its own report said 0 valid mutants with "the mutation run was incomplete; selected test file names were not recorded", and 2383 carries the only mutant reading of a rival blob, at 78 survivors — 1911 is meanwhile sitting under awaiting-author applied by github-actions[bot] at 12:59:03 after its six required contexts went green at 12:48:39, i.e. CodeRabbit has reviewed it and thread answers are owed on the one unit in this family with no mutation reading at all. A green conclusion on that gate has meant changed-line width and nothing else every time it has been checked here, so "measured" and "green" are different words and the table above should be read with that distinction.

  14. easonLiangWorldedtech commented on Nov 9, 2026

    @easonLiangWorldedtech
    ContributorAuthor

    Deferred items recorded, with the measurement behind them. The split plan for this series lists six units, and nothing in the tracking record mentions the three deferred items: issue body (4,951 bytes) and the plan comment 6735171826 (7,126 bytes, authored 2026-11-09T08:03:14Z, both read now at Zoo-Code-Org/Zoo-Code#issuecomment-6735171826) return zero hits for DACL, ordering and backup. So those three items are untracked by any unit rather than deferred by one.

    • Backup-seed ENOENT swallow (src/services/file-safety/safeWriteText.ts:892-:893) now sits inside 2385's unit, in the same change as the lock-artefact pin: decision and two-process evidence in pull 2385, thread #discussion_r4513340001. It lands with that unit's next push; nothing is pushed yet.
    • Lock-artefact naming for the writer sits in the same change (same unit, same push): the artefact is named once per write from the caller path plus the nearest existing canonical ancestor, instead of re-derived after the write renames the leaf.
    • DACL / write-ordering on the recovery pass is still unowned: it is not in any unit's scope and this issue's own question (staging-name guard vs jsonFileRecovery leftover prefixes being mutually exclusive) is where it has to be decided, because the ordering answer changes which of the two guards may run first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions