Skip to content

fn-4 hardening: the 18-defect backlog, three gated rounds - #461

Open
acebytes wants to merge 61 commits into
mainfrom
fn-4-hardening
Open

acebytes wants to merge 61 commits into
mainfrom
fn-4-hardening

Conversation

@acebytes

@acebytes acebytes commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes out the fn-4 hardening backlog — the 18 defects filed against main during the fn-5/fn-6 review campaigns, worked in three gated rounds on this branch. Every fix was reproduced red-first, every guard arm is mutation-evidenced (red rate over ≥8 runs where timing-dependent), and each round ended with an independent read-only gate. Round 3's gate additionally re-ran the named signature cells from rounds 1–2 and PR #460 — the disposal bindings, drain fences, capture bound, walker clamp, sizer arms — all green.

Suite: 1665 executed / 3 skipped / 0 failures, exit 0 (swift test, at commit 2bb2596, 214 s). 29 commits, 60 files, +7,221 / −857. Forked from main at 8a38e3f.

What a user gets

  • Deletion binds what it deletes, on the mainline path. Contents-mode and no-revalidator item deletions bound only their container; a child renamed at its own path inside the window was destroyed unexamined. Both arms now bind kind+inode through the same function on both sides of the hop (the Stale git-worktree scanner (fn-5) #460 r18 mechanism, reused rather than duplicated).
  • A truncated git answer is never a success. A drain that died on a hard read error looked like EOF, so a partial porcelain listing parsed as "a repository with fewer worktrees". Refused as .timeout at the execute boundary — the only place it could be caught; measured, no downstream parser can see the truncation.
  • Background scans keep out of TCC-protected paths. The resolver canonicalized pointers before the deferral gate could answer, so an automatic scan could realpath through ~/Documents. Order inverted at every pointer-derived path — plus three sibling arms the spec didn't name, one where canonical equality answered true for a deferred target.
  • No more app-freezing waits. The container-identity capture is bounded and off-main (a hung mount reported per-scanner, never a spinner); dockerPrune's whole child interaction is raced (the read parks before the wait — bounding only the wait would have moved the strand one line); zero bare waitUntilExit() remains in production, enforced by a grep-gate cell.
  • The sizing walk answers cancellation (25 µs cancel-to-return, measured at 111k entries/s) and caps at 2M entries — the cap disclosed as permanent, never dressed as retryable, and every cleaner arm refuses on a cancelled measurement.
  • The walker cannot crash the cooperative pool. Depth clamped at 128; the crash band was measured at 257–288 on the real executor (reverting the clamp dies on signal 10). Production passes 8 everywhere — zero behaviour change.
  • Launch performs no unbounded I/O on configured roots; a symlink root is answered from its link content, destination-free.
  • Issue labels state their producer's true condition. Full kind→producer matrix audited; bare EPERM is never labeled TCC (decision recorded where both producers see it); one new extensible wire kind mutation_scope_refused; no wire string renamed.
  • Bare repositories whose checkouts are all gone are now discovered — the prune tier's own case. The discovery proves the shape git itself requires, TCC-deferral answers first, and the fn-5 witness discipline (discovery identities, delete-time re-proof, detached-HEAD preservation, one-to-one mapping) applies to the new path with cells. Permanent residual disclosed: a config spelling bareness other than git's writer form stays undiscovered.
  • A git child's process group is established at spawn (posix_spawn + POSIX_SPAWN_SETPGROUP), never discovered from a live leader — the exited-leader race is gone and descendants are reaped by PID.

Test integrity

  • The three trapping shapes (as! / fatalError / preconditionFailure) are statement-trap-fenced across all test sources; the reproduction aborted the process with 38 cells unrun and a tally reading 0 failures.
  • The one genuinely flaky cell was root-caused, not tolerated: a process-wide descriptor sweep asserted against a walk-local census — the exclusivity assumption was wrong, not the census or the kernel. Rewritten as a set-based classifier (leak fails at once; ambient churn retries bounded; two clean disagreements convict). An unprovable arm was written and then deleted per doctrine.
  • All 33 performer refusal tags are pinned by a two-layer source census — swapping any two reds naming position and line; a smuggled new spelling reds at its line.
  • The Trash message fence now pins the vocabulary byte-exact and derives its own coverage: Remedy carries no free text, retirement is a production declaration, and a new Established case fails a named cell without anyone editing the test's lists. Attack round: 0 of 11 fresh false wordings pass.

Measured, deliberately not built

The dev-root "double walk" (Codex P2) is 2.2% duplicated probes, not doubling — 249 of 11,544 on an artifact-bearing 5,772-entry tree, because the two walks are asymmetric (one prunes matched dirs, the other must not). Buying 2.2% would rewrite the scanner I/O contract. The measurement cell ships executable (DevTreeWalkMeasurementTests) so the decision can be re-derived when tree shapes change.

Review round 1 (Codex, on efc2760)

Three findings, all on code this PR added — the pattern the campaign documents: each round's findings attack the last round's fix. All fixed and mutation-proven.

  • P1 — a losing task could launch the prune it was already reported not to have run. FirstWinsRendezvous decides which outcome is reported, not whether the work started; a queued detached task could run() after the timeout branch truthfully reported nothing running and re-enabled the button. The starvation the off-pool timer exists to survive is exactly what keeps the task queued. New LaunchClaim decides the pair under one lock. Mutation: 504 red, evidenced by 500 concurrent begin/abandon pairs requiring exactly one winner each.
  • P2 — MOUNT FIRST said since fn-4.12, while the probe ran above it. On an unresponsive mount that lstat never returns, so the promised issue is never emitted and the session leaves a blocked worker. The kernel table costs no filesystem call; asking it first is free. The cell counts probes rather than hanging to prove a hang.
  • P2 — unchecked posix_spawn setup returns. A dropped adddup2 spawns a child whose stdout goes elsewhere: git exits 0 and the empty buffer is accepted as complete — fn-4.24's class re-entering through fn-4.27's spawn. Now try require(...)d. Negative result recorded: the failure cannot be staged behaviourally, and launch was not reshaped to make it reachable; a source fence with a vacuity guard asserts it instead.

Known residuals (each disclosed at its site)

  • A losing bounded-capture worker stays parked on a .utility thread until its volume answers — one per timed-out attempt.
  • Appear-and-stay descriptor churn from a sibling cell would convict the census cell as a leak (the measured flake shape was vanish-churn; the retry ladder covers that).
  • Third-spelling aliases can shadow a real dev root — availability only, never destruction; pinned at all three sites.
  • The fence forces every wording change into its own diff but cannot judge truth — a coordinated wording+pin edit remains reviewer judgement.
  • pid-recycle on group SIGKILL unchanged in class from Stale git-worktree scanner (fn-5) #460 r18.
  • 29 of 33 refusal tags' user-facing detail prose has no behavioral cell (the tag routing is pinned; 4 details asserted).

…d the no-revalidator item arm (fn-4.21)

remove(at:expecting: nil) proves the container and nothing else; a child
renamed aside at its own path inside the window was destroyed unexamined
with the measured tree's bytes reported (reproduced deterministically on
both arms before the change — contents mode and item mode with no
revalidator, moveToTrash: false).

The binding is the r18 BoundObject shape through the same function:
TrashDisposal.boundLeaf under the proved admitted container at the
pipeline's first read of the leaf, re-proved by the identical read in
provingImmediatelyBefore on the far side of the queue hop
(provedStillTheBoundLeaf, shared by both arms so two readings cannot
disagree). Refusal is the same .notTheInspectedObject the verdict arms
throw, tagged content-drift in contents mode too.

The standing enumeration at DepthSafeRemoval is re-derived from grep:
five sites, not three; the dispose(expecting:) site is never nil at
runtime (inside if-let — the task spec's claim retired against source);
the Trash arms' disposal-entry binding residual is stated where each arm
ignores the early capture. Vanish-at-bind fixtures now count the
matching probeChild call: call 1 is the pipeline bind (already-gone
skip, newly pinned), call 2 the disposal's own window.
…ke EOF (fn-4.24)

PR #460 r18 adversarial verification (runner scope), MEASURED: on any
read(2) error that is not EINTR/EAGAIN/EWOULDBLOCK, PipeDrain.drain()
set isDone and returned, signalling `finished` exactly like EOF —
join(within:) answered true, execute's success gate could not tell the
two endings apart, and the partial buffer shipped as .success(stdout:).
A truncated porcelain listing does not look malformed; it looks like a
repository with fewer worktrees, or a clean tree.

Reproduced RED before the fix with a real failing descriptor (EISDIR
via a directory fd), at both levels:
- drain: terminalReadFailure nil where EISDIR died the worker
- execute: .success(stdout: 0 bytes) [stdout arm] and .success(stdout:
  16 bytes) [stderr arm] where the drain had died mid-stream, with a
  CONTROL run proving the stub succeeds through healthy drains

Fix: the drain RECORDS the terminal errno under its own lock where the
error fires; execute reads it right after the successful joins — the
first read of that fact on the only path that can still become a
success — and answers .timeout, the same class as the unjoined-drain
arm (C7) and for the same reason. Retry can differ: fresh invocation,
fresh pipes, fresh descriptors. No terminate in the new arm: the child
has provably exited and both drains returned, so a kill there would be
an unevidenced guard.

Downstream-parser question (asked by the spec), answered from source
and RECORDED at the gate: GitWorktreePorcelainParser fails closed on a
mid-field/mid-record cut but accepts a record-boundary cut as fewer
worktrees; WorktreeStalenessAssessor.verdict counts a truncated status
as cleaner and an empty one as .clean; first-line readers accept
whatever line survives. The boundary gate is load-bearing.

Mutations (deterministic cells, filtered runs, target rebuilt each):
- m1 restore EOF-equivalence (drop the errno recording): RED 3/3 cells
- m2 delete the execute gate: RED both execute cells
- m3 drop the stderr operand: RED stderr cell only
- m4 drop the stdout operand: RED stdout cell only

PR #460 drain bounds re-run green after the change:
testCapturingOutputIsNotStarvedByAContinuouslyWrittenWriteEnd,
testCloseIsNotStarvedByAContinuouslyWrittenWriteEnd,
testAReadTurnThatNeverRunsDryStillEndsOnItsOwnBound,
testTheDrainIsEndedOnlyThroughTheBoundedSpelling,
testAnUnfinishedDrainOnANormalExitIsNotReportedAsSuccess.

Full suite: 1600 executed / 2 skipped / 0 failures (baseline 1597/2/0).
…dicate both dereferenced deferred paths (fn-4.26)

GitWorktreeGitdirResolver canonicalized a worktree's gitdir: pointer BEFORE
its first gated probeKind, so an .automatic scan realpath(3)'d through a
TCC-protected admin directory before DeferringIdentityProvider could answer
.absent — and the deferral predicate (ProjectTreeWalker.isProtectedRoot)
itself canonicalized the very path it was classifying. Reproduced RED first
in six cells (scan-level realpath count, resolver pointer/commondir/backlink/
cross-validation, predicate direct-spelling); the cross-validation cell also
showed the fallback's canonical path equality answering TRUE for a deferred
target.

The fix is ORDER, not removal: the resolver probes every pointer-derived
path AS SPELLED before canonicalizing or comparing it, and isProtectedRoot
classifies lexically first, canonicalizing only spellings the lexical stage
could not match (aliases). The pass-through delegation injected test
providers rely on is untouched. Retired the 'house doctrine draws the line
at canonicalize' claim where it was written.
… (fn-4.26)

ProjectTreeWalker.isProtectedRoot grew 30 lines, moving the cited
device-compare/isMountPoint arm from 529-532 to 559-562; the anchor
integrity cell caught the drift in the full-suite run.
…ted (fn-4 r1 gate)

The gate found BuildArtifactsScanner.swift:729 citing the mount-refusal arms
at :1151/:1371 — lines that now hold a comment and an unrelated identity
check after fn-4.21's ~200-line insertion. Repointed to :1187/:1460, verified
by grep against the 'mount boundary' refusal strings themselves.
SourceAnchorIntegrityTests does not cover doc-comment anchors in THIS file's
prose (it pins its own expectation table), which is why the gate had to catch
it by hand — same class as the r19 shift, different detector.
…line capture (fn-4 r1 gate)

fn-4.21's refusedChild path had no cell for a bind read that fails with
anything but ENOENT: an EACCES at TrashDisposal.boundLeaf during the
pipeline capture must be the child's reported FAILURE, never the silent
skippedAlreadyGone skip — the leaf is still standing there.

Provider double denies exactly one probeChild read (the
LockUnreadableProvider pattern), with an unarmed control. Mutation
proved: widening the ENOENT catch to swallow every
DepthSafeRemoval.Failure as a skip turns the cell red on the
error-count assertion (0 != 1), reverted.
…ead (fn-4.19)

ContainerSnapshot.capture ran synchronously inside scanValidatedSession,
which CacheoutViewModel.scan reaches on the MainActor: every session
root's lstat ran on the main thread before any bound existed, so a hung
mount under a root — including a root INSIDE a mount, which the
mountPointPaths() preflight cannot see — froze the app unbounded and
unreported (r12 measured 6.03 s with isMainThread=true from a 6 s
blocking identity(of:)).

captureBounded races the capture loop, detached at .utility (the
producer's band-separation decision, for the same reason — an
unspecified-band capture queued behind saturation-cell holders and rode
its own deadline), against a ScanSessionClock timer via
FirstWinsRendezvous — BoundedDiskInfo's rendezvous, extracted so the
three bounds share one spelling. scanValidatedSession is async since
this change; both production consumers and 16 test call sites await it.

An expired capture runs NO scanner and says so: one .scanDidNotFinish
per selected scanner whose detail names the capture, ledger concluded
.boundFired (GUI declines adoption, CLI target-scoped refusal reads the
rows), snapshot .empty (admits nothing — the same fail-closed refusal an
omitted root always had). A retry can differ — stalled volume, starved
band, both transient — so the re-scan remedy is real, not a strand.

New captureDeadline on ScanSessionBounds: production 30 s, fixture
default 10 s. The cell's wedge is a releasable semaphore: a fixed
5 s sleep leaked its abandoned-capture worker into the saturation
cell's window (red only in close pairings; measured, fixed, 8/8 green
paired). Mutation A proved: restoring the sync unbounded capture reds
the cell 8/8 on the elapsed assertion.
…last bare waitUntilExit calls (fn-4.20)

dockerPrune did readToEnd() then a bare Process.waitUntilExit() in a
detached task, awaited unbounded: the retired primitive's last
production call site, on a cooperative worker, latching isDockerPruning
for the life of the app if docker never exited. And the wait was not
the only park — the task-spec question answered: readToEnd() blocks
until EOF, so a wedged child that keeps its pipe open parks the read
BEFORE any wait is reached; bounding only the wait would have moved the
strand one line up.

The budget (stated: 600 s production, seams for tests) therefore races
the WHOLE interaction (spawn -> drain -> waitForExit(within:)) via
FirstWinsRendezvous on ScanSessionClock. Expiry is reported ('did not
finish within ...'), SIGTERM is best-effort, the button releases on
every path, and a completed failure cannot trade places with a timeout
(three cells: expiry, success control, completed-failure control).

The spec's 'last surviving call site' claim was stale against source:
Tier2Interventions carried two more bare waits (post-SIGKILL reaps);
both converted to waitForExit(within: 5) so the gate can hold. The gate
itself is a cell (DocumentedContractTests): zero non-comment
waitUntilExit() lines across Sources/**.swift, comment mentions allowed
(they document the retirement), test sources out of scope per spec.
Verifier finding: 3c98463 grew PathGuard.swift by a uniform +74 lines and
SpaceScanner.swift by +29 below its insertions, and
SourceAnchorIntegrityTests.testEverySourceAnchorStillPointsAtWhatItCites
went red on exactly the 11 anchors citing below those points (green at
a8de2a2, red at 3c98463 — attributed by running the cell at both). The
cited text itself is byte-identical (every pinned excerpt found at the
old offset + the file's uniform delta); each citing sentence was re-read
against its shifted target before repointing, per this check's own rule.

21 citing sites + the 11 anchorExpectations rows updated; every
replacement is digit-for-digit the same width, so no line in any citing
file moved and no further anchors drift from this commit.
…ies (fn-4.15)

DirectorySizer.measure had no cancellation point and no entry cap
(measured: Task.isCancelled occurred 0 times in the file) — a scanner
cancelled mid-measure kept walking, and nothing bounded the walk.

CANCELLATION: checked between entries, before the pulled entry is
processed. A cancelled walk returns what it has with the report MARKED
partial (SizeReport.cancelled) — deliberately not a denial: nothing
refused the read, and a retry CAN differ (cancellation is a caller act).
Measured: 111,437 entries/s full-walk rate; cancel-to-return latency
25 us after 5,765 entries (figure to beat was r16's 46.3 ms). Red-first:
testAPreCancelledMeasureStopsBeforeTheFirstEntry failed at 12/12 entries
enumerated before the check existed.

ENTRY CAP (the design decision): 2,000,000 entries per measure call,
DISCLOSED as .enumerationCapped at the walk root, spent only when a
further entry actually exists (an exact-fit tree makes no truncation
claim). The cap is DETERMINISTIC over a static tree, so the disclosure
states permanence and offers no retry — no re-scan wording anywhere,
pinned by testTheCapDisclosureNamesAPermanentConditionNeverARetry.
Value sized against measurement: ~111k entries/s puts the cap at ~18 s
of walk; the largest evidenced real tree (23G worktrees, scenario 2 of
FIELD-EVIDENCE) extrapolates to ~300k entries at this repo's measured
entries-per-byte (.build: 5,917 entries / 481 MB).

PER-CALLER VERDICTS (the C6 check, stated per scanner):
- CacheScanner / sweep / worktree items: capped figures are floors; the
  denial rides the existing channels (scanErrorKind .other;
  rootIssueKind -> .enumerationTruncated, whose GUI label is already
  true of it). No deletion verdict consumes the cap: delete-time
  re-measurement is UNCAPPED.
- BuildArtifactsScanner census: a capped census is a FLOOR, and the
  probe's doubling already grows past an undercounting census by
  documented design.
- GitWorktreeScanner admin-prune suppression (denials.first) would
  suppress on a cap denial; unreachable there in practice (fixed-shape
  admin dirs, ~10 entries) and delete-time still re-verifies uncapped.
- CacheCleaner (delete time): UNCAPPED sizer by construction — the
  mount doctrine reads mountBoundaries as 'the whole tree was swept',
  so a capped walk would either permanently strand deletion (the C6
  pattern) or delete mount-blind. The pass stays proportional to the
  deletion it precedes, and is cancellable per entry.

FAIL-CLOSED CONSUMERS (a partial report is never consumed as complete):
CacheCleaner's category-child and item arms and WorktreeReclaimPerformer's
worktree and admin-prune arms each refuse a cancelled report before the
mount check and before any claim registration, tag
'measurement_cancelled', wording explicitly retryable ('not permanent').
Scan-time consumers are covered by the session-completion discard
(CacheoutViewModel: completed = !Task.isCancelled && !didExceedBounds;
nothing a cut-off session saw becomes deletable).

Anchors: 4 repointed (DirectorySizer 261-272 -> 317-332, 354-359 ->
443-448, 483 -> 570; CacheCleaner 514 -> 523) across expectations and
citing sites.

Suite: 1625 executed / 2 skipped / 0 failures in 208.6 s wall (exit 0,
total line printed). Mutation matrix runs next; results recorded in the
task report.
…mp the walker's depth budget (fn-4.13)

THE SWEEP (every self-calling function over a filesystem tree in
Sources/, traced to actor reachability):

ONE true recursion found: ProjectTreeWalker.visit — one frame and one
anchor per level, reached from the cooperative pool by BuildArtifacts
and GitWorktree scans. Its budget guard (childDepth <= maxDepth, before
the self-call) bounds production at defaultMaxDepth = 8 — no production
caller passes anything else (grepped: both scanner inits default it) —
but maxDepth was an unclamped parameter, so a test seam or future
caller could turn it into a crash.

MEASURED, red-first, through the real visit on the real executor
(Task.detached, mkdirat-chain fixture, env-gated exploratory cell):
depth 128 survived, 192 survived, 256 survived, 288 and 320 died with
signal 10 — a guard-page hit on the cooperative pool's small stack.
The crash band (257..288) matches PR #459 r14's freshContentBelow
measurement (~250-260) — the class this task exists to hunt.

FIX: walk() clamps maxDepth to stackSafeMaxDepthCeiling = 128 (2x
margin under the measured crash floor, 16x above the production
default; zero shipped behavior change). No runtime refusal exists to
word: the clamp is deterministic and disclosed at the constant.
Mutation: reverting the clamp kills
testAWalkAskedForACrashBandDepthSurvivesTheCooperativePool with
signal 10 (the r14 precedent: a signal death on revert is a
legitimate red); with the clamp it returns with the deepest event
exactly at the ceiling.

NEGATIVE RESULTS, recorded:
- DirectorySizer.enumerateTree: a while-loop over Foundation's deep
  enumerator (heap-backed, no per-level frame) — MEASURED at depth 350
  on the cooperative pool, walks to completion and reaches the leaf
  (testADeepChainMeasuresOnTheCooperativePoolWithoutAStackCrash).
- DepthSafeRemoval: iterative on purpose (its own doc/code) — the
  in-repo re-anchoring pattern this task treats as the replacement.
- EphemeralTempScanner.walkForFreshContent: iterative [URL] stack
  (fixed in PR #459 r14; pinned there, out of scope here).
- ValuablesDetector's ValuablesProbeWalk: an explicit frame machine
  (descend pushes frames; no self-call).
- Single-level listings only: CacheCleaner (category children),
  GitWorktreeInventory (admin container), InstalledAppResolver.
- Every other self-call hit in the sweep is overload dispatch, not
  recursion (CacheoutViewModel.handle/clean, TrashDisposal.look ->
  look(named:), FileSystemIdentityProvider.leafMetadata,
  GitWorktreeScanner.respell, GitCommandRunner.run, DevRootsStore
  .resolve, ValuablesDetector.acknowledgementToken/probe,
  EphemeralTempScanner.boundedFirstLevelNames,
  SpaceScanner.validatedOutcome, ProjectTreeWalker.issue).
- Mutual-recursion pass: manual read of the walker files; visit's
  self-cycle is the only cycle. Sources/CacheoutHelper has no tree
  walk; Watchdog/ sits outside Sources/ and the task's scope.

The exploratory env-gated cell (WALKER_DEPTH=n) stays, skipped by
default, for future re-measurement.
ProjectTreeWalker.swift:559-562 -> 578-581 (the +22-line
stackSafeMaxDepthCeiling doc moved the mount-check excerpt); caught by
testEverySourceAnchorStillPointsAtWhatItCites on the full run.
…ion — policy, dev-root resolution and union answer from the link's own content (fn-4.11)

Reproduced RED first in 8 cells: `PathGuard.validateContainerRoot`
canonicalized every configured root — full realpath(3), leaf included —
at runtime construction on the main thread, so a persisted dev root a
same-UID process re-aimed at an unresponsive mounted volume froze launch
before any window existed; `DevRootsStore.resolve`'s probe pass and
`SpaceScannerRuntime.suppressingAliasShadows` then paid the same
resolution again (and a dangling alias sent realpath's fallback walking
the destination's parent chain).

The fix is the fn-4.26 order applied at all three sites, with fn-6's
readlink technique as the spec directs: probe AS SPELLED first (lstat
no-follow), canonicalize only a spelling proven a real directory (the
resolved leaf then IS the object the lstat touched), and answer for a
symlink leaf from the link's OWN content — one readlink(2) plus the
lexical fold hoisted from EphemeralTempRoots to
FileSystemIdentityProvider.lexicalTargetPath, shared by all four
resolvers. The policy's deny verdicts survive destination-free: "/" and
..-escapes from the folded content, volume roots from the getfsstat
kernel table (never a path syscall), $HOME by string against both
spellings; the r15 kernel-table preflight now also refuses an
over-mounted DECLARED root at the policy and skips it in the union's
probe. Cross-scanner alias shadowing still suppressed, by NAME —
residual (third spelling: second hop, case variant, /var-style alias)
disclosed at all three sites and pinned as kept-both.

Mutation evidence, one arm at a time (all restored): policy preflight →
over-mounted cell red (contact + no refusal); canonicalize-first
restored → all five no-contact cells red at first contact; mount-table
arm → alias-of-mount cell red; "/" arm → both root-alias cells red;
home arm → both home-alias cells red; DevRoots drop arm → drop cell +
delete-time shadow cell red; DevRoots/union symlink canonicalize → the
production/resolution contact cells red; union drop arm → both shadow
cells red; union preflight → union-probe cell red. The dropped-alias
covering-key link in canonicalKeys was mutation-tested GREEN (no cell
reached it — a dropped spelling supports no admissible claim), so it is
retired rather than kept unevidenced, and the r16 sentence claiming it
is rewritten at both sites.

Also retired on the same evidence: EphemeralTempRoots' recorded r12
"one surviving contact" residual and its r15 symlink-to-mount residual
bullet (both closed by this change, history kept), and the
"canonicalize-before-check" wording wherever the proposition survived.
All shifted anchors repointed and re-verified by
SourceAnchorIntegrityTests.

The union probe's kind switch spells every KindProbe case explicitly —
the R4 no-`default:` fence over SpaceScanner.swift caught the first
draft (testNoExhaustiveReclaimActionSwitchGainedADefaultArm), which is
that fence doing its job.

Full suite: 1636 executed, 3 skipped, 0 failures (exit 0, 187 s).
…e — respell, repoint the population, and pin (fn-4 r2 gate)

Third anchor-rot occurrence in two rounds, and the root cause was a SPELLING:
the citations of CacheCleaner's two mount-refusal arms were written
`deleteGuardedChild`:1151 — a form SourceAnchorIntegrityTests' pattern cannot
see — so the suite could never redden on them and each gate caught the drift
by hand. Worse, the r1 fix repointed ONE citing site while ValuablesDetector
and OrphanedCachesScanner carried the identical stale pair: the
sweep-the-phrasing-not-the-claim failure, applied to anchors.

All three citing sites respelled to the canonical CacheCleaner.swift:1210 /
:1496 form, which the gate's DEFAULT-DENY rule then forces into the pin table
— three rows added, so the next insertion that shifts those arms is a red
cell, not a reviewer finding. The pre-existing stale :571 citation of
preDeleteUserDataProbe (actual :816, stale at base 8a38e3f) is repointed and
pinned the same way. The mechanism promptly proved itself: the +2 lines of
respelled citations shifted the pre-existing BuildArtifactsScanner
:1405-1406 pin, and the gate went red before this was committed; repointed to
:1407-1408 in both the pin table and the citing test comment.

CORRECTION OF RECORD for 9ce6b1d: its commit message claims "Anchors: 4
repointed", which was false against its own tree — it re-broke the
BuildArtifactsScanner:729 anchor the r1-gate commit 6bb24c0 had just fixed
(arms moved :1187->:1210, :1460->:1496 under fn-4.15's insertions). Corrected
forward here per the project's standing practice; the history is not
rewritten.

Suite: 1636 executed / 3 skipped / 0 failures, exit 0, 183 s, at this commit.
…itionFailure out of test sources (fn-4.14)

The PR #459 r15 strand diagnosed: a SHORT READ — both test socket clients
did one unframed read(2) on a newline-framed SOCK_STREAM reply, and the
truncated JSON fed a trapping cast. Reproduced red-as-crash (signal 6,
38 later cells never ran, last tally read 0 failures) and red-as-failure
after the XCTUnwrap shape; the casts themselves were converted on main in
8fa8ad3, so this closes what was left open:

- TestSocketClient.readNewlineTerminatedReply: loop to the newline, EOF,
  or a full buffer; both helpers now call it. Evidence cell drives a
  server that writes the reply in two spaced segments — single-read
  mutation red 10/10, framed loop green 10/10.
- StrandFenceTests: as!, preconditionFailure and fatalError promoted from
  'counted, not scanned' to statement traps (all zero occurrences, no
  allowance to rot). Mutation: one cell carrying all three shapes redded
  each arm by name at its line.
- CONTRIBUTING.md: a green TALLY is not a green RUN — trust exit code and
  executed count, never a greppable '0 failures' line.
…sserted a per-walk property (fn-4.16)

DIAGNOSED, not retried away. The suite is one process; a sibling cell's
deferred cleanup (StatusSocket accept handlers' deferred closes, Process
pipe teardown) can close descriptors mid-walk, so heldDescriptorCount()
minus a baseline is NOT the walk's descriptor count. That is exactly the
observed shape — 'census 2+1 vs measured -8', kernel count DOWN, which no
walk that closes only what it opened can produce. Alone the old cell was
green 20/20 (recorded negative result): the failure needed a sibling.
Neither the census nor the kernel was wrong; the cell's exclusivity
assumption was.

The cell now snapshots the fd SET around each attempt and classifies:
appeared-and-stayed = leak, fail at once; vanished = ambient churn, the
cross-check is unattributable, retry (bounded at 5 — a retry CAN differ,
the churn is transient sibling cleanup, not a deterministic limit);
set-clean disagreement = suspect, and a second one convicts the census.
Walk-internal invariants (completion, forced climb, observed mid-climb
peak, window bound) assert on every attempt. A new cell manufactures the
churn deterministically — closes eight held fds inside the first census
instant — and pins the classifier while reproducing the original
disagreement evidence.

Mutations: census transient over-counted by one → red 8/8 (two-suspect
conviction); classifier blinded to vanished fds → churn cell red; a
descriptor leaked inside the attempt → red on the leak arm. Fixed cells
green 20/20.
…ag gate, decision recorded (fn-4.23)

DECISION (of the task's two designs): the FULL per-tag gate, implemented
as a two-layer source census rather than 29 new behavioral cells — the
set-gate alternative cannot meet the task's own mutation bar, because
swapping two tags between arms leaves the set unchanged. Layer 1 pins the
ORDERED sequence of the 42 tag literals (33 distinct) at their definition
sites: any swap, insertion, removal, or rename changes the sequence, so
sequence equality subsumes the set gate (hence no second gate, per the
spec's 'do not add both'). Layer 2 sweeps the whole file for tag-shaped
literals and requires each to be censused or pinned as a non-tag, closing
the new-spelling hole a position grammar alone would leave. User-reaching
DETAILS keep their behavioral assertions in WorktreeReclaimPerformerTests
(the r17 lesson: 4 tags asserted there today); this gate protects the
routing discriminator underneath them.

Mutations, each red with the position and source line named: swapping
worktree-deregistered with worktree-not-linked (position 20, line 2290);
swapping the prunedAdminBinding pair (position 30); smuggling
'brand-new-tag' through a let binding (layer 2, line 2695).
…ion; bare EPERM is never TCC (fn-4.12)

The GUI derives the whole visible row from ScanIssue.Kind alone
(ScanIssueRowPresentation.label(for:)), so a kind shared with a different
condition prints a false diagnosis to the user and to wire consumers
(PROTOCOL.md scanner_errors[].kind). Audit of the full kind -> producer
matrix, r17 message-truth method (derive from the producing path, then
check the label):

  containerRefused "not a configured search root"
    - GitWorktreeScanner outside-every-root arm  TRUE, kept + pinned
    - GitWorktreeScanner scope arms (worktree inside a root)  FALSE
        -> NEW mutationScopeRefused (wire mutation_scope_refused)
    - DevRootsStore validateContainerRoot catch (detail says
      "configured dev root refused")  FALSE -> policyRefusedRoot
    - ProjectTreeWalker admitSearchRoot catch  FALSE for policy clauses
        -> policyRefusedRoot; mount standing at the root -> the
        mountedVolumeRoot ephemeral shape (table re-read per walk, so the
        label's "then re-scan" is true); notAConfiguredContainer keeps
        containerRefused (roots: is a parameter; that label IS the truth)
  symlinkRoot "symlinked - not searched"
    - ProjectTreeWalker root gate (any non-directory)  FALSE for
      file/FIFO/socket/device -> nonDirectoryRoot, split by probed kind
    - OrphanedCachesScanner rootNotADirectory  same split
    - DevRootsStore + EphemeralTempRoots alias arms  TRUE by construction
      (readlink-gated), pinned
  tccDenied "access denied by macOS privacy settings" + Grant-access link
    - bare-errno EPERM producers (ProjectTreeWalker forFailedOpen;
      DirectorySizer.denial(forFailedProbe:) feeding walker, sweep and
      sizer walks)  FALSE: a raw errno has no provenance (TCC, SIP,
      immutable flags indistinguishable) -> neutral unreadable/.metadata
      with the cause-not-established detail; the rule the ephemeral
      scanner already measured and recorded, now decided ONCE at the
      shared classifier. Chain-proven EPERM (classifyDenial via
      NSUnderlyingErrorKey) keeps tccDenied - the one assertable arm.
  unreadable / enumerationTruncated / config/tool/malformed/didNotFinish
    - audited, labels true; GitWorktreeScanner validation failures stay
      unreadable (state could not be faithfully read; recorded)

Wire: mutation_scope_refused is an ADDITION on the extensible enum
(schema stays 4, no string renamed); PROTOCOL.md, API-REFERENCE.md and
CHANGELOG call out every condition whose kind moved. cacheout-mcp needs
no update (measured in the task spec: forwards rows opaquely).

Residual, recorded in EphemeralTempScanner header (c): its sizing-path
.tcc neutrality is now over-conservative (the conflation that mandated
it died at the source) but asserts nothing false; out of fn-4.12 bounds.

Anchors repointed in the same commit (SpaceScanner +25 tail,
DevRootsStore +6 tail, DirectorySizer +5 tail, ProjectTreeWalker
578-581 -> 629-632, CLIHandler 2123 -> 2125, CacheoutViewModel
1487-1488 -> 1488-1489); SourceAnchorIntegrityTests green.
…ever discovered from a live leader (fn-4.27)

r18's ownProcessGroup read getpgid(pid) after run(), so the group fact
was contingent on observing a LIVE leader: a git that spawned a helper
and exited inside that window left group == nil, and terminate() then
signalled only the corpse's pid while the descendants -- the case the
group protocol exists for -- ran on holding the inherited pipe. The r18
comment called nil "never worse than before"; for the exited-leader case
it was exactly as bad as before. Comment updated to what is now true.

Fix: the runner spawns through its own SpawnedProcess -- posix_spawn
with POSIX_SPAWN_SETPGROUP (pgroup 0), so the kernel makes the child the
leader of a new group (id == pid) atomically at creation; pid IS the
group id for the process's whole life and nothing about signalling is
conditional on leader liveness. ownProcessGroup is deleted.

WHY posix_spawn over the trampoline option (spec asked): it keeps the
argv fence byte-identical (/usr/bin/env + ["git", ...], argv-only,
never a shell -- testTheNewGitFilesNeverConstructAShellString) and
ships no new binary (scripts/bundle.sh copies nothing it is not told
to -- the v2.1.0 lesson); a shell trampoline is banned by the fence, a
compiled one is a bundling liability.

Parity with Process, each stated in SpawnedProcess's doc: /dev/null
stdin via file actions, dup2'd pipe write ends, CLOEXEC_DEFAULT for
everything else, parent write-end close at launch (Process did this
implicitly; forgetting it starves the drains of EOF), and a bounded
pid-targeted waitpid(WNOHANG) poll in Process.waitForExit's own
deadline/backoff shape (waitUntilExit misses wakeups under concurrent
reaping -- house doctrine). terminationStatus maps signal deaths to
-(signal) so no real exit code is impersonated; callers test 0/127.

Cell (fn-4.27 acceptance):
testALeaderThatExitsImmediatelyStillHasItsDescendantReapedByPid --
leader records pid, spawns a TERM-immune descendant holding stdout,
exits 0; asserts .timeout, that the LEADER was already unsignallable
when the runner answered, and that the descendant dies BY PID.
Green 8/8 unmutated.

Mutation (acceptance): restoring post-launch discovery -- signalTree
requiring getpgid(pid) == pid before group-signalling, r18's
semantics -- RED 8/8 on that cell (the leader is provably reaped before
terminate, so discovery always fails and the descendant survives).
Negative result, recorded: restoring discovery at the LAUNCH spot
(r18's literal line) cannot be reddened deterministically -- the leader
is microseconds old there and always observable -- which is why the
mutation targets the semantics (group contingent on leader liveness)
at the point of use.

Full GitCommandRunnerTests: 32 tests, 0 failures on the new seam.
… the bare-EPERM rule; repoint the two anchors the fn-4.12/4.27 growth shifted (fn-4.12)

The full-suite gate caught what the targeted batches missed:

1. BuildArtifactsScanner.obstruction(at:errno:detail:) was a FOURTH raw
   bare-EPERM -> .tcc producer (a failed openat on the containment
   descent) the audit's grep of ScanIssue producers could not see - it
   classifies into an ITEM's SizeDenial, not a ScanIssue - and its .tcc
   becomes the item row's .tccDenied grant link. Same rule now: bare
   EPERM -> neutral .metadata with the cause-not-established caveat.
   Cell testDescentOpenEPERMClassifiesNeutrallyEACCESAsPermission (EPERM
   neutral + EACCES control); mutation restoring the .tcc arm reddens it.
   The walker's child-probe comment stating the retired proposition is
   updated with it.

2. ProjectTreeWalker.swift:629-632 -> :637-640 (the admission-catch
   rewrite grew the walker after the anchor gate had last run) and
   BuildArtifactsScanner.swift:1407-1408 -> :1416-1417 (the obstruction
   fix above), repointed with their citing sites in the same commit;
   SourceAnchorIntegrityTests green.
…ne — the prune tier's own case (fn-4.28)

Discovery keyed entirely on an entry named .git, and a bare repository
has none — so once its linked checkouts were deleted, no repository
group formed and the prune tier never ran for exactly the
all-checkouts-gone case it exists to reclaim. REPRODUCED first: bare
clone + worktree added + checkout rm'd -> scan returned zero items and
zero issues, silently.

The fix is a discovery kind, not a tier: GitWorktreeGitdirResolver
gains the bare-shape proof (HEAD a regular file whose content git's
validate_headref would accept, an objects directory, a refs or
reftable backend, and a config that declares 'bare = true' the way
git's writer spells it — which is what keeps a --separate-git-dir
git directory, same shape with bare = false, from being claimed).
Every probe runs through the injected identity provider, so the TCC
deferral answers first, and HEAD/config are read only behind their
probeKind gates (fn-4.26 ordering). The walk consumer admits the shape
only when the event's own lstat'd entries already show it — no .git
entry of any kind, HEAD/objects/refs kinds right — so unproved
directories cost no probe and provably reach no git subprocess.
Grouping canonicalizes the bare directory exactly as the main-checkout
arm canonicalizes <dir>/.git, so a bare parent reached both ways
(its own shape and a live checkout's gitdir: pointer) names ONE group
and pays for ONE listing (cell-pinned). Downstream is untouched: the
porcelain first record must still declare itself bare and
cross-validate against the same directory before anything acts.

Cells: the named discovery/prune cell (the task's mutation gate), the
looks-bare refusal (no item, no issue, no subprocess), per-requirement
forgery refusals with an intact-shape control, the deferral cell with
its own control, one-listing dedupe, detached-HEAD preservation on the
bare path, and the end-to-end cell that prunes through the production
composition and then asks git itself: clean registry, quiet fsck, and
a fresh 'git worktree add' that succeeds.

Residual, disclosed at the proof: a bare repository whose config
spells bareness any other way stays undiscovered — the same silent
non-discovery it had before, never dressed retryable.
…ication, not doubling; fan-in recorded, not built (fn-4.18)

MEASURE FIRST, per the task's own acceptance bar. On an
artifact-bearing tree (two populated node_modules, one Rust target,
one repo+worktree; 5772 entries):

  build WALK    249 probes  0.004s  (consumer prunes matched dirs)
  build SCAN   5772 probes  0.230s  (walk 249 + sizing census 5523)
  git   SCAN   5772 probes  0.212s  (walk prunes nothing, by design)
  union WALK   5772 probes  0.132s  (zero consumers = fused reach)

Codex's 'nearly double filesystem I/O and latency' is CORRECTED, not
inherited: the walks are asymmetric, the duplicated enumeration is
their intersection (the pruned build walk), and a fused walk must
carry the git walk's unpruned reach while the build scanner pays its
sizing census either way — so the fan-in buys 249 of 11544 entry
probes (2.2%) on the tree class these scanners exist for. The claim's
true half is a dev root with no artifacts and no repository: both
walks enumerate everything (496 probes each, ~7ms) and fusing halves
milliseconds.

DISPOSITION — scoped, not fixed, with the numbers: a shared walk must
be per scan session (the walk-instant witness the r16/r17 re-proof
chain hangs off can never come from an earlier session's walk),
scanners run concurrently AND individually, and nothing at the
SpaceScanner boundary names a session for two scan(context:) calls to
rendezvous on — coalescing on accidental concurrency would make the
walk count timing-dependent in the safety path. Buying the measured
2% means rewriting the protocol's 'a scanner does its own I/O'
contract, which the task's Boundaries forbid: record and stop.

The measurement is KEPT EXECUTABLE (DevTreeWalkMeasurementTests) with
the two load-bearing facts pinned: the pruned build walk is strictly
smaller than the git walk, and the git walk equals the zero-consumer
union reach. The disposition note sits at the walk step of
GitWorktreeScanner.scan, where the finding was anchored.
… text, retirement is a production declaration, extension fails closed (fn-4.22)

The r18 fact-set assembly made a false clause unrepresentable at any
call site; two surfaces remained, measured at 11/11 unanticipated
false wordings passing:

1. Remedy.text was free prose whose only check compared the rendered
   clause against the enum's OWN text — a tautology any prose
   satisfies. The enum now carries NO text: the remedy wordings live
   in sentence(for:), the one production wording table, and the fence
   pins them byte-exact per Remedy case instead of asking production
   to agree with itself.

2. A new Established case carried its own false sentence through
   every structural check, because each check is relative to tables
   the same author edits and the test-side neverEstablished list
   could not know about a case added after it. Three moves close it:
   - the unspoken set is DERIVED in the fence from established(for:)
     over every cause and required to equal a new production
     declaration, Failure.retired — a case claimed by no cause must
     be visibly retired, and a retired case must render nil;
   - the whole vocabulary, every wording, both remedy wordings and
     the placesTheItem verdicts are pinned BYTE-EXACT in the named
     cell testTheVocabularyAndEveryWordingArePinnedSoExtensionFailsClosed
     (default-deny, the SourceAnchorIntegrityTests shape): a new case
     or a reworded/grafted sentence is a red cell the moment it
     exists, with no test edit needed for the failure to fire —
     making it green again forces the sentence verbatim into the
     fence's own diff;
   - the r18-measured GREEN residual (a false clause grafted into an
     existing wording) reddens under the same pins.

What a pin deliberately does not claim: that a pinned sentence is
TRUE. The residual is disclosed BY MECHANISM at
TrashDisposal.Failure.sentence: (1) a coordinated wording+pin edit in
one commit — the fence forces the sentence in front of a reviewer,
it cannot judge it; (2) established(for:) admitting a proposition
its path does not prove — narrowed by placesTheItem and the measured
fixture cells, unnarrowed for non-placing facts.

Attack round (>= 10 fresh false wordings) follows in the next commit
with per-attack results.
…dings pass the pinned fence (fn-4.22)

Eleven unanticipated false wordings, each applied to production alone
and run through the full TrashDisposalHopProofTests filter, then
reverted: both remedy prose grafts (including the task's measured
passer), the r18-measured GREEN graft, a net-effect rewording, a
hedge strip, a non-placing proposition reworded into a placing claim,
three whole-new-Established-case rides (claimed, unclaimed, and
smuggled through retired with a sentence), and two derivation edits.
All eleven RED; baseline re-ran green between attacks as the control.
The per-attack table lives on the pin cell's doc.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: efc27604d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Cacheout/ViewModels/CacheoutViewModel.swift Outdated
Comment thread Sources/Cacheout/Scanner/ProjectTreeWalker.swift
Comment thread Sources/Cacheout/Scanner/GitCommandRunner.swift
… reported not to have run (PR #461 codex r1, P1)

FirstWinsRendezvous decides which OUTCOME is reported. It cannot decide
whether the work STARTED, and for a destructive child those are different
questions. A detached task still queued when the off-pool timer wins observes
nothing; the timeout branch sees `process.isRunning == false`, truthfully
reports that nothing is running, re-enables the button — and the task is then
scheduled and calls `run()`, launching an unowned `docker system prune -f`
after the operation was reported timed out, free to overlap the retry the user
was just invited to make.

The starvation this off-pool timer exists to survive is exactly the condition
that keeps the task queued, so the window is widest when it matters most.

New LaunchClaim: both sides claim through one lock, so the pair is decided
once. `begin()` false means DO NOT START; `abandon()` false means it already
started and the caller owns stopping it. The timeout branch now reads
`launch.didStart, process.isRunning` — stating which case happened rather than
inferring it from a Process that answers false when it never launched.

MUTATION (begin ignores a prior abandon): 504 assertion failures. The
evidence is not a sequenced imitation of the race — testUnderContention...
runs 500 concurrent begin/abandon pairs and requires EXACTLY ONE side of each
to win: both true is a prune launched after being reported stopped, both false
is a prune neither run nor accounted for.

Suite: 1665 executed / 3 skipped / 0 failures, exit 0, 214 s, at this commit.
The paragraph has said MOUNT FIRST since fn-4.12 while the root probe ran
above it. On an unresponsive mounted volume that `lstat` never returns: the
walk hangs, the `.mountedVolumeRoot` issue the paragraph promises is never
emitted, and the session reaches its watchdog leaving a blocked worker behind.
The comment described the contract; the order did not honour it — this
branch's most-retired defect class, found in the fix that wrote the comment.

The kernel table is memory and costs no filesystem call, so asking it first is
free. Reordered; the absent-root probe now follows, which is what makes the
hang unreachable.

EVIDENCE, and why it counts rather than measures: a hanging provider cannot
prove this — it would hang the suite, which IS the defect. So the new cell
counts instead. A provider that records every probe must record NONE for the
mounted root, and recording none is exactly what makes the hang impossible.
MUTATION (probe restored above the table): red, naming the recorded probe of
the mounted root.

Anchor: this round's insertions shifted ProjectTreeWalker.swift:637-640 ->
:650-653; repointed in both the citing prose (BuildArtifactsScanner.swift:735)
and anchorExpectations. Fourth payout of that gate this campaign.
…ns a different child (PR #461 codex r1, P2)

Every `posix_spawn_file_actions_*` and `posix_spawnattr_*` return value was
discarded. These calls allocate, so under transient pressure they answer
ENOMEM — and discarding that is not a lost message. A dropped `adddup2` leaves
git's stdout attached to whatever descriptor 1 already was: git exits 0, the
drain sees an immediate EOF, and the EMPTY buffer is accepted as a complete
answer. That is precisely the class fn-4.24 closed at the execute boundary,
re-entering through the spawn fn-4.27 introduced. A dropped attribute call
defeats fn-4.27's group isolation just as quietly.

Every setup call is now `try require(...)`d, throwing SpawnFailure. The caller
maps it to `.gitUnavailable`, which fn-4.25 retries rather than caches — and
ENOMEM is transient, so that retry can genuinely differ.

NEGATIVE RESULT, recorded in the cell rather than worked around: no
behavioural cell can stage this failure. `launch` takes Pipes and builds its
own descriptors; `adddup2` does not validate that a descriptor is OPEN at
setup time, so a closed pipe throws from `fileDescriptor` one layer earlier
and never reaches the guard. Staging real ENOMEM is not available to a test,
and reshaping `launch` to take raw descriptors purely to make the failure
reachable would widen production API for evidence — which this project
declines. A visibility widening tried for that purpose was reverted.

So the property is asserted where it lives, in the same shape as the
waitUntilExit gate (fn-4.20) and the refusal-tag census (fn-4.23): a source
fence over the spawn path, with a vacuity guard (>= 6 setup calls must be
found, or the fence has gone blind).

MUTATIONS: dropping one `try require(` reds naming GitCommandRunner.swift:785;
renaming the calls so the scan matches nothing reds the vacuity guard.
…its one call site (gate r3 P1)

`LaunchClaim` is correct and `LaunchClaimTests` proves it — the gate
independently re-measured RED 12/12 on the near-miss. But every one of those
cells builds its own claim and calls `begin` itself. Nothing held
`dockerPrune`, the ONLY production user, to keeping the launch inside the
body: the claim takes a closure, so `begin({})` is writable and the launch is
free to drift back to the next statement, which is the original defect rather
than a variant of it. The gate restored that two-statement shape here and all
1667 cells stayed green.

A test cannot hold this boundary. The damage needs the timer to land in a
fork/exec-wide window, so any cell for it samples rather than proves — I
wrote one, it went green 3/3, and the vacuity floor I added then showed
16/16 rounds reported the arm with ZERO children ever observed, because the
correct shape kills the child before it can write. That green was hollow.

So the boundary moves into the type. `ClaimedProcess` owns the `Process`,
builds it from executable/arguments/environment/pipes, and never hands it
out; `start()` is the only launcher and decides under the claim's lock.
There is no `process` in scope at the call site any more.

  VERIFIED: the gate's mutation applied verbatim now fails to build with
  `cannot find 'process' in scope`.

  LIMIT, measured and disclosed at the type: a caller can still construct
  its OWN Process and run it (that compiles). But that starts a DIFFERENT
  child than the one the claim guards — a visible act, not silent drift —
  and `didStart` would contradict it. The boundary is "the claimed child
  cannot start unclaimed", not "nothing may ever be spawned here".

CacheoutViewModelTests 56/56, LaunchClaimTests 6/6, GitCommandRunnerTests
34/34.
…ad (codex r2)

Codex named `HEAD` and `config` in the bare-repository probe. The population
is EIGHT: seven `String(contentsOf:)` and one `Data(contentsOf:)`, across
`GitWorktreeInventory`, `pathContents`, and `WorktreeReclaimPerformer` — a
DELETION path. Every one asked `probeKind` about a PATH and then handed that
same PATH to a reader that resolves it again and FOLLOWS symlinks. Fixing
only the two named would have left six with the identical shape.

Three defects, one primitive:

1. TOCTOU. A `HEAD` or `config` replaced by a symlink between the probe and
   the read was opened through the replacement, so an automatic scan of a
   dev root can be steered into a TCC-protected or unresponsive target
   despite a check that just said "regular file". A path is not an identity,
   and asking twice is asking about two objects.

2. UNBOUNDED READS. Any directory under a dev root with the cheap
   bare-repository shape but a multi-gigabyte `HEAD` was loaded and UTF-8
   decoded in full before anything decided it was not a repository. The size
   is now checked against the DESCRIPTOR and the file REFUSED, never
   truncated — truncating would let a huge file whose first bytes spell
   `ref: refs/…` pass as a valid HEAD.

3. `WorktreeReclaimPerformer.HeadWitness` resolved its file THREE times
   (probeKind, identity, bytes), so the witness could pair one object's
   inode with another's bytes — and that witness is what the reclaim proves
   the far side against. Kind, identity and bytes now come from one
   descriptor.

`O_NONBLOCK` is not incidental: `O_NOFOLLOW` does not save the open of a
FIFO, and a named pipe left at one of these names parks the opening thread
until a writer appears.

9 new cells, 9/9 green. MUTATION, each alone and measured:
  - drop O_NOFOLLOW      -> reds the symlink cell, nothing else
  - drop BOTH size checks -> reds the oversize cell, nothing else
                             (dropping one is caught by the other; the file
                              can grow between them, so both stay)
  - drop S_IFREG         -> reds the FIFO cell
  - drop O_NONBLOCK      -> THE SUITE HANGS, killed at 300 s with no tally.
                             That is the guard's point, and why the FIFO cell
                             asserts wall-clock: a reader that parks does not
                             fail, it stops.

Limits named once (`gitPointerByteLimit` 64 KiB, `gitConfigByteLimit` 1 MiB).
A real repository whose config exceeds the limit stays UNDISCOVERED — the
same silence every bare repository had before fn-4.28, disclosed at the
constant, never a refusal dressed as retryable.

GitWorktreeInventoryTests 97/97, WorktreeReclaimPerformerTests 153/153,
GitConfigBarenessTests 12/12, SmallRegularFileReadTests 9/9.
…ys done it the other way (codex r2)

For a permanently deleted item whose scanner registers no revalidator —
every shipped scanner without one — the leaf binding was taken AFTER
`sizer.measure`. That left a window the whole measurement wide: rename the
target away, install a stranger at the same name, and the binding recorded
the STRANGER. The far-side proof then succeeded (it proved the stranger
against itself), the stranger was destroyed, and the report credited the
ORIGINAL tree's bytes. Contents mode binds first and always has; only this
arm was backwards, 300 lines apart in the same file.

The binding and its `admittedParent` capture move above the measurement,
which is where contents mode has them.

  NEW CELL `testItemModeTargetSwappedDuringTheMeasurementIsRefused`: swaps
  the target when the SIZER reaches the payload. MUTATION (bind after the
  measure, as before) -> RED 3/3, and the mutant reproduces the report
  exactly: "the stranger was DELETED" plus an entry crediting 4096 bytes of
  a tree it never touched. Unmutated GREEN.

THE PRE-MEASURE BINDING IS `try?`, WITH A FALLBACK, and that is not
incidental. A hard `try` re-tagged the absent-target arms: two
OrphanedCaches cells refuse a directory that appears at a name the probe
found ABSENT, and they assert the refusal MESSAGE on purpose — "had the
replacement landed BEFORE the probe, the probe would have walked it and
refused with user-data-shaped content instead, so the fixture cannot
silently degrade into testing the other arm". A ghost leaf now yields nil
here and the original read still stands exactly where it always did, same
call, same point, same failure. The new binding only ever ADDS a refusal;
it never moves one.

Three fixtures keyed on provider CALL ORDER needed re-arming, and the
production order is why:
  - SwapAtTheDisposalContainerProofProvider counted descriptor-identity
    questions after the revalidator gate. The admitted-parent capture used
    to be #1 and the disposal's container proof #2; the capture now happens
    before the gate, so the proof is #1. What the swap targets is unchanged.
  - TempRaceWonAtTheFinalCheckProvider opened its window on ANY
    descriptor-identity call. Two now happen before the revalidation, so it
    opened too early and the swap landed ahead of the revalidator's own path
    check — which caught it, making the cell prove a different guard than
    its name. It now gates on `ownerUID(ofDescriptor:)`, the revalidator's
    own accessor, as its sibling fixture already did.

`token` and the success tail moved inside the widened `do`; a failure on any
path still reaches the catch with the token abandoned.

Anchor CacheCleaner.swift:1496 -> :1543 at all four citing sites.
Full suite: 1692 executed, 3 skipped, 0 failures, exit 0.
…eported as success (gate r4 P4)

LIVE DATA DESTRUCTION, reproduced on 70f4376 before the fix: item mode, a
scanner with no revalidator, a target already unlinked when cleaning starts.
The pre-measure bind answers absent, so `preMeasureLeaf` is nil; the `??`
fallback then re-reads the leaf at the original point, and if an object has
appeared in between it binds THAT one. The far-side proof succeeds — it
proves the newcomer against itself — the newcomer is destroyed, and the item
reports SUCCESS with `errors: []`.

The r2 comment said that fallback was "the original read … same call, same
point, same failure". The third clause was false: the re-read can SUCCEED,
on a different object. Nothing inspected it and nothing measured it, so the
report has no bytes to be wrong about; it simply deletes a stranger and
calls it a success.

Pre-existing rather than introduced by the r2 hoist — the hoist closed this
window for leaves that bind pre-measure and left it open for ghosts — and it
was disclosed nowhere. The population with the hole is exactly the one the
r2 commit said it was fixing.

The re-read still happens, because a still-absent leaf must raise its ENOENT
at the original point with the original identity: the absent-target arms pin
that message on purpose, so a fixture "cannot silently degrade into testing
the other arm". What changes is what a SUCCESSFUL re-read means — a drift
event, refused as `.notTheInspectedObject` (tag `content-drift`), not a
target.

  NEW CELL `testAStrangerArrivingAtAGhostTargetIsNeverDeleted`, written
  BEFORE the fix and RED on it 3/3: "a stranger that arrived at a GHOST
  target's name was DELETED", plus a success entry and zero errors. GREEN
  after. It carries a vacuity floor (`provider.planted`).

Deletion-path suites after the fix: CacheCleanerTests, OrphanedCaches 120,
EphemeralTemp, WorktreeReclaimPerformer — 436/436 green.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-06T07:49:14.576594Z 6dc0942 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…nce read one file (gate r4 P1, P2, P3)

P1 — the unchecked `map { strdup($0) }` that 2d5b351 fixed in the spawn path
was ALSO in `CLIHandler`'s `execv` re-exec, on the shipped Homebrew install
route. Nil is argv's TERMINATOR: a failed copy of element k truncates the
command, so `cacheout install-helper` through the documented symlink
re-execs into a no-subcommand `cacheout` — and the process image is already
replaced, so nothing can report it. Every copy now goes through a nil-check
and a failure declines the re-exec instead of performing a different one.

The fence could not see it because it read ONE FILE. That is the third time
this fence has been beaten by moving one scope out: rebuild 3 keyed on a
CALL (a local alias walked past), rebuild 4 on the SYMBOL but within one
function BODY (the alias hoisted to type scope), rebuild 5 on one FILE (the
defect moved to a sibling file). The scope is now every Swift file the
repository tracks, via `git ls-files` — the repository's own list, not the
machine's. There is nowhere left to move.

P3 — the allocation rule matched `\bstrdup\b` alone under a heading reading
"EVERY ALLOCATION", so `strndup`, `malloc` and `calloc` walked past a cell
whose name promised otherwise. It is now the family, and the wrapper is
`guard let` / `if let` rather than one spelling.

P2 — the scanner blanked string CONTENTS, which also blanked interpolated
EXPRESSIONS, so `_ = "\(posix_spawn_file_actions_addclose(&a, 5))"` compiled,
ran, and was invisible to every rule. Blanking strings was itself rebuild
5's mitigation for a different escape: the fix introduced the hole.

`scannableSource` is now a real Swift lexer — nested block comments, plain /
raw (`#"…"#`, any hash count) / multiline strings, correct escape and
interpolation handling per hash count — because three of five rebuilds were
defeated THROUGH the scanner rather than around it, and because refusing
files with raw or multiline literals is not available: the fence must cover
`CLIHandler.swift`, which has both. It carries 8 cells of its own, each
asserting newline count so a scanner that eats a newline cannot send a
reader to the wrong line.

VERIFIED, 10 variants built and run across two files:
  RED   unchecked strdup in CLIHandler (P1) · call inside an interpolation
        (P2) · unchecked strndup · unchecked malloc (P3, both)
        alias at type scope · raw literal + laundered call · bare setpgroup
  GREEN module-qualified · wrapper split across lines · `if let` allocation
10/10, 0 mismatches. Per-rule vacuity floors: 7 spawn symbols, 2 allocations,
plus a file-count floor and a required-membership check for the two files
that actually hold a guarded allocation.

GitCommandRunnerTests + CLIHandlerTests 42/42.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c57a0d7a14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Cacheout/Scanner/GitWorktreeScanner.swift Outdated
Comment thread Sources/Cacheout/Scanner/GitCommandRunner.swift
…e claims that do not reproduce (gate r4 P6, P7, P8)

P7 — the SpaceScanner probe anchor shipped ROTTED through 1a3bb64, the very
commit whose job was repointing anchors. That commit measured the +14 shift
correctly and repointed six anchors; it missed this one. The integrity cell
stayed green because it asks only whether the excerpt lies SOMEWHERE inside
the cited range, and 19 lines of range absorbed 14 lines of drift — so six
citing sites pointed at a doc-comment continuation instead of the probe.
Repointed to :2102-2120 at all six.

The tolerance that hid it is now disclosed precisely, with the known fix
named: carry each excerpt's expected OFFSET within its range and assert it
exactly. Measured across the 58 rows, offsets legitimately run 0 to 58, so
no fixed tolerance works and the offsets must be recorded per row — a schema
migration that belongs in its own increment rather than bolted onto a
review-fix round. (Writing the historical range into that disclosure made
the gate red on it, correctly: default-deny treats any `File.swift:NNN`
spelling as a live pin. Respelled as prose.)

P6 — the byte-limit disclosure said a file past the limit "stays
UNDISCOVERED … never a refusal dressed as retryable". True of
`gitConfigByteLimit`, whose one path treats nil as "not a bare repository".
FALSE of `gitPointerByteLimit` at two sites: a `.git` pointer past it yields
`ambiguous` ("Re-scan once that path is settled") and a `gitdir` back-link
past it yields `.incomplete` and then "the prunable set is not provably
complete". The limit is a FIXED CONSTANT, so for that cause a retry can
never differ — a permanent strand wearing a retryable message, the class
this project refuses everywhere else. Kept (the messages are shared with
genuinely transient causes, and no git writes a 64 KiB pointer file) and now
disclosed at the constant AND at both refusal sites.

P8 — two evidence claims in 70f4376 do not reproduce, both mine:
  (a) "three fixtures keyed on provider CALL ORDER needed re-arming" — the
      diff contains exactly TWO, and the commit then enumerates two.
  (b) "the mutant … crediting 4096 bytes of a tree it never touched" —
      measured: the entry is `exactBytes: 0, estimatedUpToBytes: 0`, because
      the sizer's size read lands after the rename. The defect is unchanged
      and no smaller — a stranger nobody inspected is destroyed and reported
      as SUCCESS — but the bytes half was wrong, and a wrong detail in an
      evidence note is how the next round is sent looking in the wrong place.
Corrected at the cell, where a future round reads, not only here.

CacheCleanerTests 110/110, SourceAnchorIntegrityTests 6/6,
GitWorktreeInventoryTests + anchors 56/56.
…4 P5)

`smallRegularFile` shipped with nine cells and they proved nothing about
whether anything USES it: the gate reverted two of the eight converted sites
to their pre-PR `String(contentsOf:)` / `Data(contentsOf:)` shape and the
full 1692-cell suite stayed green.

The steady state is not the gap, which is why this was easy to miss. Every
one of these sites still asks `probeKind` first, so a symlink merely SITTING
at the name is refused under either shape. What the descriptor buys is the
RACE — the probe answers about a path and a path-based read then resolves
that path AGAIN — so a cell has to stage the window. These fixtures plant the
symlink on the way out of the probe, truthfully answering `.regularFile`
about a file that IS one, and swapping only the timing.

  MUTATION, measured: reverting the `HEAD` read reds
  `…ABareRepoProbeDoesNotFollowASymlinkPlantedAfterTheProbe` and nothing
  else; reverting `pointerPath`'s read reds
  `…AWorktreePointerReadDoesNotFollowALatePlantedSymlink` and nothing else.

COVERAGE IS TWO OF EIGHT, disclosed at the file rather than implied away.
The other six — including `WorktreeReclaimPerformer.headWitness`, which its
own converting commit called "what the reclaim proves the far side against"
— still have no call-site cell. The two here are the two distinct mechanisms;
the rest being the same shape is a reason to expect them correct, not
evidence that they are.

Both cells carry a control that must resolve with nothing swapped, and the
second one earned it twice over: its first version was GREEN AND VACUOUS,
answering nil because the fixture wrote a `gitdir:` prefix into the admin
back-link where real git writes a bare path. Without the control it would
have reported the read as guarded when nothing had been read at all — the
same failure shape as the hollow cell in gate r3.

GitMetadataReadCallSiteTests + GitWorktreeInventoryTests +
SmallRegularFileReadTests 61/61.
…onger indexes

`StrandFenceTests.testNoLoopBoundIndexSubscriptCanStrandTheRun` flagged seven
integer subscripts in the scanner and tally added for gate r4 — correctly: a
loop-bound index traps and kills the PROCESS, not the cell, which is how 985
and 493 cells were skipped at earlier rounds.

`XCTUnwrapElement` is the sanctioned idiom but it is an assertion helper, and
a lexer's inner loop is the wrong place for one. The scanner now consumes an
`ArraySlice` from the front — `first`, `starts(with:)`, `prefix`, `dropFirst`
— so it cannot read past the end AT ALL. Bounds safety is now a property of
the operations rather than of my arithmetic, which is a better answer than
either subscripting carefully or asserting per character. The per-rule tally
is keyed by rule name instead of indexed for the same reason.

Re-verified after the rewrite, because a scanner rewrite invalidates every
result that depended on it: the same 10 fence variants behave identically
(9 measured here, the `if let` reformat measured separately as GREEN), the
scanner's own 8 cells pass, StrandFenceTests 14/14, GitCommandRunnerTests
41/41.

Full suite: exit 0, 1702 executed, 3 skipped, 0 failures, "All tests passed"
printed once, 0 error lines — whole log captured and grepped, not tailed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21c48b523a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Cacheout/Scanner/GitWorktreeInventory.swift
…codex r3)

`.gitUnavailable` carries two causes and only one is permanent:
`/usr/bin/env` answering 127 means git is genuinely not on PATH, while a
throwing launch is ENOMEM/EAGAIN/EMFILE under momentary pressure. Since this
PR made every spawn allocation checked (2d5b351), a failed `strdup` now
arrives on that same path — so the hardening created new traffic through a
door that was already mislabelled.

`unavailabilityIsDefinitive` has recorded the distinction since PR #460 r21.
Two consumers ignored it.

SCANNER (behavioural). A transient failure withdrew the WHOLE scan and
published `.toolUnavailable`. That branch justifies itself with "the runner's
availability verdict is instance-cached, so no further repository could
succeed anyway" — false for the transient case precisely because a
non-definitive verdict is deliberately NOT cached. Now only the definitive
answer withdraws; a transient one is an `.unreadable` issue for that
repository and the scan continues, so the comment is true again.

CLEAN TIME (message). Three sites told the user to "Retry once git is
installed and reachable" — a false instruction when git IS installed. The
refusal was and remains correct; only the remedy was wrong. Wording now
splits on the cause.

  MUTATION 1: drop the scanner's `guard listing.unavailabilityIsDefinitive`
  -> RED 3/3, exactly `testATransientLaunchFailureIsReportedPerRepositoryNot
  AsAMissingTool`, with its definitive-case control staying green.
  MUTATION 2: collapse the clean-time ternary back to the installed wording
  -> RED 3/3, exactly `testATransientLaunchFailureAtTheGateDoesNotTellTheUser
  ToInstallGit`, with its definitive sibling green.

Both new cells carry vacuity floors (a git invocation must actually have been
attempted). The withdrawal branch had NO behavioural coverage before this —
the two new scanner cells are its first.

Both test stubs defaulted `unavailabilityIsDefinitive` to false, so every
pre-existing "tool unavailable" cell was silently exercising the TRANSIENT
answer while meaning the definitive one. Both now default to definitive and
expose a knob, which is what those cells meant all along.

GitWorktreeScannerTests 54/54, WorktreeReclaimPerformerTests 98/98,
GitWorktreeReclaimActionTests green.
…s closed (codex r3)

`[core] bare = true` followed by `[include] path = …` whose included file
sets `core.bare = false` is a NON-bare repository to git. `declaresBare`
examined only the primary config and answered true.

That is not a silent miss. The directory is admitted as `.bareRepository`,
git's own listing then reports it non-bare, `crossValidate` fails, and a
RECURRING `unreadable` issue is published on every scan for a healthy
repository this scanner deliberately does not cover.

Codex offered two routes: resolve includes, or fail closed. Resolving them
means following an untrusted indirection out of a directory nothing admitted
— `~` expansion, relative resolution, `includeIf` conditions, recursion,
cycles — which is the same class of indirection the metadata reads already
refuse by opening `O_NOFOLLOW`. So: an include that could reach `core.bare`
makes the answer "not bare". That is the under-discovery direction, costing a
silent non-discovery instead of a false claim.

Keyed on the `path` key git actually acts on, not on the section name, so an
`[include]` section without a path does not suppress a genuine answer.

CORRECTED ON THE RECORD: 1913dbc's residual listed `include.path` among the
spellings that "leave the repository undiscovered". That was wrong — it only
considered the under-discovery direction, and an include was in fact making
this code declare bareness it should not have. The claim is now true by
construction rather than by accident, and the disclosure says so.

  MUTATION, measured: delete the include guard and exactly THREE cells red
  (the include, includeIf, and include-before-value cases), 3/3 runs. The
  fourth, `…AnIncludeSectionWithoutAPathDoesNotSuppressBareness`, stays
  green — which is what shows the guard is keyed on `path`, not the section.

GitConfigBarenessTests 16/16, GitWorktreeInventoryTests + scanner 120/120.
…s metadata was read (codex r3)

`bareRepositoryGitDirectory` reads `directory/HEAD` and `directory/config`,
and the `O_NOFOLLOW` those reads gained earlier in this PR protects only the
LEAF. Path resolution walks through a symlinked `directory` before the leaf
is ever opened. The caller reaches this validation from a walk that produced
`event.entries` earlier, so a directory renamed away and replaced with a
symlink in between was validated — and could then aim `git worktree list` at
a repository outside the configured root, or block on an unresponsive
replacement.

The directory is now probed no-follow before anything inside it is read.

RESIDUAL, disclosed at the site and NOT closed here — it is the larger half.
This stops the SYMLINK replacement, not the identity question. A replacement
that is itself a real directory still passes, because `ProjectTreeEvent`
carries only a URL: no identity, no descriptor, so nothing in this function
can prove it is looking at the directory the walker enumerated. The complete
fix is descriptor-relative validation bound to the walk's open directory,
which means adding identity to that event and threading it through BOTH
scanners that consume it. That is an architectural change to a shared type
and belongs in its own increment, reviewed on its own, rather than smuggled
into a review round.

  MUTATION: delete the `probeKind(of: directory)` guard -> RED 3/3, exactly
  `testASymlinkedDirectoryIsNeverValidatedAsABareRepository`.

The cell carries a control asserting the symlink's TARGET is a repository
this proof accepts, so the refusal is attributable to the symlink rather
than to the target's shape.

GitWorktreeInventoryTests 51/51.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08d71299b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Cacheout/Scanner/GitWorktreeScanner.swift
… its task

08d7129 closed the symlink half of the walked-directory finding and disclosed
the identity half as deferred. A disclosed residual with no ticket is a note
that survives exactly as long as someone remembers to look for it, so the
comment now names where the work lives:
fn-5-stale-git-worktree-scanner.7.

That task carries the full context — both candidate shapes (identity binding
vs descriptor-relative), the shared-type blast radius through
BuildArtifactsScanner, the TCC and hung-mount constraints, and acceptance
criteria that include retiring this very comment.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34f3d4a441

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Cacheout/Models/FirstWinsRendezvous.swift
…zed an unbound path (codex r4)

The linked-worktree arm has carried an `AdminWitness` — path AND identity, as
of the instant the walk observed it — since PR #460 r17. The bare arm, added
later in fn-4.28, kept only a URL. `repositoryGroups` then canonicalized that
URL unbound.

`canonicalize` is `realpath`, which resolves every component: on a directory
renamed and replaced after the walk consumer returned, it both FOLLOWS the
stranger and can block on it, and that same stored path becomes the
`listingTarget` — putting `git worktree list` on a repository outside the
configured dev root.

A bare discovery now carries its identity from the instant the bare-shape
proof accepted it, and grouping re-proves it BEFORE `realpath` touches the
path. A drifted discovery contributes nothing — the same silent
non-discovery this arm already takes when a pointer fails to resolve, and the
safe direction.

  MUTATION: delete the re-check -> RED 3/3, exactly
  `testABareDiscoveryWhoseDirectoryDriftedIsNotGrouped`.

The cell drives `repositoryGroups` directly, with a control that must group
when the witness matches. A first attempt drove it through a whole scan with
a provider that drifted its answers mid-flight, and it FAILED HONESTLY:
something earlier in the scan already asks that path for its identity, so the
capture and the re-check both saw the drifted value and agreed. Timing a race
through two unknown call sites proves less than calling the function with the
two states it must distinguish — recorded at the cell so the next round does
not retry the harder, weaker version.

Full suite: exit 0, 1711 executed, 3 skipped, 0 failures, "All tests passed".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1e8188dd9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Cacheout/Scanner/GitWorktreeScanner.swift Outdated
…lease path built it anyway (codex r4)

`Cacheout.xcodeproj/project.pbxproj` is GENERATED from `project.yml` and is
also checked in, so it can silently drift — and it had. THREE tracked sources
were absent, not the one reported: `DepthSafeRemoval.swift` and
`TrashDisposal.swift`, both deletion primitives missing since mid-August, and
`FirstWinsRendezvous.swift`, which carries `LaunchClaim` and `ClaimedProcess`.
The file's last change was a MERGE, which is what generated artifacts do when
nothing watches them.

`swift build` never notices, because SPM globs the directory. The path that
notices is the one that SHIPS: `build-dmg.sh` regenerated the project only
`if command -v xcodegen` and otherwise built the stale copy, where those
symbols cannot resolve. It went unnoticed here because xcodegen IS installed
on this machine, so the local release path always regenerates and the
checked-in copy is never exercised.

Three changes:

- Regenerated the project. The diff is surgical — 12 insertions, 0 deletions,
  exactly the three files — so the checked-in copy is now accurate.
- `XcodeProjectCoverageTests` asserts every tracked `Sources/*.swift` appears
  in it, listed via `git ls-files` rather than a directory walk so the verdict
  is a property of the repository and not of whatever is lying around
  untracked. MUTATION: restoring the stale project reds it, naming all three
  files exactly.
- `build-dmg.sh` now FAILS CLOSED when xcodegen is absent, with a message
  that says what is wrong, instead of building a project nobody regenerated
  and surfacing a confusing compile error deep inside xcodebuild.

Pre-existing rather than introduced here: two of the three files predate this
branch. The PR's own new file simply joined them.
…mands produced the same tiff

Found while verifying the previous commit, and NOT caused by it: the control
run proves it. Restoring the stale project reproduces the identical two
errors, so this predates the branch.

    error: Multiple commands produce
      '.../Cacheout.app/Contents/Resources/MenuBarIcon.tiff'
      note: has copy command from Sources/Cacheout/Resources/MenuBarIcon.tiff
      note: TiffUtil .../MenuBarIcon.tiff

`Resources/` ships a checked-in `MenuBarIcon.tiff` AND the `@1x`/`@2x` PNG
pair. The SPM path copies the `.tiff` verbatim — which is why it is checked
in, and why a release once shipped with menubar assets missing. Xcode instead
GENERATES its own `.tiff` from the PNG pair via TiffUtil. Feeding both into
one resources phase makes two commands claim the same output, and xcodebuild
refuses the whole build at PLANNING — before compiling a single source, which
is why nothing in the Swift suite could ever have caught it.

Neither toolchain is wrong on its own: the checked-in artifact is required
for one and a duplicate for the other. So the prebuilt `.tiff` files are
excluded from the Xcode resources phase only, and stay on disk untouched for
`scripts/bundle.sh`.

VERIFIED by building, not by reasoning:
  - before: BUILD FAILED, 2 errors, zero sources compiled
  - after:  BUILD SUCCEEDED, 0 errors, and DepthSafeRemoval, TrashDisposal
            and FirstWinsRendezvous all compiled
  - the built app still contains MenuBarIcon.tiff and
    MenuBarIconTemplate.tiff, so TiffUtil produces what the copy used to

This also completes the check the previous commit could not make: the release
toolchain now resolves the three sources that were missing from the project.

No Swift source changed, so the suite is unmoved from the 1711/0 run at
1bf97cd; XcodeProjectCoverageTests and SourceAnchorIntegrityTests re-run green.
…uld witness a stranger (codex r5)

The r4 fix carried a bare witness so grouping could re-prove the directory
before `realpath` touched it — but it captured that identity AFTER
`bareRepositoryGitDirectory` returned. A replacement landing in that gap was
recorded as the witness, and grouping then agreed with itself about the
STRANGER and canonicalized it into a `git -C` target outside the configured
root. Closing one window by opening a narrower one is not closing it.

The capture now brackets the proof — capture before, validate, require
unchanged after — so the identity carried forward is the identity of the
object whose metadata was actually read. Extracted as `bareWitness(for:...)`
so the two ends cannot drift apart in a later edit.

  MUTATION: restore the capture-after shape -> RED 3/3, exactly
  `testAReplacementDuringValidationYieldsNoBareWitness`.

The cell drives that seam directly with a control that must witness a healthy
repository, plus a floor asserting the identity was asked at least twice —
a capture that is never re-checked is not a bracket.

RESIDUAL, disclosed at the function: this is bracketed, not descriptor-bound.
An object swapped out and back inside the bracket is indistinguishable by
inode, and the reads inside `bareRepositoryGitDirectory` still resolve by
path. The descriptor-bound answer is filed as
fn-5-stale-git-worktree-scanner.7.

Full suite: exit 0, 1713 executed, 3 skipped, 0 failures, "All tests passed".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76ba737513

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Cacheout/Scanner/GitWorktreeScanner.swift Outdated
Comment thread Sources/Cacheout/Scanner/GitWorktreeInventory.swift
… no witness at all (gate r5 P1, P2)

Two findings, one defect: I fixed the arm the reviewer named and left its
siblings, twice.

P1 — r4 made grouping re-prove a bare discovery before `canonicalize`. That
binds `realpath` and NOTHING else. The same unproven URL was carried on as
`listingTarget` and handed to `git -C`, with the rest of the grouping loop,
every EARLIER group's full processing (each awaiting git subprocesses), two
deferral checks and an admin-container enumeration in between. That window is
unbounded and GROWS with the repository count — the exact shape this file
already condemns for r16. The gate built a fixture and `git worktree list`
ran against a repository outside every configured root.

P2 — the MAIN-CHECKOUT arm carried no witness at all, which matters most
because `listingTarget` PREFERS a main checkout, so the unproven URL was the
one that most often decided where git ran. Worse than the bare case, and
silently: a replacement's own porcelain record cross-validates against its
own canonicalized git directory, so the two AGREE and the scan proceeds with
zero issues published.

Every arm now carries a witness for its OWN directory — main checkout and
linked worktree at the instant the walk observed it, bare at the instant its
shape proof passed — and the identity is re-proved at BOTH points of use:
before `canonicalize` in grouping, and again where the path becomes a
`git -C` argument. A replacement is REPORTED, not dropped, because a scan
that quietly skips a repository is indistinguishable from one that found
nothing there.

The linked arm needed it too, and the suite is what said so: 13 cells went
red when only two arms were witnessed, because `listingTarget` falls back to
a linked worktree for a repository with no main checkout. Its `adminWitness`
is about the ADMIN entry — a different object.

  MUTATION: drop the identity comparison at the `-C` target -> RED 3/3,
  exactly `testAReplacedCheckoutIsNeverHandedToGitAsATarget`.

The cell drives a WHOLE SCAN with the git runner as the swap seam, and swaps
the victim during an EARLIER group's processing, because swapping on the
victim's own call would fire after its check had passed and prove nothing.
That fixture also retires a claim I made in b1e8188 — that driving a seam
directly proves more than timing a race through call sites. Too strong: the
direct-drive cells pinned their guards correctly and could not see past them,
and this is the shape that found both defects. Recorded at the cell.

Full suite: exit 0, 1714 executed, 3 skipped, 0 failures, "All tests passed".
…ation claim was false (gate r5 P4)

`XcodeProjectCoverageTests` asked `project.contains(basename)`, which is
"this text occurs somewhere in the pbxproj", not "this file is a member of a
Sources phase". Its own comment claimed deleting any entry would red it. The
gate measured that FALSE twice, and the two blind spots are the worst
possible pair: `ContentView.swift` is a substring of
`SettingsContentView.swift`, and `main.swift` exists twice (the app and the
helper daemon), so each masked the other's absence — the app's root view and
the helper's entry point, the two files whose absence breaks the shipped
bundle hardest. It worked for the three files it was written for, which is
exactly why the vacuity was invisible.

Now keyed on `/* <name> in Sources */,`: the `/* ` prefix stops a longer
basename from satisfying a shorter one, and the trailing comma counts phase
MEMBERSHIPS rather than mentions, so a basename carried by N tracked files
needs N of them.

That comma was itself a measured correction. The first rebuild counted
`/* <name> in Sources */` without it — but xcodegen emits that string TWICE
per membership, once declaring the PBXBuildFile and once listing it in the
phase, so a basename carried by N files yielded 2N markers and the test could
never fire. Removing one of the two `main.swift` memberships left three
markers against a requirement of two and stayed GREEN. I only caught it
because I ran all three mutation shapes instead of the one the gate named.

  MUTATION, all three measured, pristine green:
    - remove the ContentView.swift entries -> RED (substring version: GREEN)
    - remove one main.swift membership     -> RED (substring version: GREEN)
    - restore 1bf97cd^'s stale project     -> RED, naming all three files
… a false count (gate r5 P3, P5, P6)

P3 — `e80694e` split THREE clean-time messages on
`unavailabilityIsDefinitive` and pinned ONE. The gate collapsed the other two
back to the pre-fix wording and the full 1713-cell suite stayed green. The
commit's "MUTATION 2 … RED 3/3" reads as covering the change; it covered a
third of it. Both arms now have cells — the registry re-read (which needs the
parent-repo resolve to SUCCEED first, or the refusal comes from the arm above
and pins nothing) and the prune tier's oracle, whose sibling table cell
asserts the DEFINITIVE wording, which is why the collapse was invisible.

  MUTATION: collapse both ternaries -> RED 3/3, exactly the two new cells.

P5 — `GitMetadataReadCallSiteTests`' class doc says "Both cells carry a
CONTROL that must resolve with nothing swapped". The first cell had none. The
control is added and the doc now says the claim was false when written, so
the correction survives rather than being quietly absorbed. Without it, a
later edit to that hand-built fixture turns the cell green-and-vacuous with
nothing to catch it — the exact rot the sibling cell earned its control for.

P6 — `e80694e` says "Three sites told the user to 'Retry once git is
installed'". Measured at the parent: `git grep -c` returns TWO. The third
read "git is unavailable at clean time" and never mentioned installing
anything — which the code comment got right and the commit message did not.

Two residuals the gate found undisclosed, both now closed rather than noted:

- `bundle.sh` warned and CONTINUED when a menubar tiff was missing, producing
  an app with no menubar icon and exit 0 — the shape that shipped v2.1.0
  broken. Since 205b187 excluded those tiffs from the Xcode resources phase,
  bundle.sh is their ONLY consumer, so the fail-open became load-bearing
  exactly when it stopped being redundant. It now fails closed.
- `bareDirectoryWitness`'s first `identity(of:)` uses the RAW provider and
  runs BEFORE the resolver's TCC gate; that lstat previously happened after
  it. An lstat is outside this file's stated doctrine for protected paths, so
  it stays in policy, but it is an ordering change made by an identity fix
  and is now stated at the site.

Full suite: exit 0, 1716 executed, 3 skipped, 0 failures, "All tests passed".
… core.bare, so bareness fails closed on it (codex r4)

With `extensions.worktreeConfig` enabled, git reads `config.worktree` AFTER
the primary config, so a primary `bare = true` can be turned off there.
`declaresBare` read only the primary and answered true — the same shape as
the include finding, with the same consequence: a valid non-bare repository
discovered as bare, `crossValidate` disagreeing with git's own listing, and a
recurring `unreadable` issue published for a repository this scanner intends
not to cover.

Same answer as the include: this scanner does not chase a second file, so the
extension being ON makes the answer "not bare". Every spelling git reads as
true counts — true/yes/on/1, quoted, any case — because being generous about
what enables the extension is the FAIL-CLOSED direction. The VALUELESS key is
caught as well: git reads a bare `worktreeConfig` as true, and the parse loop
only read `key = value` lines before this, so the one spelling that turns the
override on with the fewest characters was the one that would have slipped.

  MUTATION, measured: delete both arms of the guard -> exactly THREE cells
  red (7 assertions), 3/3 runs; the false-and-subsection cell stays green,
  which is what shows the guard is keyed on the value and the exact section
  rather than on the key name alone. A first draft of the cell's note said
  "four"; the count is now the measured one.

GitConfigBarenessTests 20/20, GitWorktreeInventoryTests 51/51.
Full suite: exit 0, 1720 executed, 3 skipped, 0 failures, "All tests passed".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50a690fae2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return trimmed.count > symrefPrefix.count
}
guard trimmed.count == 40 || trimmed.count == 64 else { return false }
return trimmed.allSatisfy { $0.isHexDigit && ($0.isNumber || $0.isLowercase) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept uppercase hex in detached HEAD IDs

When a valid bare repository has a detached 40- or 64-character object ID containing A–F, Git accepts the HEAD, but this predicate rejects every uppercase letter because it requires isLowercase. A bare-only repository in this state is therefore never discovered, so the new orphaned-worktree prune tier remains unavailable when all checkouts are gone. Validate ASCII hexadecimal case-insensitively instead.

Useful? React with 👍 / 👎.

Comment on lines +493 to +495
for rawLine in configContents.split(
whereSeparator: \.isNewline
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fold Git line continuations before resolving core.bare

When a config contains core.bare = false followed by an unrelated value continued with \ whose next physical line begins bare = true, Git treats that second line as part of the unrelated value, but this physical-line loop parses it as a new core.bare assignment and returns true. Fresh evidence after the last-value fix is that continuation state is never tracked before splitting here, so the scanner can over-discover a non-bare git directory and process it under the bare-repository path instead of leaving that unsupported shape undiscovered.

Useful? React with 👍 / 👎.

…k to a generic glyph

A changelog accuracy check read scripts/bundle.sh's fail-closed note against
CacheoutApp.swift and found it overstated: when MenuBarIconTemplate.tiff is
absent the app does not show nothing, it shows the SF Symbol
externaldrive.fill (CacheoutApp.swift:183). The bundle ships without ITS
icon, not without an icon. A comment asserting a property the code lacks is
a defect here regardless of size; corrected at the site, with the
correction recorded so it is not re-derived.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6dc0942b98

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let process: SpawnedProcess
do {
try process.run()
process = try SpawnedProcess.launch(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep transient spawn failures distinct for every caller

When availability has already been cached as successful and this launch then fails transiently (for example, posix_spawn or one of its checked allocations returns ENOMEM), execute still produces .gitUnavailable with only the invocation-level definitive flag distinguishing the cause. The listing path now checks that flag, but WorktreeStalenessAssessor.run discards the invocation and switches only on outcome, turning the failure into a failed gate; GitWorktreeScanner.handle then silently omits an otherwise stale worktree without emitting a ScanIssue. Fresh evidence after e80694e is that this assessor path still cannot observe unavailabilityIsDefinitive; preserve a separate retryable execution-failure outcome or carry the distinction through all assessor calls.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant