Description
What this unit delivers: the read side of the same story. Each read records what it actually saw - a bigint stat pair brackets the read, the target is observed only when both tokens match, and the record says whether the view was complete - and "lines clipped in this view" and "lines omitted from this view" become two separate notices.
Why it matters: a slice, a truncated read, or a lossy indentation view must never authorize a full-file replacement later, and the model needs to be told about clipping and omission separately.
Implemented by: two pulls, not one. PR #1913 (branch fws/u4-read-scope-recording) carries unit U4a at head bb89f14, and PR #2433 carries unit U4b, declaring in its own body that it is stacked on U4a's head and must merge after PR #1913 rather than before it, at head 316abe2. Both heads are as of the campaign's read of 2026-11-12T01:23:28Z. Neither pull carries a closing link to this issue as of that read, so landing either one does not close this issue by itself.
Acceptance:
- Observation only when both stat tokens match; a stat failure leaves the target unobserved and never fails the read.
completerecorded so slice/truncated/lossy views never authorize a full-file replacement.Task.disposeOnce()retires the registry; post-abort reads skip the observation work.- Project-scoped MCP settings writes confine to the provider cwd; global writes stay unconstrained by design.
Part of the file-write-safety epic #1375, landed as a nine-PR chain tracked on #1991. Merge order: U1 -> U2 -> U3 -> U4 -> U5 -> U8 -> U6 -> U7 -> U9.
Activity
Merge upstream/main (73931fe) into fws/u4-read-scope-recording
Reconcile the carried lock-key spec with the replacement protocol
Mark safeWriteText internal now that nothing in production calls it
Add the notice-separator row the review asked for on the clipped full…
Stage the confined write's bytes after staging, and copy the target m…
Read the staged bytes after staging, where the escape undo can use them
Copy the target's mode onto the staged file so the publish cannot wid…
Compare the interruption injection against identity so main's rows fi…
Compare the interruption injection against identity so main's rows fi…
easonLiangWorldedtech commentedon Nov 9, 2026
Planned-unit list, added by lane u7-1918 on 2026-11-09 (unit U4, PR #1913, head
4132582f4de)CodeRabbit offered follow-up issues on five threads. The lead declined all five: an issue does not create mutation headroom, a split unit does. They are carried here instead, each with the cost measured against the gate own counter (
selectFromGitinscripts/stryker-diff.mjs, base73931fe8be, throwaway commits parented on4132582f4de, deleted afterwards).The arithmetic that makes this absolute:
safeWriteText.tsandsafeWriteJson.tsarrive in this branch as new or wholly rewritten files, so every one of their executable lines is already inside the counted 500. Replacing a line costs 0 against the cap; adding one costs 1. The gate throws above 500 (scripts/stryker-diff.mjs:265) and CI printedextension (500 lines)for this head, so headroom is 0 and only a split unit changes it.Free in cap terms - need a decision plus a lane run, not budget:
6098w-staging directory compared lexically (safeWriteText.ts:317): +0 measured, one-line replacement reusingcanonicalDirKey, which already canonicalises the nearest existing ancestor. Open question for the split unit: atempPathin a missing directory then compares against a canonicalised parent instead of the literal parent.6098ahflush opens the mode-narrowed staged filer+(safeWriteJson.ts:610): a replacement of an already-counted line, same class.Charged - need new budget from the split:
60-CvMresolveLockKeyswallows resolution errors (safeWriteText.ts:261-276): +3 (classify the primary error), +4 (classify the walkreadlinkerrors), +7 (both), all measured; reviewer wants unexpected errors propagated rather than terminating the walk.6098ajundo runs although the commit rename failed: +3 estimated for the commit flag (not prototyped).6098aUancestor directories never fsynced: about +5 estimated.6098aftarget bound to the held lock (A -> B -> A): +3..4 estimated.6098acidentity window between the final stat and the unlink: restructure of 188-207, cost depends on shape.6098addangling link rejected before recovery: reviewer contract - recover under the referent lock only after the confinement check, then re-resolve, keep the ENOENT rejection when no backup matches; test both directions plus confinement-fails-in-recovery.6098akteardown skipped when revalidation throws: reviewer contract - escape error stays primary, rollback failure and backup location reported separately, preserve the backup, never skip teardown because revalidation failed.6098ai,6098aX,6098aaWindows ACL and descriptor work: several lines plus a platform split;libuv chmodonly toggles the write bit on this box, so I am not putting numbers on them.6098ablock key not revalidated after acquisition: design choice between the helper and every caller;jsonFileRecovery.tsreads throughwithFileLockwith no revalidation of its own.6098xJcleanup cannot read a 0o200 escaped file: +1..2.Routed, not owned here:
6098xN(backup named for the referent while a scan searches the alias). The publishing path resolves before recovery (safeWriteJson.ts:343); the alias-shaped scan issrc/utils/jsonFileRecovery.ts:30/33/43against:48. The files endpoint shows that file in 2357 26-file delta and 2359 28-file delta and in no other unit of this chain; declared merge order puts 2357 first, so the fix belongs in 2357 and ports forward to 2359.Standing note for whoever clears these threads: at
4132582f4dethere is noZoo Code / PR review gatecheck run - 0 of 72 check runs carry that name; 15reconcile PR review state (1913)runs and 39resolve PRs to reconcileruns are what exists. An absent check run is not a passing one.easonLiangWorldedtech commentedon Nov 9, 2026
Rows carried from PR #1912 (unit U5, branch
fws/u3-observation-completeness, head9d420ad0c3ab012948ba16fb8c11fcf52121fa15).The reviewer pass of 2026-11-09T06:37Z opened eight further rows on that PR. Eleven threads are open; one (a mode-preservation test) was fixed in
9d420ad0c3. The ten deferrals below aredeferred with a number rather than a mood: the mutation/size arm on that branch is red on
headroom alone - CI job
129040967378prints "Mutation gate failed: extension has 672 changedexecutable lines (limit 500). Split the PR or obtain a maintainer-reviewed narrow exclusion.", and
I reproduce 672 locally with
scripts/stryker-diff.mjsselectFromGitagainst merge base73931fe8be253b93ec11103a0672c615e849f895. Adding a counted line costs 1 and replacing one costs0, so no production line lands there until the split lands; in each case the regression test the
reviewer asks for is red without the production line, so test and fix move together.
4510045535-src/utils/safeWriteJson.ts:362(Major, Security): the canonical target isrecomputed at
:360and its parent is pinned at:362without re-running the scope test that:345runs for the supplied path, and_ancestorIdentities(:203-212) degenerates the pin to[root]when the relative path escapes. One executable line. Reviewer reply4510480809, myanswer
4510477160and4510523797.4510045541-:367(Minor, Functional Correctness): reachableTargetMovedError. I firstargued the comparison sides agree; that was wrong -
resolvePublishTarget(
src/services/file-safety/safeWriteText.ts:317-332) returns the supplied, lexical path at:330whenrealpathhits ENOENT andlstatconfirms the path absent, so the pinnedexpectedResolvedPathis lexical while the file is missing and canonical after recoveryrestores it at
:378. Fix plus a row that swaps the alias directory and a missing targetthrough it. Reviewer reply
4510502880, my answer4510496600and4510516119.4510045547-:441(Major, Data Integrity):safeWriteJsonperforms nofstat/lstatofthe staged file (0 matches for either in the file) before its commit rename at
:441, whilesafeWriteText.tscaptures staged identity from the descriptor (:659,:698) and re-verifiesthe name at
:873-900. Mirror that block here. Reviewer reply4510480496, my answer4510477660and4510524563.4504888149-:441(Major, Security): three re-verification points exist (:367,:405,:437) and all throw rather than follow; what remains is the interval between the last checkand
fs.rename, which no path-based Node API closes (the reviewer agrees -4510483157). Ahandle-bound rename or a fail-closed path is the shape. My answers
4510478090,4510541075.4504888130- orig:294, nowline: null(Minor, Security): confinement-before-recovery isin place (
:367before:378, comment:363-366); what is unprotected is recovery's ownrenames and unlinks after that check. The leftover-scan half (scan the referent directory) is in
src/utils/jsonFileRecovery.ts, which is in feat(file-safety): guard machinery in the writer, with the identity and rights refusals (U5-B, split 1/2 of #1914, #1375) #2357's and feat(tools): the guardedWrite tool on top of the writer's guard API (U5-A, #1375) #2359's deltas and not in this unit's13-file delta, so it routes there. Reviewer reply
4510508067, my answers4510497945,4510517774.4504888151-:457(Minor, Stability): corrected by the reviewer and by me in turn -recovery already receives the referent's lock key, and the defect is the order:
:335rejects adangling link (
safeWriteText.ts:329) before recovery is reached at:378, so an exit betweenthe fallback renames at
:455/:457leaves the symlink dangling and the next writer neverreaches recovery. This one is fixable inside
safeWriteJson.ts. Reviewer reply4510502764, myanswers
4510497074,4510517411.4510045497-src/services/file-safety/safeWriteText.ts:429(Minor, Data Integrity): therealpath(path.dirname(key)).catch(() => undefined)fallback is deliberate (comment:425-426); the exposure is lock granularity turning into a lost update, becausesafeWriteJson.tsimportsresolveLockKeyat:11and calls it at:305and does not gothrough
safeWriteTextpublish, so two writers can hold different locks over the same JSON andthe path comparison at
:860-865cannot see it. Reviewer reply4510486701, my answers4510479409,4510516554.4510045502-:430(Minor, Data Integrity):path.resolvefolds..lexically before thetarget's own components are resolved, so
sub/../file.jsonwithsuba directory symlink landson a wrong lock key. A component-wise walk is the fix, and it belongs with row 7 and row 9 in the
same function. Reviewer reply
4510483384, my answers4510479880,4510540193.4510045504-:436(Minor, Stability): the throw at the eight-link bound is unconditional,and there is no
lstatbetween the walk (:413onward) and that throw (the file'slstatsitesare
:325,:531,:546,:874), so a legitimate depth-8 chain whose last referent istransiently missing fails. I argued the primitive has no caller; true of
safeWriteTextpublishonly -
resolveLockKeyis called by JSON writes today. Reviewer reply4510486748, my answers4510480346,4510516985.4510045513-:879(Minor, Data Integrity): theENOENTbranch throws without settingstagedPathIsForeign(declared:590, set on the identity branch:895, consumed by thecleanup guard at
:1040, unlink at:1044), so cleanup can unlink a file that took the name.One line plus its test, and the reviewer confirmed the mechanism independently (
4510505469). Myanswers
4510478955,4510540619.4504888118-:903(Minor, Data Integrity): the rename is already preceded by there-resolution at
:860-865and the identity re-verification at:873-900; the irreducible partis
:874to:903. Thread stays open by agreement (4510505469). My answers4510497560,4510518246.Fixed rather than deferred, recorded here so the split does not re-do it:
4510045532, themode-preservation test, shipped in
9d420ad0c3atsrc/utils/__tests__/safeWriteJson.test.ts :1577-1612(+36 test lines, 0 counted executable lines - the gate reads 672 before and after). Itpins the direction on platforms that store a mode; on Windows the pre-existing 0o600 row already
fails a hardcoded-0o600 implementation, which I measured under a control.
easonLiangWorldedtech commentedon Nov 9, 2026
Measured six-unit pre-cut of #1912 (issue #1375) - nothing opened yet
Per the standing ruling the split plan lives here rather than in a new issue. Every number below came out of the gate's own functions (
packageForPath,executableChangedLines,selectFromGitfromscripts/stryker-diff.mjs) run against six disposable commits built locally, each measured from its own merge base (the previous unit's commit), not from the whole-PR diff. Test and spec lines cost 0 because the gate excludes__tests__/,*.spec.*and*.test.*.Measured cost per unit
Cap verdict per unit from its own merge base:
selectFromGitreturned without throwing for all six, so none tripsMAX_CHANGED_LINES = 500. The whole PR measures 672 counted lines (172 over), which is the reason for the split. Largest unit: 304.The six counts sum to 680, eight more than the whole-PR 672. Those eight are the mode-copy helper lines unit 4 carries while its later call-site neighbours stay in unit 5: the lines between them shift position, so both diffs mark them changed and they get attributed twice. The boundary that sums to exactly 672 (hunks 1-13 vs 14-24, measured 110 + 54) was built, measured and failed typecheck, so those eight lines are the price of a unit that compiles alone.
Chain, merge order, fidelity
Merge order 1, 2, 3, 4, 5, 6; each commit's parent is the unit before it. After unit 6 the tree is byte-identical to the current head:
git diff --name-only 229556893a2d1e5ae040a5f4d87a26a20b14da44..9d420ad0c3ab012948ba16fb8c11fcf52121fa15printed 0 files. Nothing was pushed and no pull request opened; the commits live only asrefs/tmp/u6-1912-cut1..refs/tmp/u6-1912-cut6in the fork clone.Shas with measured merge bases:
350f56ae1540e0975c19f41cada9a60685f8fbbd(parent73931fe8be253b93ec11103a0672c615e849f895, merge base73931fe8be253b93ec11103a0672c615e849f895, counted 168)b7cae3c23d464d64e1eadb3617e5c8d916a0e825(parent350f56ae1540e0975c19f41cada9a60685f8fbbd, merge base350f56ae1540e0975c19f41cada9a60685f8fbbd, counted 304)81c600f10c5af46262bc1f762599b660ca77c848(parentb7cae3c23d464d64e1eadb3617e5c8d916a0e825, merge baseb7cae3c23d464d64e1eadb3617e5c8d916a0e825, counted 17)490577edb2402e86b09d838462bae0afd25165f2(parent81c600f10c5af46262bc1f762599b660ca77c848, merge base81c600f10c5af46262bc1f762599b660ca77c848, counted 143)aee35f770cb34bc7519cdbb73cbe3a4982965c50(parent490577edb2402e86b09d838462bae0afd25165f2, merge base490577edb2402e86b09d838462bae0afd25165f2, counted 29)90ed0d4c11996e024c7f9cd90876a12806e5deb5(parentaee35f770cb34bc7519cdbb73cbe3a4982965c50, merge baseaee35f770cb34bc7519cdbb73cbe3a4982965c50, counted 19)Verification run on each unit alone
node node_modules/typescript/bin/tsc --noEmit -p src/tsconfig.pcmap.jsonfrom the scratch worktree root (interpreternode v22.23.4). The scratch config extendssrc/tsconfig.jsonand maps all 9 workspace@roo-code/*packages into the unit's ownpackages/*/src(18 path entries) - a plain donor junction would type the unit against the donor instead of itself. Printed: 14-20 s per cut, exit 0, 0 errors, six for six.src,--globals, only that unit's own test files -vitest.services.config.tsfor units 1, 2, 6;vitest.core.config.tsfor unit 3;vitest.misc.config.tsfor units 4, 5. Printed test totals: 6, 101, 223, 5, 81, 93; every exit 0.safeWriteText.tsplus the two spec slices):eslint --max-warnings=00 errors / 0 warnings,prettier --checkclean.src/eslint-suppressions.jsonuntouched,--prune-suppressionsnever used.Boundaries, and the cuts I disproved
resolvePublishTargetandresolveLockKeystop (line 439 of the new module);safeWriteTextstarts at line 450. Everything downstream consumes those two exports - that is the contract crossing.Task.tsfield that owns it. Measured:Task.tsis the only production file in this delta importing@roo-code(@roo-code/telemetry,@roo-code/cloud) andTask.spec.tsthe only spec that does - two files, not three. Only unit 3 needs the overlay for its own code, although every unit needs it to typecheck the whole tree.confineToplumbing and the mode-copy helper; unit 5 carries the commit rename, recovery and cleanup-logging hunks; unit 6 is the threeMcpHub.tscall sites that pass the workspace root asconfineToand is only reviewable after unit 4 ships that parameter.refs/tmp/u6-1912-cut1-specimport-disproved(95ca90cbe99d…): the first unit-1 draft kept the spec's full import list, which namessafeWriteTextbefore it exists -TS2305: Module '../safeWriteText' has no exported member 'safeWriteText'. Six unused imports and one unusedfakeChildbinding also had to come out before eslint printed 0.refs/tmp/u6-1912-cut4-hunk13-disproved(e9624d893f…): the exact-672 boundary (hunks 1-13 vs 14-24) fails because the call to_copyTargetModesits at line 423 while the definition is at line 587 in a later hunk -TS2304: Cannot find name '_copyTargetMode'. That dependency is what the boundary must respect.refs/tmp/u6-1912-cut4-lane-failed(c0c714bf2d…): shipping the wholesafeWriteJson.lockKey.spec.tswith unit 4 fails its own lane -does not log a cleanup error when the safety net finds the temp file already goneasserts oneconsole.errorand got two, at line 255, because the suppression of the second log ships with unit 5. The test now travels with unit 5, where the behaviour lives, and unit 5's lane prints 81 passed.#1912 state measured while doing this (not part of the plan)
(Lane u7-1918. Everything above is a printed measurement from this round; nothing here is estimated or heard.)
easonLiangWorldedtech commentedon Nov 9, 2026
#1912 state - correction to the measurement line in my previous comment
The check-run and CodeRabbit lines in that comment were under-fetched, not zero: I queried the fork commit (
easonLiangWorldedtech/Zoo-Code/commits/9d420ad0c3…/check-runs, which genuinely reports 0) instead of the upstream commit, and I stopped the reviews query at page 1. Re-run with complete pagination and printed HTTP statuses:Zoo-Code-Org/Zoo-Code/commits/9d420ad0c3…/check-runs: HTTP 200, total_count 114, 114 runs fetched over 2 page(s); 96 reconcile/resolve runs and 18 real runs, 17 of the real ones success. Names and states: {"Zoo Code / reconcile PR review state (1912)":"completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/success , completed/su.Everything else in the earlier comment (unit costs, shas, lanes, typecheck, lint, disproved cuts) was measured directly and stands as printed. (Lane u7-1918.)
easonLiangWorldedtech commentedon Nov 9, 2026
#1912 state - second correction, the CodeRabbit line only
My correction comment printed "Latest CodeRabbit review: state null". That was my filter, not the API: I matched login
coderabbitaiand the API reportscoderabbitai[bot], so the filter matched 0 of the 112 reviews. Re-measured with the paginated list and a prefix match:submitted_at: state COMMENTED, submitted 2026-11-09T08:21:02Z, id 5823254898, body 0 bytes.9d420ad0c3ab012948ba16fb8c11fcf52121fa15. The thread count grew from 43 to 53 on that same head - the bot pass finished and added 10 rows, 24 threads now flagged outdated, 32 resolved, 21 open, 137 thread comments. Checks on the head: 114 total, 96 reconcile and 18 real (17 success, 1 failure: mutation-diff, job 129040967378); 0 runs queued or in progress, so the bot is no longer annotating this head.easonLiangWorldedtech commentedon Nov 9, 2026
Note: comment 6735280837 (the eleven deferred production rows for #1912) was filed on this tracker by mistake - this issue tracks fws U4 = #1913, while #1912 is tracked by #2018, where the pointer now stands. Nothing was deleted here; the rows that do belong to U4 were posted earlier (6734682909) and stay here.
easonLiangWorldedtech commentedon Nov 9, 2026
Pointer so this issue does not read as a gap: the six-unit plan for #1912 I posted here (6736294037, plus its two measurement corrections 6736306244 and 6736313767) belongs on #2018, whose body names pull request 1912 - #2019 names 1913. I posted it here because I did not read the trackers' bodies first. The plan, with re-measured census numbers and the gate-evidence correction, is now #2018 comment 6736488173, and that comment supersedes these three for #1912 purposes. The eleven deferred rows in 6735280837 stay where they are. Nothing deleted, nothing edited in place. (Lane u7-1918.)
easonLiangWorldedtech commentedon Nov 12, 2026
Split plan for pull 1913, recorded here before either half is offered as a pull.
Pull 1913 cannot clear the mutation gate as one pull. Parent shape at pull head acc49b9 against main tip 53cb5b5: 484 counted executable lines, 185 selectors, 535 instrumented mutants, against the caps of 500 counted executable lines and 400 instrumented mutants that scripts/stryker-diff.mjs blob 7dfe0006d2d5c7e4d2282f65f68b8ed5f16866bc sets at line 11 and line 12. The line cap is clear by 16 lines, the mutant cap is over by 135, so it is the mutant cap and not the line cap that forces this split. Those shape figures are teammate f0-1528's, counted with a blob-exact copy of that gate plus Stryker 10 dry runs; my own run of the gate as shipped, node scripts/stryker-diff.mjs ci --base 53cb5b5 --head acc49b9, prints extension 484 lines and exit 0 at that head.
What each figure means and what it does not. Counted executable lines are the instrument's own executableChangedLines for the src extension leg, taken from git diff --unified=0 of merge base to head, with tests, mocks, fixtures and test-utils dropped from both caps so test code costs nothing in either cap. Selectors are the ranges the instrument hands Stryker in runRoot-relative path-colon-start-to-end form. Instrumented mutants are the Instrumented N source file with M mutants line Stryker prints before this host aborts the run at its initial test run: every mutant figure in this comment is such an instrumented count on a host where every dry run aborts at the initial test run, and no killed, survived, no-coverage or timeout count and no digest came out of any run described here.
Two sentences carried verbatim, each on its own line:
a green local preflight over Valid 0, Killed 0, Survived 0 and No coverage 0 with blocking 0 and advisories 2 is not evidence in either direction on this machine
the mutation leg is unmeasured, a PASS over an empty digest is not evidence in either direction, and CI at the first pushed unit is the only proof of the gate
No shape below is called green and no figure below is a CI figure.
Unit U4a, read scope and the guarded text writer. Branch fws/u4-read-scope-recording, the branch pull 1913 already tracks, so this half stays on pull 1913 and pull 1913 keeps its number. Content source of record: pull head acc49b9 restricted to these 11 files: src/services/file-safety/safeWriteText.ts, src/services/file-safety/tests/safeWriteText.spec.ts, src/services/file-safety/tests/safeWriteText.integration.spec.ts, src/utils/tests/fileLock.canonicalKey.spec.ts, src/utils/fileLock.ts, src/core/tools/ReadFileTool.ts, src/core/tools/tests/readFileTool.spec.ts, src/integrations/misc/indentation-reader.ts, src/integrations/misc/tests/indentation-reader.spec.ts, src/integrations/misc/tests/indentation-reader-unicode.spec.ts, src/eslint-suppressions.json. Shape at its head bb89f14 against main tip 53cb5b5, measured by me with the same blob-exact gate copy: 319 counted executable lines against the 500 cap, 119 selectors, 347 instrumented mutants from 4 instrumented files against the 400 cap. Standalone width 11 files, 3791 insertions, 13 deletions.
Unit U4b, the json publish layer and the MCP call sites. New branch fws/u4b-json-publish, stacked on U4a and offered as a new pull against main that names U4a as its dependency. Content source of record: the same pull head acc49b9 restricted to these files: src/utils/safeWriteJson.ts, src/services/mcp/McpHub.ts, src/utils/tests/safeWriteJson.test.ts, src/utils/tests/safeWriteJson.lockKey.spec.ts, src/utils/tests/safeWriteJson.interruption.test.ts, src/services/mcp/tests/McpHub.spec.ts, plus the export keyword on the two names named below. Shape against U4a as its own base, measured by me at candidate head 316abe2: 167 counted executable lines against the 500 cap, 68 selectors, 188 instrumented mutants from 3 instrumented files against the 400 cap. Standalone width 7 files, 1685 insertions, 32 deletions.
The split of the parent shape. teammate f0-1528's halves add up exactly: 319 plus 165 equals 484 counted lines, 119 plus 66 equals 185 selectors, and 347 plus 188 equals 535 instrumented mutants. My pushed halves re-price the second half by two counted lines and two selectors, 167 lines and 68 selectors against those 165 and 66, because U4a declares two names without export and U4b adds the keyword back with their consumers, so src/services/file-safety/safeWriteText.ts line 195 and line 220 are charged to both halves. Mutants stay additive: 347 plus 188 equals 535, the same instrumented count the parent shape shows. Lines go 319 plus 167 equals 486 against the parent 484 for those same two lines. Nothing is duplicated: every one of the 17 files in the parent diff carries the same blob at the U4b candidate head as at acc49b9, and no file appears in either half that is not in the parent diff.
Merge order and dependencies. U4a first, then U4b, both into main. U4b is stacked on U4a and its merge base is U4a's head, which is why its figures above are measured against U4a and not against main. The chain order this issue's body records, U1 through U9 with U4 in place, is unchanged by this split: U4a and U4b take U4's place in that order, U4a before U4b.
Cross-unit contract points, one owner each. canonicalDirKey and resolvePublishTarget, declared in src/services/file-safety/safeWriteText.ts: U4a owns the declarations and keeps them unexported, U4b owns the export keyword and arrives with the only consumers, since src/utils/safeWriteJson.ts imports canonicalDirKey and calls resolvePublishTarget when it resolves its lock key and when it rechecks the resolved target. resolveLockKey: owned by U4a, whose consumer src/utils/fileLock.ts travels in U4a. The eslint-suppressions.json hunk: owned by U4a, and it only lowers a count, core/tools/tests/readFileTool.spec.ts at no-explicit-any from 96 to 94. Neither half adds a knip config entry and no suppression count rises anywhere in either half.
The knip claim, measured rather than predicted. teammate f0-1528 named it as the cost U4a would pay and did not run knip. I ran the repository's own command, knip 5.88.1 through the script knip --treat-config-hints-as-errors declared in knip.json, against the knip.json as it stands at U4a. At the U4a tree before the unexport adjustment it exits 0 with 0 bytes of output. After the adjustment it exits 0 with 0 bytes of output. Control: add one export nothing imports, knipDiscriminateProbe at src/services/file-safety/safeWriteText.ts line 662, and the same command exits 1 and prints Unused exports 1 naming that symbol. So the rule is live on this file with this config and the clean runs are a measured zero rather than a skipped file: the two names cost nothing to keep exported in this half because knip.json sets ignoreExportsUsedInFile to true and both names are called inside their own declaring file, at line 263 and line 294. The adjustment therefore did not buy a green knip run, it bought a unit boundary that does not advertise a name its own unit never imports.
What these figures carry and what they do not. They carry which file travels in which half, that each half sits under both caps at the head it will be pushed at, that the instrumented mutant totals of the halves add to the instrumented mutant total of the parent, and that the two halves reproduce the parent content byte for byte. They do not carry any killed or survived mutant, any coverage figure, any CI verdict, or any claim about the other ruleset contexts beyond the local knip run described above, which is a run of a repository command and not a CI reading. Nothing on this issue and nothing on pull 1913 is resolved by this comment, and no re-review is requested by it.