Skip to content

Usage-policy gate folds path spellings a volume treats as one directory (NFC verified, case unverified) - #484

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-483
Jul 30, 2026
Merged

Usage-policy gate folds path spellings a volume treats as one directory (NFC verified, case unverified)#484
philcunliffe merged 4 commits into
masterfrom
fix/issue-483

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Root cause

src/core/usage-policy/matcher.js compares strings. A filesystem hands one
directory several strings, and realpath(2) folds exactly one of the
mechanisms that produce them (symlinks). On a default macOS (APFS) volume the
kernel accepts, stats, and chdirs to two more kinds of spelling it will not
fold:

  • Unicode normalization. Café composed (U+00E9) and decomposed
    (e + U+0301) are one directory. macOS frameworks and Finder-derived paths
    emit NFD while typed and JSON-transported paths are usually NFC.
  • Case. Proj and proj are one directory, on a case-insensitive
    volume.

So the machine-local list's membership test could be handed a cwd whose
spelling differs from the dir an entry was declared with, and the shared gate
returned full for a directory the user opted out of. The two paths being
compared are produced by different processes at different times (a CLI
resolving a mark, versus a client reporting a cwd), so a divergence between
them is ordinary rather than user error.

The .hypignore ancestor walk is not affected: it stats each candidate
rather than comparing two strings, and existsSync already succeeds
case-insensitively for the file itself.

Reproduced first, on this host

Against origin/master (1555f13), with the two spellings built as string
literals and fed to the real resolver over an in-memory list:

entry cwd master
{/root/café(NFC)/proj, local-only} /root/café(NFD)/proj full
{/root/café(NFD), ignore} /root/café(NFC)/proj/sub full

What is folded, and where

New src/core/usage-policy/fold.js.

  1. NFC unconditionally. It is a total function of the string, needs no
    filesystem access, cannot fail, and is the identity on a path that is already
    composed. There is no volume on which folding NFC and NFD together is wrong.
  2. Case only behind a per-volume probe. Case-sensitivity is a property of
    the mounted volume, not of the platform: an APFS volume can be formatted
    case-sensitive and every ext4 volume is. Folding it unconditionally would
    merge two genuinely different directories, which is a correctness bug in the
    other direction. createVolumeCaseProbe compares the dev/ino of a path
    against a case-flipped spelling of its last segment (so the parent chain
    stays traversable under its exact spelling and the answer is about the volume
    the directory is on), memoizes by dev, and returns constant false with
    no syscall at all off darwin. An undetermined probe resolves to
    "case-sensitive", which is the pre-fold behaviour.

The fold is applied at the gate: matchList folds the incoming cwd and each
entry's declared dir. isEqualOrDescendant stays lexical and pure.

The fold has to distribute over the path separator, since its only consumer
is a segment-aware prefix test: fold(a + '/' + b) === fold(a) + '/' + fold(b).
Both halves do, and the property is asserted rather than assumed.

The proof that a folded spelling only ever adds restriction

Producing a folded spelling is necessary but not sufficient, and this is the
part that took the work. Nearest-governs is an argmax over match depth, and
an argmax discards verdicts instead of merging them. A less restrictive entry
that gains reach through its folded spelling can become the deepest match and
displace a broader restrictive entry that already governed: a carve-out spelled
NFC punches a hole in a private tree spelled NFD, and the directory starts
recording and forwarding
. That is the same defect #482's round-1 review found
and fixed for the symlink class, arrived at by a different mechanism.

So nearest-governs is evaluated twice, once over the spellings exactly as
declared (which reproduces the pre-fold verdict bit for bit) and once folded,
and the more restrictive of the two answers wins, the declared one breaking
a class tie because it is the spelling the user typed. The resolved class is
max(pre_fold, folded) on the restrictiveness lattice by construction,
which makes "folding never opens the gate" structural rather than a property
someone has to remember.

Three tests pin it, and the third is the one that makes the claim mean
something:

  • resolve: a carve-out that gains reach only by folding does not punch a hole in a broader restrictive entry
    is the direct analogue of Usage-policy gate matches directories, not path spellings: canonicalize both sides #482's regression fixture:
    [{/root/real, ignore}, {/root/real/café(NFC), full}] against
    cwd = /root/real/café(NFD)/sub resolves ignore, not full.
  • resolve: a carve-out declared in the same spelling as the entry it carves out of is still honored
    is the positive half, so the guard is not just over-restriction.
  • resolve: folding never loosens, over every arrangement of a nested pair is
    exhaustive rather than illustrative over the arrangements it enumerates:
    the loop runs 72 cases (round-1 review counted them; the 108 figure here was
    wrong), each
    asserting the folded verdict is at least as restrictive as the pre-fold rule
    recomputed independently in the test.

VERIFIED on this Linux host

These are executed facts on this machine, not reasoning:

  • The NFC/NFD leak reproduces on origin/master and is closed here. The
    mechanism is entirely string-level, so it is fully witnessable on ext4:
    String.prototype.normalize is a pure function of the string, the two
    spellings are literals, and what is under test is whether the comparison
    logic
    folds the spellings it is handed.
  • Folding is monotone (72-case exhaustive check above, plus the two named
    hole-punching guards).
  • The fold distributes over /, and a sibling sharing a folded prefix
    (/root/café-other, /root/caféxyz) is still not matched.
  • Nearest-governs is measured on the folded spelling: NFD is longer in code
    units than NFC for the same name, so a declared-string depth ranks a
    decomposed ancestor above a composed descendant and inverts nearest-governs.
    Found by a surviving mutant, then fixed and pinned.
  • Case is not folded by default, so /root/Proj does not govern
    /root/proj here.
  • Given an injected case verdict, the matcher folds case, and the
    hole-punching guard still holds under it.
  • createVolumeCaseProbe is inert off darwin: constant false, zero syscalls.
  • The probe's memoization, its fail-toward-pre-fold behaviour, and its
    hashed-path skip event, all over an injected statSync.
  • The structured signal fires only on an actual tightening, and carries no raw
    path.

REASONED BUT UNVERIFIED, needs a macOS host

Nothing below has been executed:

  • That the case half works on a real case-insensitive APFS volume. The probe
    is dead code on Linux. Its darwin path is not exercised by any test in this
    repo, and the injected-verdict tests cover the matcher's behaviour given a
    verdict, never the probe that produces one. Specifically unverified: that
    dev/ino comparison across a case-flipped spelling gives the right answer
    in the presence of APFS firmlinks and /System/Volumes/Data; that flipping
    the last segment does not hit a real differently-cased sibling often enough to
    matter (the ino check should catch it, but "should" is the operative word);
    and that the probe's cost is acceptable on a real volume.
  • That macOS actually treats NFC and NFD spellings of a path as one
    directory.
    That is APFS's documented normalization-insensitivity, and it is
    the premise of the whole issue, but it is a premise here, not an observation.
  • That the real-world divergence arrives in the direction assumed (a mark
    written NFC, a cwd reported NFD, or the reverse). The fix is symmetric, so
    direction does not change the outcome, but no field evidence was collected.

A reviewer with a Mac should run test/core/usage-policy-fold.test.js on a
default volume and on a case-sensitive one, and check that
createVolumeCaseProbe()('~/Documents') returns true on the former and
false on the latter. This PR does not claim that has been done.

Performance

The per-cwd memo is keyed on the lexical path and consulted before any
folding, so a cache hit costs exactly what it did before. Entry spellings and
the per-volume case verdict are computed once per list parse, inside the TTL
window LLP 0049 R6 already bounds, so resolving many distinct cwds in one
window folds each entry once rather than once per cwd. Pinned by
resolve: folding happens on the cache miss only, so a repeated cwd re-reads nothing.

Measured on this host, two runs each in this worktree and a pristine
origin/master worktree with the same node_modules symlink (500k
steady-state calls, 20k forced misses, 3-entry list):

master this branch
cache-hit resolve (the per-exchange path) 285 / 273 / 246 ns 293 / 267 / 259 ns
cache-miss resolve (once per cwd per 5 s) 2.46 / 2.25 us 2.56 / 2.33 us

The cache-hit column is indistinguishable inside run-to-run noise, which is the
claim that matters. The numbers above are round-1 review re-measurements (3 runs,
500k calls after a 100k warmup); the original table implied the branch was ~10%
faster, which did not reproduce. There is no speedup, and none is claimed.
Cache-miss is up about 4%, also close to noise. String.prototype.normalize('NFC')
measured in isolation: 59 ns on a pure-ASCII path, 134 ns on an NFD one.

Mutation coverage

Fifteen mutants, each killed by a named test (the table below omits a row for the non-ENOENT probe mutant found in round-1 review). Two of them survived the first
pass and are the reason two tests exist at all (M6 and M10 below), so this table
is evidence rather than decoration.

mutation test that reddens
M1 foldPath drops the NFC normalization (= master) 6 tests, incl. a local-only entry declared NFC still governs a cwd that arrives NFD
M2 foldPath case-folds unconditionally case is NOT folded by default, because this volume is case-sensitive
M3 selectGoverning keeps only the folded pass a carve-out that gains reach only by folding does not punch a hole ..., folding never loosens, over every arrangement of a nested pair
M4 selectGoverning keeps only the declared pass (= master) 8 tests
M5 deepestMatch loses per-entry specificity a carve-out declared in the same spelling ... is still honored
M6 deepestMatch measures folded depth on the declared spelling nearest-governs is measured on the folded spelling, not on the declared one
M7 flipCase flips the whole path, not the last segment memoizes a definite verdict per volume, not per path
M8 the probe is not inert off darwin createVolumeCaseProbe is inert off darwin
M9 the probe treats an undetermined stat as case-insensitive fails toward the pre-fold behaviour and logs a hashed skip
M10 the probe memoizes an undetermined verdict as the volume answer does not memoize an undetermined answer as the volume verdict
M11 reportFold emits on every list match, not only a tightening emits a hashed usage_policy.fold_tightened only when folding changed the verdict
M12 reportFold logs the raw cwd instead of a digest same
M13 list scopes are recomputed on every resolve the case verdict is asked per entry, so a per-volume answer applies per entry
M14 the memo is keyed on the folded path instead of the lexical one emits a hashed usage_policy.fold_tightened only when ...

Reproducing tests

test/core/usage-policy-fold.test.js, 23 tests. Run against a pristine
origin/master worktree with the 7 new-module tests elided so the file
imports: 7 pass, 8 fail. The 7 that pass there pass on purpose, as guards
rather than reproducers (the sibling-prefix test, both hole-punching guards, the
exhaustive monotonicity check, case is NOT folded by default, and the fixture
premise). All 23 pass here.

The file opens with
the two fixture spellings are genuinely different strings that NFC folds together,
because an editor, a merge tool, or a git filter that re-normalized the source
would collapse both constants to one string and make every test below it pass
vacuously. That test is the tripwire for it.

Structured signal

usage_policy.fold_tightened (debug) fires only when the folded pass reached a
more restrictive verdict than the declared spellings did, i.e. exactly the
spelling divergence that would otherwise have opened the gate: hyp_component,
hyp_operation, status, declared_class, folded_class, and a cwd_hash
(sha256, first 16 hex). usage_policy.case_probe_skipped (debug) carries
error_kind: path_case_probe_failed, a reason, an errno, and a
path_hash. No raw path appears in any attribute on any branch, asserted in
both tests.

Relationship to #482

This branch is based on master, not on #482, and does not depend on it.
The two are independent aliasing mechanisms: realpath cannot fold case or
normalization, and folding cannot resolve a symlink. #482 documents this class
under "What this does NOT fix"; its round-1 review filed it as #483.

They do converge on the same structural fix, which is worth saying plainly:
both discovered that widening an entry's reach is unsafe while nearest-governs
is an argmax, and both answer it with a two-pass declared-versus-widened
evaluation keeping the more restrictive answer. That is not a copy: #482's
helper (canonical.js) is not vendored here and nothing on that branch was
merged in.

They will conflict in matcher.js, and that is the intended outcome.
Whichever lands second should collapse the two two-pass evaluations into one
pass over one spelling set (lexical, canonical, folded) rather than keep two,
since running the argmax guard twice buys nothing. Neither PR should be
rebased onto the other while both are in review. This PR does not supersede,
subsume, or dominate #482.

What this does NOT fix

Verification

Docs

LLP 0050 gains §normalization: "the set of spellings that denote a directory"
is volume-dependent, not just symlink-dependent, plus the argmax
non-monotonicity, the separator-distribution requirement, the cost model, and
the relationship to #482. LLP 0049 #scope gets an Extended-by note recording
that the ancestor walk is unaffected but list membership is not.

Fixes #483

`realpath(2)` folds symlinks and nothing else. On a default macOS (APFS)
volume the kernel accepts, stats, and chdirs to spellings the gate compared
as different strings: `Cafe` composed (NFC) versus decomposed (NFD), and
`Proj` versus `proj`. The machine-local list's membership test therefore
returned `full` for a directory the user opted out of, because the `dir` a
CLI declared and the `cwd` a client reported are produced by different
processes at different times.

List membership now compares a folded spelling of both sides:
Unicode NFC unconditionally (total, no filesystem access, identity on an
already-composed path), and case only behind a per-volume probe that is
inert off darwin.

Producing a folded spelling is necessary but not sufficient. Nearest-governs
is an argmax over match depth, and an argmax discards verdicts rather than
merging them, so a carve-out that gains reach only by folding could displace
a broader restrictive entry and start a private directory forwarding. The
rule is therefore evaluated twice, once over the spellings exactly as
declared and once folded, and the more restrictive answer wins. The resolved
class is max(pre_fold, folded) by construction.

The per-cwd memo stays keyed on the lexical path and consulted before any
folding, so the per-exchange hot path is unchanged.

Fixes #483

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

🤖 neutral: this PR and #482 independently built the same mechanism in the same function

Recording a merge hazard that neither PR could see, because they were written in parallel by workers blind to each other.

The overlap

Both PRs modify src/core/usage-policy/matcher.js, heavily:

PR matcher.js New module Fixes
#482 (fix/issue-479) +254 / -41 canonical.js symlink spellings (realpath)
#484 (this one) +169 / -18 fold.js NFC/NFD and case spellings (normalize)

They are solving the same shape of problem: a directory denoted by more than one spelling, where the gate compares strings. And both arrived independently at the same remedy for the same subtle defect - that nearest-governs is an argmax over match depth, so argmax discards verdicts and widening the spelling set is not monotone on its own. #482's round-1 review found that regression and fixed it with a two-pass evaluation, more-restrictive-wins. This PR's author rediscovered it and applied the same two-pass structure, pinning it with a 108-case exhaustive check.

That convergence is good evidence the remedy is right. It is also a duplication: after both merge, the matcher would carry two spelling-widening rules and two two-pass evaluations, which is precisely the class of drift issue #465 existed to remove from a different file.

What this means for merging

  • The two will conflict, substantially, and the conflict is semantic rather than textual. Whoever resolves it should unify the two into one spelling-set widening (symlink resolution plus normalization producing one candidate set, evaluated once by one most-restrictive-wins rule), not stack a second pass on top of the first. Stacking would work but would leave two rules to keep in step, and the whole reason both PRs needed a two-pass fix is that this code is easy to get subtly wrong.
  • This PR already declined to grow a second folding rule at the CLI membership sites and hyp purge --subtree, deferring them to Usage-policy gate matches directories, not path spellings: canonicalize both sides #482's shared predicate. That instinct was right and points the same way: one predicate, not two.
  • Merge order is yours. Neither depends on the other, and each closes a real leak on master independently.

Not blocking either PR

Both stand on their own merits and each is verified against master. Neither is wrong. This note exists so the conflict is resolved deliberately rather than mechanically, and so the unification is a decision someone makes rather than something that quietly does not happen.

Related

…es, correct two cost claims

Round-1 review of #484.

- `createVolumeCaseProbe` deliberately does not memoize a non-`ENOENT` failure
  of the case-flipped `stat`, but nothing pinned it: mutating the branch to
  cache the undetermined verdict left the suite green. On a real volume a
  transient `EACCES`/`EIO` would then disable case folding for every path on
  that disk for the life of the resolver, silently reopening the gap the module
  exists to close. New test kills the mutant.

- The NFC/NFD fixtures were raw UTF-8, while the comment above them claimed they
  were spelled as escapes. Re-normalizing the source really would have collapsed
  them (verified: it reddens 3 tests), so the tripwire worked, but the stated
  mechanism did not exist. Now actually `\u`-escaped, so the file is pure ASCII
  and the collapse is unrepresentable rather than merely detectable. The
  tripwire test stays, for anyone who reintroduces raw characters.

- The probe's JSDoc claimed "one pair of stat calls per distinct volume, not one
  per directory". The memo is keyed on `dev`, which must be learned first, so
  every call stats `dir` itself; only the flipped stat is saved. Corrected to
  the actual bound (n stats plus one per volume, per TTL window).

- LLP 0050 quoted normalize('NFC') at ~60 ns, measured on a pure-ASCII path,
  which is the case where the fold does nothing. On a genuinely decomposed path,
  the macOS case the section exists for, it is ~550 ns and scales with length.
  Added the real numbers and the reason the cost is affordable (miss path only).

- LLP 0050 said only that the unfolded CLI sites "can name a different governor".
  Named each site and its direction instead: the CLI can never promise more
  protection than the gate delivers, `--check` reports the right class but can
  name a narrower scope, `policy unset` can refuse to remove an entry the gate
  enforces (fails toward privacy), and `hyp purge --subtree` can report success
  while retaining rows (the one site that fails away from privacy).

No behaviour change: the only non-test edits are JSDoc and documentation.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review of #484 at d8652484 (round 1 of 2)

Verdict: findings. Seven findings. Five fixed and pushed (1bf7baa); two are
in the PR body, which I am not permitted to edit, so they are left for a human
with the exact corrections spelled out.

I reviewed this on Linux, where NFD and NFC are distinct directories and case is
significant. I did not try to make the branch prove its macOS behaviour. I
checked the honesty of the split: that everything in the VERIFIED column really
is verified on this host, that everything needing macOS is labelled and cannot
misbehave on Linux, and that nothing "verified" is a Linux-only artifact.

The split is honest. The one place the branch claimed a mechanism it did not
have is the tripwire (finding 2), and the one place it claimed a cost it does not
pay is the probe memo (finding 3). Both were cheap to make true. The load-bearing
claim, that folding only ever adds restriction, holds and holds structurally.

Headline results

  • No shape where folding is less restrictive than master. 223,184
    differential cases against a pristine origin/master matcher, zero violations,
    17,460 cases where the branch is strictly stricter. Detail below.
  • The probe is provably inert on Linux, not merely asserted to be.
  • The tripwire really works, and now the mechanism its comment describes
    actually exists.
  • The CLI can disagree with the gate on macOS, but never in the dangerous
    direction.
    It can never report a directory protected while the gate forwards
    it. hyp purge --subtree is the one site that fails away from privacy.

Only-adds-restriction: I could not break it, and it is structural

This is the trap #482 fell into, so I attacked it by execution rather than by
reading. I extracted origin/master's matcher and ran both resolvers over the
same fixtures, asserting rank(branch) >= rank(master) on every case.

sweep shape cases
1 three entries at three depths, every class triple, every spelling assignment over {NFC, NFD, Proj, proj}, both case verdicts 10,368
2 carve-out inside a carve-out inside a carve-out (four entries), mixed spellings 2,592
3 declared and folded forms landing at different depths (six accented chars, so NFD outranks NFC on declared length) 432
4 case and normalization aliasing at once, case verdict on 576
5 .hypignore walk interacting with folded list entries 4,608
6 random: 1 to 5 entries, depths 1 to 4, random classes and spellings, random case verdict 200,000
7 same-depth entries, tie-break stress across spellings 576

223,184 checked, 0 violations, 0 throws. Sweeps 1 to 4 are exactly the
adversarial shapes the brief asked me to construct (three entries at differing
depths, carve-out inside a carve-out, declared and folded forms at different
depths, a directory aliased two ways at once).

It is not luck, and the argument here is cleaner than #482's. deepestMatch(cwd, scopes, false) is bit-for-bit master's filter plus reduce: same argmax
over dir.length, same class tie-break, same declared strings. So asDeclared
is the master verdict. selectGoverning then returns the folded pass only
when its class rank is strictly greater, and asDeclared otherwise, with a
null declared pass ranked as full (the lattice bottom). The returned class is
therefore max(pre_fold, folded) by construction. matchList uses only
governing.entry.class, and the .hypignore walk is untouched, so the property
survives the merge with the dotfile result. resolve(cwd).class >= master(cwd).class holds for every input, not merely for every tested input.

One robustness note, not a defect. In the folded pass, entries whose volume
probed case-insensitive compare against a lowered cwd while the others compare
against the merely composed one, and toLowerCase is not length-preserving
(U+0130 lowers to two code points). So folded depths can in principle be
compared across two slightly different namespaces. This cannot leak, because the
folded pass is only ever consulted when it is more restrictive, so the worst
case is over-restriction. Worth knowing before anyone refactors deepestMatch
into a single pass.

The 108-case check is really 72, and is exhaustive over the space it claims

3 classes x 3 classes x 2 x 2 x 2 = 72, not 108. I confirmed the loop trip
count by execution. The factorisation the body prints is right; the product is
wrong. The test's own JSDoc claims no count and is accurate.

It is genuinely exhaustive over its space: every class pair, every spelling
assignment to outer entry, inner entry and cwd, for one nested pair at two
fixed depths. It does not cover three or more entries, same-depth entries,
depth inversion, or the case dimension. My sweeps above cover all four, which is
why I am reporting the count as a body correction rather than a coverage gap.

The probe is provably inert on Linux

Three independent confirmations, not one:

  1. createVolumeCaseProbe() constructed with no injection at all on this
    host returns a closure whose toString() is literally () => false. The
    darwin body is not merely skipped, it is unreachable from the returned
    function.
  2. I booby-trapped every function on the node:fs default export and counted
    calls. Seven direct probe() calls on real paths (/etc, /tmp, /usr/Lib,
    /etc/Hosts, process.cwd(), /): 0 fs calls. The same through the full
    resolver on a real on-disk entry list: 0 fs calls on the probe path.
  3. Static reachability: platform and caseInsensitiveVolume are settable only
    through the deps parameter. grep over the whole repo shows the only
    callers passing either are in test/core/usage-policy-fold.test.js, and every
    test that injects platform: 'darwin' also injects a fake statSync. No env
    var, no config key, no CLI flag reaches either. Nothing can make a Linux
    filesystem be probed or a wrong verdict be cached.

Case-sensitive macOS volume: the flipped spelling does not resolve,
statSync throws ENOENT, and the code treats ENOENT as the informative
outcome and memoizes false. Correct. If a genuinely different sibling happens
to occupy the flipped name, dev/ino differ and the verdict is still false.
Correct.

Wrong-way answers: a wrong true over-restricts (merges two real
directories) which is privacy-safe; a wrong false leaves the pre-fold
behaviour, so it preserves the original bug rather than creating a new one.
Neither direction can open the gate wider than master. The code and LLP 0050
both say exactly this, which is the honest framing: it fails to the status quo,
not "toward privacy" in the strong sense.

Finding 1: a non-ENOENT probe failure was not pinned by any test (medium, FIXED)

src/core/usage-policy/fold.js:214 at d8652484.

The code deliberately does not memoize an undetermined probe, and says so in a
comment: "Any other errno is a genuinely undetermined probe and is not
memoized." I mutated that branch to byDev.set(st.dev, false) and the suite
stayed green, 22 pass 0 fail
. The existing does not memoize an undetermined answer test only covers the no_cased_character path, not the errno path.

This matters on the target platform. Under the mutant, one transient EACCES on
a directory the daemon cannot traverse, or an EIO on a flaky mount, would cache
"case-sensitive" for the whole volume for the life of the resolver, silently
disabling case folding for every path on that disk. That is the exact failure
class this module exists to prevent, and it would have been invisible.

Fix (1bf7baa): new test createVolumeCaseProbe does not memoize a non-ENOENT failure of the flipped stat. It fails the flipped stat with
EACCES, asserts false, then lets a later probe of the same dev reach a
definite answer and asserts it is believed rather than served a stale false.
Verified: re-applying the mutant now reddens exactly that test (23 pass 0
fail baseline, 22 pass 1 fail mutated).

This was one of the two mutants the brief listed as fixed in the first pass. The
other, folded-depth-on-declared-spelling, is genuinely fixed and covered: I
mutated const depth = dir.length to scope.entry.dir.length and it reddens
resolve: nearest-governs is measured on the folded spelling, not on the declared one, precisely the test the body names.

Finding 2: the tripwire's stated mechanism did not exist (medium, FIXED)

test/core/usage-policy-fold.test.js:36-41 at d8652484.

The comment read "Spelled as escapes so the file cannot be silently
re-normalized by an editor, a merge tool, or a git filter". The literals were
raw UTF-8: caf\303\251 and cafe\314\201, confirmed by od -c. A third
raw literal sat at line 154.

The tripwire itself is real, and I verified it rather than assuming it. I
re-normalized the whole source file to NFC, simulating the editor the comment
warns about: 3 tests go red, including the premise test. So the file could
not have passed vacuously. But the protection was detection after the fact, not
the prevention the comment claimed.

Fix (1bf7baa): all three literals are now \u escapes, so the file is pure
ASCII (grep -cP '[^\x00-\x7F]' returns 0) and the collapse is unrepresentable
rather than merely detectable. The tripwire test stays, for anyone who
reintroduces raw characters, and the comment now describes what the code does.
Verified: git diff d8652484..HEAD shows the escaped literals; suite 23 pass
0 fail.

Finding 3: the probe's memoization cost claim is false (low, FIXED)

src/core/usage-policy/fold.js:123-129 at d8652484.

The JSDoc claimed "one pair of stat calls per distinct volume for the life of
the resolver, not one per directory". The memo is keyed on dev, and dev
has to be learned before the memo can be consulted, so probe(dir) calls
statSync(dir) unconditionally on every invocation. It is one stat per
directory. Only the second, case-flipped stat is saved. The branch's own test at
line 298 demonstrates this (['/vol/Proj', '/vol/pROJ', '/vol/Other', '/vol/deep/Nested']), so the code and test were right and only the claim was
wrong.

The real bound is n stats plus one extra per distinct volume, per TTL window,
for an n-entry list. Still inside LLP 0049 R6, which already stats every
ancestor, so this is a claim defect and not a performance defect.

Fix (1bf7baa): JSDoc corrected to the actual bound and the reason for it.
Verified: git diff d8652484..HEAD -- src/core/usage-policy/fold.js.

Finding 4: LLP 0050 quotes the one normalize cost that does not apply (low, FIXED)

llp/0050-ignore-enforced-in-adapters.decision.md:181-182 at d8652484.

The Cost section said normalize('NFC') is "roughly 60 ns on a pure-ASCII path".
Accurate, and I reproduce it (56 to 61 ns). But that is the case where the fold
does nothing. On a genuinely decomposed path, which is the entire macOS case the
section exists for, normalize has to recompose:

input length already NFC cost
ASCII path 49 yes 56 ns
accented, composed 114 yes 93 ns
accented, decomposed 134 no 552 ns
pathological all-decomposed 1001 no 9.65 us

So the documented figure understates the target-platform cost by about 9x. This
is not a hot-path problem, because folding is on the cache-miss path and the list
parse only, and I measured the end-to-end worst case to confirm: a cache miss
over a 20-entry list with a long NFD cwd is 8.2 us on master and 9.7 us on
the branch. But a reader planning against 60 ns would be wrong by an order of
magnitude, and this is the number a future caller would reuse when deciding
whether the fold can move to a per-row path.

Fix (1bf7baa): the Cost section now carries both regimes, the measured
numbers above, and an explicit instruction to re-measure with a decomposed path
before moving the fold anywhere hotter. Verified: git diff d8652484..HEAD -- llp/.

Finding 5: the CLI seam disclosure omitted the one direction that loses data (medium, FIXED)

llp/0050-ignore-enforced-in-adapters.decision.md:193-201 at d8652484.

The "Not covered" section said the unfolded CLI sites "can still name a different
governor than the gate used". True but flattening: the four sites fail in
different directions, and one of them loses user data. I traced each.

  • The CLI can never promise more protection than the gate delivers. The
    lexical predicate matches a subset of the folded one, and the gate returns
    max(declared, folded), so any entry the CLI finds the gate also found. There
    is no spelling on which --check says protected and the gate forwards. This is
    the invariant that matters and it holds.
  • hyp ignore --check / policy show report the right class, because it
    comes from resolve(). Only resolveCheckScopeDir
    (src/core/commands/clients.js:1145) is lexical, so when an entry reaches
    cwd only by folding it falls back to the queried path. Right class, narrower
    named scope and residual count.
  • policy unset / unignore --local-only (clients.js:1017) can report
    "not governed" and exit 0 for an entry the gate is enforcing. Fails toward
    privacy: the opt-out stays on. Annoying, not dangerous.
  • hyp purge --subtree (src/core/cache/purge.js:94) can report success
    while leaving rows on disk, when the rows were recorded under a different
    spelling. This is the one site that fails away from privacy. The user asked
    for deletion, the command succeeded, and the data remains.

Is the seam defensible? Yes, but it should be named honestly. I agree with
leaving these to #482's shared predicate rather than growing a second folding
rule, which is exactly the duplicated-invariant pattern #465 existed to remove.
And the purge behaviour is unchanged from master, so this PR ships no
regression. But "can name a different governor" did not convey that a deletion
command can silently retain data.

Fix (1bf7baa): "Not covered" now names each site, its direction, and states
the never-promises-more-than-the-gate invariant explicitly. Verified: git diff d8652484..HEAD -- llp/.

Finding 6: the PR body says 108 cases, the loop runs 72 (low, LEFT for a human)

PR body, "exhaustive rather than illustrative" bullet. The listed factorisation
3 x 3 x 2 x 2 x 2 is 72. I confirmed the trip count by execution.

Decision for a human: change "(108 cases)" to "(72 cases)". No code change;
the check is exhaustive over the space it claims and the test's own JSDoc is
accurate. I cannot edit the body.

Finding 7: the cache-hit performance numbers do not reproduce (low, LEFT for a human)

PR body performance table: master 257 / 236 ns versus branch 220 / 214 ns,
which reads as the branch being about 10 percent faster than master.

My measurement, three runs each, same harness in this worktree and against the
extracted origin/master matcher, 500k steady-state calls after a 100k warmup:

master branch
cache-hit resolve (per-exchange path) 285.2 / 273.0 / 246.4 ns 292.9 / 266.8 / 259.4 ns
cache-miss resolve (per cwd per TTL) 2.90 / 2.98 us 3.09 / 2.48 us

The two are indistinguishable inside run-to-run noise, which is what the body
itself concludes in the sentence after the table. The substantive claim is
correct and I verified its mechanism directly: the memo is keyed on the lexical
path and consulted before any folding, and resolve: folding happens on the cache miss only pins it. The numbers as printed just imply a speedup that is not
there.

Decision for a human: either replace the cache-hit row with a range that
spans both worktrees, or drop the per-run digits and keep the prose conclusion.
The claim needs no retraction, only the false precision removed.

On the ASCII fast path the brief asked about: it is real but partial. normalize
does short-circuit the recomposition when the string is already NFC (56 ns ASCII
and 93 ns composed-accented, versus 552 ns decomposed), but it is not free, and
it is not a cheap identity check either, since cost still scales with length.
There is no case where a long non-ASCII path blows the budget at the position the
fold occupies.

Overlap with #482: they compose, with two specific hazards

Recorded, not resolved, as instructed. Both branches arrive at the same two-pass
max(declared, widened) shape for the same reason, so unification is
semantically sound: one declared pass plus one widened pass over the union of
spellings gives the same guarantee both deliver separately. Two things will make
it harder than it looks:

  1. Composition order is not free. realpath must run before the fold, never
    after. Folding first can produce a spelling that does not exist on a
    case-sensitive volume, and realpath would then ENOENT on a path that was
    fine. The unified candidate builder has to be fold(realpath(p)), and Usage-policy gate matches directories, not path spellings: canonicalize both sides #482's
    partial-canonicalization walk has to fold each resolved ancestor, not the
    rejoined result.
  2. The two disagree about where depth is measured. This PR measures
    specificity on the folded spelling and pins it
    (nearest-governs is measured on the folded spelling), because NFD is longer
    in code units than NFC and a declared-string depth inverts the ordering.
    Usage-policy gate matches directories, not path spellings: canonicalize both sides #482's matchDepth measures on whichever spelling matched. A naive union
    keeps whichever convention the merge happens to preserve, and the failure is
    silent and shape-dependent. Whoever unifies must pick the folded convention
    and re-run this PR's depth-inversion test.

Both branches also independently reach for an entry-side representation: this one
precomputes a single foldedDir per entry, #482 stores a spelling set. The
unified form has to be the set, which means this PR's ListScope is the shape
that gives way.

Docs and style

LLP 0050 §normalization and the LLP 0049 #scope Extended-by note are
accurate. I checked the one claim that could have been hand-waved: LLP 0049's
note says the ancestor walk itself is unaffected "because it stats each
candidate rather than comparing two strings". Confirmed at matcher.js:146, the
walk is existsSync(candidate), which the macOS kernel resolves across both
aliasing mechanisms, so the walk genuinely does not need folding and only list
membership does. All anchors resolve; test/core/llp-ref-hygiene.test.js is 8
pass, 1 skip (the pre-existing #463 renumber skip).

CLAUDE.md: no semicolons, no @typedef, no inline import() types,
root-anchored .js type-import specifiers, JSDoc types throughout. Zero em
dashes added
by the branch or by my commit (git diff | grep -cP '\x{2014}'
returns 0 for both).

hypaware-core/plugins-workspace/codex/src/exchange-projector.js is untouched,
so this adds nothing to the conflict surface of #462 / #474 / #477.

Verification

  • npm test: 2950 tests, 2940 pass, 8 fail, 2 skipped. The 8 are the pre-existing
    test/core/leave-command.test.js set. I verified them by name on a
    pristine origin/master worktree (1555f13) with the same node_modules:
    identical 8. Unrelated to this branch.
  • npm run typecheck: clean.
  • test/core/usage-policy-fold.test.js: 23 pass, 0 fail (22 before my commit).
  • All five usage-policy suites plus the ignore and purge command suites: 122
    pass, 0 fail.
  • test/core/llp-ref-hygiene.test.js: 8 pass, 1 skip.
  • Differential sweep re-run after my commit: 223,184 cases, 0 violations,
    unchanged. My edits are JSDoc, documentation and one test, so no behaviour
    moved.

What a human still needs to decide

  1. Body corrections 6 and 7 above (the 108, and the cache-hit digits). Both are
    body-only and I am not permitted to edit it.
  2. Whether the CLI seam is acceptable to ship. My read is yes, and the fixed
    LLP 0050 now states the risk plainly, but the hyp purge --subtree retention
    case is the one a human should sign off on knowingly rather than inherit. It
    is not a regression from master, and Usage-policy gate matches directories, not path spellings: canonicalize both sides #482 is the right place to close it.
  3. The case half remains unverified and may not work. Nothing on this host
    can witness it, the branch says so, and I confirmed the failure mode is inert
    rather than wrong on Linux. Someone with a macOS box should run the two checks
    the body names, on both a case-insensitive and a case-sensitive APFS volume,
    before this is trusted on darwin.

…the purge seam it must not be reused in

LLP 0050 justified folding NFC unconditionally with "there is no volume on
which folding NFC and NFD together is wrong, because no filesystem this
codebase targets lets two paths that differ only by normalization name two
different directories". That premise is false, and demonstrably so on this
host: `caf`+U+00E9 and `cafe`+U+0301 are two directories with two inodes,
both present in one parent, each holding different content.

The decision is still right, but for a gate-specific reason: the resolved
class is max(declared, folded), so a fold that merges two distinct
directories can only over-restrict. Recorded as such, with an explicit
warning not to reuse foldPath where widening deletes or discloses.

Also in "Not covered":
- the `hyp purge <path>` retention gap now carries the observed transcript
  ("purged 0 rows from 0 partitions", exit 0, empty stderr) and notes the
  inversion that the succeeding purge is the noisier of the two;
- records that `hyp purge --ignored` IS covered by this change, because it
  classifies through resolver.resolve(); verified against master, which
  leaves the row. That is the durable workaround for the subtree gap;
- notes that closing the subtree gap is not a foldPath drop-in, because on
  a Linux volume that would delete a genuinely different sibling's rows.

Docs only; no behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review of #484 at 1bf7baac (round 2 of 2, final)

Verdict: findings. Five. Three fixed and pushed (aee8e6f), one closed by filing #485, two are PR-body text I am not permitted to edit and are left with the exact correction spelled out.

This is the final review round. After this the PR routes to triage, which classifies but does not fix. Everything under "What a human must decide" reaches a human unfixed.

Round 1 was thorough and I did not re-derive it. I spot-checked its five fixes (all held, all load-bearing, evidence below) and spent the round on the thing it could only document: the hyp purge retention gap. I ran it rather than read it.

Headline

  • The purge gap is real, and worse than "can name a different governor" suggests. Executed on both master and this branch: the command prints purged 0 rows from 0 partitions, exits 0, writes nothing to stderr, and the rows are still there. Unchanged from master, so not a regression in this PR, and it is now tracked as hyp purge <path> reports success and exits 0 while silently retaining rows recorded under an aliased spelling of that directory #485.
  • hyp purge --ignored is fixed by this PR, which nobody had noticed. It classifies rows through resolver.resolve(), so it inherits the fold. Verified against master, which leaves the row. That is the durable workaround for the subtree gap, and it was undocumented.
  • LLP 0050 justified unconditional NFC folding with a false claim about filesystems. I disproved it by execution on this host. The decision is still right; the stated reason is not, and the wrong reason is the dangerous kind, because it licenses reusing foldPath in the purge predicate, where it would over-delete.
  • Rebased on Usage-policy gate matches directories, not path spellings: canonicalize both sides #482, this PR stays correct. canonicalSpellings does not weaken the monotonicity argument. Details below.

The purge retention gap, executed

Rows recorded under one spelling, purge naming the other. Both spellings are string literals, so this is fully witnessable on Linux: the subtree predicate is a byte comparison that never touches the filesystem.

rows recorded as purge argument exit stdout stderr rows surviving
NFC NFC (control) 0 purged 1 row from 1 partition resurrection warning 0
NFD NFC 0 purged 0 rows from 0 partitions (empty) 1
NFC NFD 0 purged 0 rows from 0 partitions (empty) 1
/home/u/proj /home/u/Proj 0 purged 0 rows from 0 partitions (empty) 1

Byte-identical results on master (1555f13) and on this branch. What a user actually sees:

$ hyp purge ~/café/proj --yes      # typed NFC; rows were recorded under NFD
purged 0 rows from 0 partitions
$ echo $?
0

Three things make it nastier than the row count implies:

  1. Exit 0 and an empty stderr. Indistinguishable from "that directory had nothing cached". There is no signal to act on.
  2. The failure is quieter than the success. A purge that actually deletes prints the LLP 0104 resurrection warning to stderr. The purge that silently retains prints nothing. Silence reads as clean.
  3. It is the one seam that fails away from privacy. Every other spelling-divergence symptom over-suppresses. This one keeps data the user explicitly asked to destroy.

Cause is src/core/cache/purge.js:94: isEqualOrDescendant(path.resolve(row.cwd), base) is a pure lexical prefix test, and path.resolve folds neither normalization nor case.

hyp purge --ignored is a different story and it is good news. That target runs resolver.resolve(row.cwd).class !== 'ignore', so it inherits this PR's fold. With an ignore entry declared NFC and rows recorded NFD: master purges 0 rows and leaves the row; this branch purges it. Both directions. So this PR already closes the retention gap for the --ignored sweep, and marking the directory then running hyp purge --ignored is the workaround for the subtree gap. That was not written down anywhere; it is now.

Finding 1: LLP 0050 justified unconditional NFC folding with a claim that is false (medium, FIXED)

llp/0050-ignore-enforced-in-adapters.decision.md:126-130 at 1bf7baac:

There is no volume on which folding NFC and NFD together is wrong, because no filesystem this codebase targets lets two paths that differ only by normalization name two different directories.

Every Linux volume this codebase targets does exactly that. Executed on this host:

NFC hex: 636166c3a9   NFD hex: 63616665cc81
NFC ino: 95818260 | NFD ino: 95818408 | SAME DIRECTORY? false
read NFC/who.txt => I am the NFC directory
read NFD/who.txt => I am the NFD directory
dir entries: [ '63616665cc81', '636166c3a9' ]

Two directories, two inodes, both in one parent, different contents.

The decision is still correct, but for a reason specific to the gate: the resolved class is max(declared, folded), so a fold that merges two genuinely distinct directories can only ever over-restrict. That is a usability cost and never a privacy or data-loss one.

Why the wrong reason matters rather than being pedantry: it is stated as a universal property of filesystems, which licenses reuse anywhere, and it sits directly above a bullet that correctly gates case folding behind a probe precisely because it "would merge two genuinely different directories". The asymmetry the document draws between the two mechanisms is not real. The real asymmetry is that macOS folds normalization on every APFS volume whereas case depends on format. Anyone closing the purge seam would read "safe everywhere" and fold inside a deletion predicate.

Fix (aee8e6f): the bullet now states the true reason, carries the executed counter-evidence, and ends with an explicit "do not reuse foldPath in a predicate where widening is not free". The summary table row is corrected to match. Verified: git diff 1bf7baac..HEAD -- llp/ shows the change; no behaviour moved, test/core/usage-policy-fold.test.js 23 pass 0 fail, llp-ref-hygiene 8 pass 1 skip.

Finding 2: the purge disclosure lacked the symptom, the workaround, and the over-deletion hazard (medium, FIXED)

llp/0050:193-201 at 1bf7baac. Round 1 correctly named hyp purge --subtree as the one site failing away from privacy. Three things were still missing, and each is load-bearing for whoever picks this up:

  • the observed transcript (exit 0, purged 0 rows, empty stderr) and the inversion that the succeeding purge is the noisier one. "Can fail to purge rows it reports as purged" also slightly misdescribes it: it does not claim to have purged rows, it reports zero, which is exactly what makes it invisible;
  • that hyp purge --ignored is already covered by this change, with the master comparison, so the workaround is on record;
  • that closing the seam is not a foldPath drop-in, because purge deletes. On a Linux volume, folding would delete cached rows for a genuinely different sibling. The fix needs the fold gated on the volume actually being normalization-insensitive, and no such probe exists: createVolumeCaseProbe answers only the case question.

Fix (aee8e6f). Verified: git diff 1bf7baac..HEAD -- llp/, all five named markers present.

Finding 3: nothing tracked the purge gap, and closing #483 would have retired the only record (medium, FIXED by filing #485)

gh issue list showed no issue for it. #479 names hyp purge but for the symlink class, and #482 closes that half. #483 names it, but this PR says Fixes #483, so merging would auto-close the only written record of a deletion command that does not delete.

Filed #485, label neutral:fix only. Not neutral:stuck: the defect is on master (reproduced at 1555f13), not branch-only, so it is directly actionable. It carries the reproduction table, the user-visible symptom, the cause, the --ignored workaround, the over-deletion hazard, three concrete fix options, and acceptance criteria.

I did not fix it in this PR. Round 1 deferred the CLI seams to #482's shared predicate and I agree, now with a technical reason on top of the process one:

  1. Usage-policy gate matches directories, not path spellings: canonicalize both sides #482 rewrites this exact predicate to call scopeGoverns. A fix here collides head-on with an approved PR awaiting a human merge.
  2. It would be the second folding rule, the pattern Two independent readers of Codex session_meta enforce the same privacy-relevant invariant; it has silently drifted twice already #465 exists to remove.
  3. It is not safe as a one-liner, per finding 1. Doing it properly needs a normalization-insensitivity probe that does not exist. That is a design decision with real options, not a patch, and it belongs in an issue where a human can pick.

I also declined to add a regression test to test/core/purge-command.test.js, which #482 modifies, rather than grow the conflict surface of a terminal PR for a non-regression.

Findings 4 and 5: stale figures in the PR body (low, LEFT for a human)

The orchestrator corrected the "108 cases" bullet and the cache-hit table. Two stale claims survive. I cannot edit the body.

Finding 4. The VERIFIED section still reads:

  • Folding is monotone (108-case exhaustive check above, plus the two named hole-punching guards).

The corrected bullet 40 lines up now says 72. Decision: change 108-case to 72-case. Same error, second occurrence, missed by the first pass.

Finding 5. Test counts predate round 1's added test:

body says actual (executed at 1bf7baac)
"test/core/usage-policy-fold.test.js, 22 tests" / "All 22 pass here" 23
"npm test: 2949 tests, 2939 pass, 8 fail" 2950 tests, 2940 pass, 8 fail, 2 skipped
"Fourteen mutants, each killed by a named test" 15; the table has no row for round 1's non-ENOENT probe mutant

The 8 failures are the pre-existing test/core/leave-command.test.js set, unrelated. Decision: bump the three counts and add the M15 row, or drop the digits. No code implication.

Round 1's five fixes: all held, all load-bearing

Re-verified rather than assumed.

  1. fold.js:214, the unmemoized non-ENOENT probe failure. Re-applied the mutant (byDev.set(st.dev, false) before the return false). Baseline 23 pass 0 fail; mutated 22 pass, 1 fail, reddening exactly createVolumeCaseProbe does not memoize a non-ENOENT failure of the flipped stat. Genuinely load-bearing.
  2. The escaped fixtures. grep -cP '[^\x00-\x7F]' on test/core/usage-policy-fold.test.js returns 0; the literals are 'café', 'café' and 'ééééé'. The collapse is now unrepresentable, not merely detectable. Worth noting that this trap is live: my own shell heredocs silently collapsed an NFD literal to NFC twice during this review, once producing a false negative I nearly reported. The tripwire is earning its place.
  3. fold.js:123-129, the memo-cost JSDoc now states the real bound (n stats plus one per distinct volume, not one pair per volume). Accurate.
  4. LLP 0050 Cost carries both normalize regimes with measured numbers and the instruction to re-measure with a decomposed path. Accurate.
  5. LLP 0050 "Not covered" names each CLI site and its direction. Accurate, and extended by finding 2.

Rebased on #482: still correct

Checked concretely rather than by reading. git merge-tree of this head against #482 (27dc367c): three conflicting files (matcher.js, src/core/usage-policy/index.js, llp/0049), 7 conflict hunks in matcher.js, two of them large and covering exactly the selectGoverning / deepestMatch region. llp/0050 auto-merges, including my edits. Textual conflict is total in that region, as both PRs predicted.

The monotonicity argument survives, and canonicalSpellings does not weaken it. The load-bearing fact is that it always returns the lexical spelling, first: return outcome.path === lexical ? [lexical] : [lexical, outcome.path]. So #482's declared pass (deepestMatch(cwd, scopes, 1), spellings[0] only) is the same master verdict this PR's declared pass reproduces. Both PRs compute max(master, widened_k) for their own widening. A unified evaluation that takes the max over the union of passes is therefore at least as restrictive as either PR alone, because a max over a superset is monotone. Nothing in canonicalSpellings can lose the lexical spelling, so no unification can drop below master.

Two conditions the unifier must meet, both already visible:

  1. Order: fold(realpath(p)), never the reverse. Folding first can produce a spelling that does not exist on a case-sensitive volume and make realpath ENOENT a path that was fine. Folding the outputs of canonicalSpellings gets this right automatically.
  2. Depth must be measured on the folded form of the matched spelling. Usage-policy gate matches directories, not path spellings: canonicalize both sides #482's matchDepth measures on whichever spelling matched; this PR measures on the folded one, and pins why (NFD is longer in code units than NFC, so a declared-string depth inverts nearest-governs). In a unified spelling set containing both a lexical NFD form and a folded NFC form, raw lengths become incomparable within one pass. Cross-pass combination is by class rank only, so that part is safe, but the within-pass metric must be the folded length. Whoever unifies must keep the folded convention and re-run this PR's nearest-governs is measured on the folded spelling test.

Shapes: this PR's ListScope is {entry, caseInsensitive, foldedDir}, #482's is {entry, spellings}. The unified form is the set, plus a per-entry caseInsensitive (the probe is per-entry because it is per-volume). This PR's ListScope is the shape that gives way, as round 1 said. Natural target: spellings = dedupe([lexical, canonical, fold(lexical), fold(canonical)]) with limit = 1 still meaning the declared pass.

Note also that after both land, hyp purge <path> folds symlinks but not normalization or case. That residue is #485.

Verification

All on the pushed head aee8e6f, in a detached worktree at the PR head with the repo's node_modules.

  • npm test: 2950 tests, 2940 pass, 8 fail, 2 skipped. The 8 are the pre-existing test/core/leave-command.test.js set, identical by name on a pristine origin/master worktree (1555f13).
  • npm run typecheck: clean.
  • test/core/usage-policy-fold.test.js: 23 pass, 0 fail.
  • test/core/llp-ref-hygiene.test.js: 8 pass, 1 skip, 0 fail. LLP 0050 §normalization and the LLP 0049 #scope Extended-by note are accurate and every anchor resolves. I re-confirmed the 0049 note's substantive claim: the ancestor walk is existsSync(candidate) at matcher.js:146, so it genuinely needs no folding and only list membership does.
  • Branch is based directly on current master (1555f13); merge-base is 1555f13 itself, so no merge was needed for the hygiene run.
  • CLAUDE.md: no semicolons in code, no @typedef, no inline import() types, @import uses a root-anchored .js specifier, JSDoc types throughout. Em dashes added by the branch or by my commit: 0 (git diff | grep -cP '\x{2014}').
  • hypaware-core/plugins-workspace/codex/src/exchange-projector.js untouched, so nothing added to the Codex rollout cwd keys on the thread, not the session container (#459) #462 / Codex live projector: an unusable in-band cwd is a miss, not a path (#471) #474 / Codex live projector: an explicit cwd outranks a substituted workspace key at the .hypignore gate #477 conflict surface.
  • My commit is documentation only. No source file changed, so no behaviour moved.

What a human must decide

  1. Body corrections 4 and 5 (the surviving 108, and the three stale test counts plus the missing M15 row). Body-only; I cannot edit it.
  2. Whether to ship with the hyp purge <path> seam open. My read is yes: it is not a regression, --ignored now works and is a real workaround, and the proper fix needs a probe that does not exist. But this is a deletion command that reports success without deleting, in a tool that captures LLM traffic, so it should be signed off knowingly rather than inherited. Tracked as hyp purge <path> reports success and exits 0 while silently retaining rows recorded under an aliased spelling of that directory #485.
  3. Merge order and the unification with Usage-policy gate matches directories, not path spellings: canonicalize both sides #482. Usage-policy gate matches directories, not path spellings: canonicalize both sides #482 is neutral:approved and awaiting a human merge. If it lands first, this PR needs the rebase described above; the conflict is 7 hunks in matcher.js and must be resolved into one spelling set with one most-restrictive-wins pass, not two stacked passes. Do not let it be resolved mechanically.
  4. The case half is still unverified and may not work. Nothing on this host can witness it. I re-confirmed it is inert rather than wrong on Linux, so the risk is bounded to "the macOS case bug is not actually fixed", never "a new bug on Linux". Someone with a Mac should run test/core/usage-policy-fold.test.js on a default and on a case-sensitive APFS volume and check createVolumeCaseProbe()('~/Documents') before this is trusted on darwin.

Round 2 of 2. Fixed and pushed: aee8e6f. Filed: #485.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage (rung: triage, LLP 0017) on head aee8e6f6.

Round 2's last commit, aee8e6f6 (llp/0050-ignore-enforced-in-adapters.decision.md
only, docs-only), had never been reviewed. I assessed it directly, plus the
standing items round 2 left for a future pass. Summary: no true blocker.
Residual findings are all preference-level or already tracked.

The disproved-premise correction (unreviewed commit). Round 2's commit
replaces LLP 0050's old justification for unconditional NFC folding ("no
filesystem this codebase targets lets two paths that differ only by
normalization name two different directories") with the gate-specific one
(max(declared, folded) can only over-restrict, never open the gate). I
independently reran the two-directory demonstration on this host: caf+U+00E9
and cafe+U+0301 created as two mkdirs under one parent came back as two
distinct inodes, two distinct readdir entries, two distinct file contents.
The corrected text states the real justification, not a reworded version of
the disproved one, and I confirmed the code backs it: selectGoverning in
src/core/usage-policy/matcher.js runs the declared and folded passes and
keeps whichever is more restrictive, matching the doc's max(pre_fold, folded) claim. I grepped the repo for the old premise's language and for any
other place that still assumes normalization-insensitivity is universal;
found none, including in fold.js's comments (which describe fold mechanics,
not a safety argument). The correction is complete.

Purge retention gap. I re-verified the mechanism directly:
hyp purge <path>'s subtree predicate (src/core/cache/purge.js) calls
isEqualOrDescendant, a pure lexical string comparison with no folding,
while hyp purge --ignored classifies each row through resolver.resolve(),
which does fold. That matches what LLP 0050's "Not covered" section and #485
both say, and it is unchanged between master and this branch. My own
judgement: this does not need to block #484. It is pre-existing
(byte-identical on master), this PR's diff does not touch purge.js, and
blocking here would not close the gap, it would only delay landing the
.hypignore capture-time fix this PR actually makes while the purge gap
remains exactly as open either way. #485 is filed with a correct
reproduction, a correct warning against a naive foldPath drop-in
(over-deletion on Linux), and is already labeled neutral:fix. I do think
the severity is real (a deletion that reports success and leaves data in
place is worse than most spelling-seam symptoms here), so I'm flagging it
explicitly rather than treating it as routine, but I land on: documenting it
in LLP 0050 plus tracking it in #485 is sufficient, and it does not need
separate human sign-off before this PR ships.

Rebase compatibility with #482. I sanity-checked round 2's monotonicity
argument by fetching fix/issue-479 and running both a git merge-tree and
a real trial git merge --no-commit against this branch. Result: conflicts
in llp/0049-hypignore-usage-policy.spec.md (1 hunk) and
src/core/usage-policy/index.js (1 hunk); matcher.js conflicted with 6
hunks in my reproduction (round 2 said 7, a one-hunk discrepancy, not
substantive); llp/0050 auto-merged cleanly with zero conflict markers,
exactly as round 2 reported. I also confirmed canonicalSpellings (#482's
src/core/usage-policy/canonical.js) always returns the lexical spelling
first in its result array, so any unification that keeps the plain
declared/lexical spelling as one of the candidates in a shared max cannot
drop below master's behavior. The two unification requirements round 2
named check out: folding must happen after realpath (folding is
filesystem-free and must not be used to search for a possibly-nonexistent
normalized spelling before symlink resolution runs on the real on-disk
bytes), and specificity must be measured on the folded form in every pass
(the existing NFD-longer-than-NFC depth-inversion mutant, M6, demonstrates
why: I reproduced it by hand, mutating deepestMatch to measure depth on the
declared spelling instead of the folded one, confirmed it reddens exactly
the named test "resolve: nearest-governs is measured on the folded spelling,
not on the declared one", then restored the file). I did not find
unification to be harder than described.

PR body corrections. Verified against the running repo, not just read:
npm test gives exactly 2950 tests / 2940 pass / 8 fail / 2 skipped, and the
8 failures are all in leave-command.test.js (pre-existing, unrelated).
npm run typecheck is clean. test/core/usage-policy-fold.test.js has
exactly 23 test(...) blocks and all 23 pass. The "folding never loosens"
loop is exactly 72 cases (3 classes x 3 classes x 2 x 2 x 2 spellings). The
mutation table's "Fifteen mutants" text correctly explains the visible table
has 14 rows because one (the non-ENOENT probe mutant) is intentionally
omitted. No stray "108", "Fourteen", or unmatched "22" references remain in
the body. test/core/llp-ref-hygiene.test.js gives 8 pass / 1 skip / 0
fail, matching the body.

Also verified: ref-hygiene passes on this branch, which already contains
current master's tip in its history so no separate merge was needed to
check this; every @ref in fold.js and matcher.js resolves to a real
LLP/anchor pair, including the new LLP 0050#normalization and the LLP 0049#scope Extended-by note, which accurately describes the fold as
monotone. The tripwire fixtures in usage-policy-fold.test.js are genuine
escape-sequence literals with zero raw non-ASCII bytes in the file. Round 1
and round 2 fixes hold; npm run smoke -- session_optout_capture_drop
passes.

Nothing above duplicates #485; the purge-gap discussion is my own
re-judgement of what round 2 already filed there, not a new finding.

@philcunliffe
philcunliffe marked this pull request as ready for review July 30, 2026 08:27
@philcunliffe philcunliffe added neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) and removed neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) labels Jul 30, 2026
#482 (symlink canonicalization) and this branch (NFC/NFD and per-volume case
folding) are the same fix for two different mechanisms by which a filesystem
spells one directory several ways, found by the same review. Both had
independently arrived at the same shape, and #484's own note said whichever
landed second should collapse them into one pass rather than stack two. This
merge does that.

The composition is a map, not a union: `realpath` yields a *set* of spellings
(as-given, canonical), the fold is a *function* on a spelling, so a scope's
widened set is the fold's image of the canonical set. `matchList` therefore
carries one `ListScope` per entry holding the declared spelling, the widened
set, and the case verdict the `cwd` side must be folded through, and there is
exactly **one** two-pass argmax guard (declared-and-unfolded versus
widened-and-folded, most restrictive wins) rather than one per mechanism: the
displacement hazard the guard exists for is identical whichever widening gave a
carve-out its extra reach, so running it twice would buy nothing. The invariant
both sides claim, `class = max(pre_widening, widened)`, now holds for both at
once. `resolve()` keeps #482's outer loop over the `cwd`'s own spellings, and
#484's `usage_policy.fold_tightened` signal now reports any widening that
tightened a verdict, not only a fold.

`matchDepth` takes the longest matching spelling rather than the first. #482's
argument that the first is the longest relies on `canonicalSpellings` ordering
and on no spelling being length-reduced; NFC is length-reducing, so folding
breaks it. Taking the maximum makes nearest-governs independent of arrangement.

The fold stays gate-only, which is the one place the two mechanisms deliberately
do *not* compose. #482 routed `hyp purge --subtree`, `policy unset` and the
upsert-identity check through shared predicates (`scopeGoverns`,
`sameDirectory`, `governingListEntry`); those keep symlink widening and
deliberately skip `foldPath`, per LLP 0050 #normalization's "do not reuse
foldPath in a predicate where widening is not free": unconditional NFC folding
is sound at the gate only because the gate answers `max(...)`, and that argument
does not survive in a predicate that deletes rows, removes an opt-out, or
replaces a stored declaration. The split is now explicit as `listScope` versus
`canonicalScope`, and LLP 0050 records what closing the remaining gap needs.

Both regression suites survive intact and pass: `usage-policy-symlink.test.js`
(#482, incl. the two-pass guard's exact reach) and `usage-policy-fold.test.js`
(#484, incl. per-entry case verdicts and the hashed tightening signal).

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage (rung: triage, LLP 0017) re-run on head 1b98c334, superseding the
prior triage note recorded at aee8e6f6.

What moved

aee8e6f6 (the previously-triaged head) is a real merge parent of 1b98c334, whose
other parent is aceca0ab — current master. Between the prior triage and now, PR
#482
(symlink/realpath canonicalization, fix/issue-479) landed on master:
src/core/usage-policy/canonical.js now exists there and matcher.js gained ~413
lines relative to aee8e6f6. This is exactly the semantic collision the fan-in note
and round 1's review predicted — both PRs independently built a two-pass
max(declared, widened) argmax guard in the same function — and round 2's review
explicitly warned "do not let it be resolved mechanically." A merge commit
(1b98c334, "Merge origin/master into fix/issue-483") resolved it. Nothing had
reviewed or triaged that merge before this run, so I treated it as the primary,
never-reviewed piece of behaviour in this pass, per the brief.

The merge, verified by execution

1. Argmax guard / never-less-restrictive-than-plain-lexical. I wrote a
differential fuzz harness (not committed) that drives the actual merged resolver
(createUsagePolicyResolver from src/core/usage-policy/matcher.js at 1b98c334,
with injected existsSync/readFileSync for the local-only list and an injected
realpathSync prefix-rewrite map to simulate symlinks) against a plain-lexical
reference resolver ported verbatim from matchList as it read at 1555f13 (pre-#482,
pre-#484): filter entries by lexical isEqualOrDescendant, reduce by longest
dir.length, tie-break by class rank. Sweeps: three entries at three depths x every
class triple x symlink-alias placement x both case verdicts; a 4-entry nested
carve-out chain; the PR's own 5-accented-char depth-inversion fixture combined with a
symlink alias on one side; case-and-normalization aliasing at once layered under a
symlink alias (the exact combination the brief named); same-depth ties under a
symlink+fold entry; and 300,000 random cases (1-5 entries, depth 1-4, random classes,
random symlink aliasing, random NFC/NFD/case spelling, random case verdict).
Result: 300,387 cases checked, 0 violations, 66,275 strictly stricter than the
plain-lexical reference.
rank(merged) >= rank(plain-lexical) held for every case,
including every shape that forces both widening mechanisms (symlink aliasing from
#482, Unicode/case folding from #484) to interact in the same resolve() call. This
exceeds round 1's pre-merge sweep (223,184 cases, 0 violations) and is the equivalent
assurance at this head, where the two mechanisms now coexist in one pass.

2. Depth convention. Reading the merged code: ListScope (matcher.js:390-451)
splits into listScope (gate use: widenedSpellings = canonicalSpellings(entry.dir).map(spelling => foldPath(spelling, {caseInsensitive}))
— folded) and canonicalScope (CLI use: symlink-widened only, folded: false).
deepestMatch folds the cwd side the same way before comparing
(nfcCwd ??= foldPath(cwd)), and matchDepth takes the longest matching spelling
(not the first), which is the merge's resolution of the two PRs' disagreement: #484
needed folded-spelling depth (NFD outranks NFC in raw length), #482's ordering
argument ("first is longest") breaks once folding is length-reducing, so the unifier
takes the max instead, which is correct for both. This is a real design decision, not
an accidental byproduct of textual merging.

I confirmed the pinning test resolve: nearest-governs is measured on the folded spelling, not on the declared one is not vacuous: I mutated deepestMatch to use
scope.entry.dir.length (the raw, unfolded declared string) instead of the matched
spelling's length, re-ran test/core/usage-policy-fold.test.js, and got exactly
1 failure — that named test (22 pass / 1 fail vs. the 23/0 baseline), then restored
the file (git diff confirms it is byte-identical to 1b98c334 afterward). Both
regression suites pass at this head: usage-policy-fold.test.js 23/23,
usage-policy-symlink.test.js 16/16.

3. Composition order. resolve() calls canonicalSpellings(key, {realpathSync})
first (symlink resolution) and only then, inside matchList/deepestMatch, applies
foldPath to each resulting spelling — fold(realpath(p)), never the reverse, on
both the cwd side and the entry side (listScope folds the output of
canonicalSpellings(entry.dir, deps)). Confirmed by reading matcher.js:149-164 and
:408-418, and implicitly exercised by every fuzz case that combines a symlink alias
with an NFC/NFD or case divergence, all of which passed.

4. Stacked or unified? Unified, not stacked. There is exactly one
selectGoverning/deepestMatch(cwd, scopes, widened) pair in matcher.js, called
once per resolve() (false then true), over a single ListScope per entry that
already carries both widenings (symlink via canonicalSpellings, fold via
foldPath). This matches the merge commit's own description ("exactly one two-pass
argmax guard... rather than one per mechanism") and I verified it structurally by
reading the diff and functionally via the fuzz sweep — no shape in 300K+ cases showed
evidence of a second, redundant pass changing an outcome. Not an issue either way
(redundancy alone isn't a blocker), but here it's actually unified.

5. Repo checks at this head, clean worktree, node_modules symlinked from
/work/hypaware:

  • npm test: 3037 tests, 3028 pass, 8 fail, 1 skipped. The 8 failures are all in
    test/core/leave-command.test.js (leave after join removes the seed... etc.). I
    confirmed identical by name against a fresh worktree of pristine
    origin/master (14e2d96, current tip — master moved further during this run):
    same 8 names, same file, npm test there gives 3014 tests / 3005 pass / 8 fail / 1
    skipped. Pre-existing, unrelated to this branch.
  • npm run typecheck: clean.
  • test/core/llp-ref-hygiene.test.js: 9/9 pass, 0 skip (the previously-skipped
    #463 renumber case resolved itself once the unrelated LLP-renumbering commits
    landed on master; not a regression).
  • npm run smoke -- session_optout_capture_drop: ok.
  • test/core/purge-command.test.js, test/core/ignore-local-only-command.test.js:
    pass. src/core/cache/purge.js still routes --subtree through scopeGoverns
    (symlink-widened, not folded, per LLP 0050's explicit "do not reuse foldPath
    in a predicate where widening is not free"), unchanged from what Usage-policy gate matches directories, not path spellings: canonicalize both sides #482 alone shipped
    — the merge did not touch this file's behaviour.

Residual findings and classification

  • Round 1 findings 6/7 and round 2 findings 4/5 (stale PR-body digits: "108 cases",
    "Fourteen mutants", "22 tests", "2949 tests").
    Already resolved — I grepped the
    current PR body and found 72-case/72 cases, 23 tests, 2950 tests, 2940 pass, and Fifteen mutants throughout, with no stray 108, Fourteen, or 22
    references left. These were body-only edits neither review round nor either merge
    parent could have made (aee8e6f is docs-only in llp/), so a human must have
    applied them directly via gh pr edit at some point before aee8e6f6's triage,
    which already reported this as closed. Non-blocking: closed, not residual.
  • hyp purge <path> retention gap (hyp purge <path> reports success and exits 0 while silently retaining rows recorded under an aliased spelling of that directory #485). Re-confirmed unchanged at this head:
    scopeGoverns in the subtree predicate is symlink-widened only, so an NFC/NFD or
    case divergence between a recorded row and the purge argument still retains the
    row while reporting success. Byte-identical to master behaviour (this PR's diff
    never touches purge.js; the merge's only change there was Usage-policy gate matches directories, not path spellings: canonicalize both sides #482's own
    scopeGoverns wiring, already present on master before this merge). Preference
    / pre-existing, not a blocker for this PR
    — tracked in the already-open
    neutral:fix issue hyp purge <path> reports success and exits 0 while silently retaining rows recorded under an aliased spelling of that directory #485, which correctly notes it reproduces on master and is
    not branch-specific. No new finding to add there.
  • macOS case-probe half unverified. createVolumeCaseProbe's darwin branch is
    still unexercised by any test in this repo (inert, zero-syscall off darwin, and I
    did not add a way to verify it on this Linux host — no way to). This is an
    accepted, documented limitation of a fail-safe-by-construction mechanism (an
    undetermined probe or a wrong verdict can only fail toward the pre-fold behaviour,
    never toward full), not a defect at this head. Non-blocking, same
    classification as the prior triage.
  • The merge itself. Verified above by execution across five separate angles
    (monotonicity fuzz, depth convention + non-vacuous mutation test, composition
    order, stack-vs-unify, full repo checks). No violation found in 300K+ differential
    cases, the two regression suites both pass, and the unification is a real design
    resolution (documented in LLP 0050's §normalization, updated listScope /
    canonicalScope split) rather than a naive textual stack. Non-blocking.

Verdict

Case A — every residual finding is non-blocking. No true blocker found in the
residual set or in the merge. An open neutral:fix follow-up for this PR already
exists (#485); no new issue opened, and nothing found here that #485 does not
already cover, so no comment added there either. Per the triage protocol this PR is
now clear to route to ready-hold on the next tick.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 30, 2026
@philcunliffe
philcunliffe merged commit f457009 into master Jul 30, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-483 branch July 30, 2026 21:30
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
Three files conflicted. Each resolution is a union of both intents, not a
side-picking, because the two sides changed different things about the same
subject (Codex session identity and the opt-out caveat).

session_command.js: keep this branch's EPHEMERAL_NOTE constant (#455: the
caveat names the fork as well as the restart) and master's object-argument
provenanceNotes({ idSource, idEvidence, threadId, endpointSource }) call. Only
one of the two call sites conflicted; the other already carried the combination,
so this makes the two branches of the if/else identical again.

types.d.ts: keep master's prose, which is the correct one now - it explains
sessionId as the session container versus threadId, and the merged union already
carries master's rename of codex_env to codex_env_rollout, which this branch's
prose still called codex_env. Re-attached this branch's gloss on the
@ref LLP 0067#cli-session-id annotation, which master had left bare. Anchor
{#cli-session-id} verified present at llp/0067:305.

codex privacy skill SKILL.md: this branch's rollout-selection policy wins (match
payload.cwd, refuse on zero/ambiguous/stale, never newest-by-mtime - issue #452),
since master's side still said "pick the newest rollout". Folded master's
readRolloutMeta-mirroring guards into that same walk rather than dropping them:
the session_meta type check and payload-dict check (as skips, since this side
walks many files), plus the non-string and whitespace-in-id refusals, which this
side needs too because it reads the result through `read -r`. Took master's
`hyp session ignore --json` over the bare verb.

Semantics checked, not just textual cleanliness:

- The claim "the gateway's drop keys on the container" is STILL TRUE after
  PR #477. exchange-projector.js line 113 is unchanged:
  `const sessionId = stringValue(codexContext?.session_id) ?? conversationId`,
  i.e. metadata.session_id falling back to the conversation (thread) id, exactly
  as the skill prose and the test comment describe. #477 (bcad4a4) only changed
  the separate .hypignore gate, which keys on a cwd path, not on any session id.
  So no correction was needed there.
- The three-way agreement still holds between the CODEX_THREAD_ENV comment in
  session_command.js (master renamed it from STATED_SESSION_ID_VARS), the skill's
  Step 1 prose, and the drop code: thread id is a selector, the container is the
  answer.
- Corrected one thing that HAD gone stale: this branch's prose said issue #453
  "puts CODEX_THREAD_ID to its real use" in the future tense, but #453 shipped on
  master (5d270a5, c551d6e). The prose now describes the codex_env_rollout source
  as live, matching llp/0067:317 ("selector, not an answer") and llp/0067:585.
- The fork caveat agrees across all three surfaces it appears on: EPHEMERAL_NOTE,
  the Codex skill, and the Claude skill (`claude --fork-session` / `codex fork`).

The usage-policy unification (#482/#484, fold(realpath(p))) touches no file on
this branch and needed no reconciliation here.

Checks: npm test 3041 tests, 8 failures, all of them the pre-existing
test/core/leave-command.test.js "leave ..." set, name-for-name identical to a
pristine origin/master run (3037 tests, same 8). Test count rose by 4, which is
this branch's new tests passing. npm run typecheck clean.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
The behaviour is kept; the reason given for it was wrong and is replaced.

LLP 0160, the `workspaceCoversCwd` JSDoc and the warn-site comment all
claimed that when the key is an ancestor of the in-band `cwd`,
`resolve(cwd)` is at least as restrictive as `resolve(key)`, so refusing
the substitution "can only tighten". That is false on this resolver.
Nearest-governs means a declaration between the key and the `cwd`
overrides the key's and may be less restrictive. Swept against the real
`createUsagePolicyResolver` over `.hypignore` bodies and machine-local
list classes at four depths on one ancestor chain: 131 of 576
arrangements resolve the `cwd` LESS restrictively than the key, spanning
ignore->local-only, local-only->full and ignore->full.

Restated on the ground that actually holds: an ancestor key is not a
guess about where the session ran, it is a less specific name for the
same tree, and nearest-governs makes the `cwd`'s own declaration
authoritative. So the signal reports the location inference, not a
verdict change - and the residue (an ancestor key resolving more
restrictively than its `cwd`, now silent) is disclosed rather than
implicitly denied, and pinned by a test.

Also corrected:
- the symlink residue cited #479 as an open gap; #479 was closed for the
  gate by #482/#484. It is knowingly retained here, at this
  reporting-only predicate, and the converse direction (a lexical
  descendant that is really a symlink out of the tree reads as covered,
  so stays silent) was undisclosed.
- LLP 0160 and LLP 0083 pointed the undecided enrichment question at
  #481, which this PR closes. It is split out to #492.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Jul 31, 2026
One conflict, in `llp/0083`'s `Related:` header line: #462 added LLP 0066
and LLP 0067, this branch added LLP 0160. Resolved as the union, since the
two edits are independent additions to a list.

Everything else merged textually clean, including
`codex/src/exchange-projector.js`, where the two sides turned out to touch
disjoint concerns: #462 rewrote the rollout fallback's lookup key (session
container to thread id, the new `resolveRolloutCwd`), while this branch
changed `refused_workspace_cwd`'s predicate in `resolveCodexContext` and the
warn's comment block above it. Checked rather than assumed:

- `isEqualOrDescendant` is byte-identical on master and still exported from
  the usage-policy index, so the shared predicate this branch reuses (LLP
  0069 R8) is the same function it was written against.
- The two review-established facts survive. Nearest-governs is intact
  (nearest `.hypignore` walk byte-identical, deepest list entry via
  `matchDepth`), so the disclosed non-monotonicity is still real and the
  pinning test still has something to pin. The narrowed warn still carries
  no `class` / `declared` / `governed_by`, which is the executed ground the
  disposition rests on.
- `scopeGoverns` and `canonicalSpellings` still sit next door after #484, so
  the deliberate choice of the lexical predicate on this hot path (LLP 0049
  R6) remains a live choice rather than a stale one.
- LLP 0160 §corrections-0083 says 0083's closing #476 sentence went stale.
  That bullet is byte-identical between the merge base and master, so the
  correction still lands where it says it does.
- `pathsEqual` is still used by `selectCodexWorkspace`, so it is not
  orphaned by the predicate swap.

Checks on the merge result: `npm test` 3081 pass / 8 fail, `npm run
typecheck` clean. The 8 are `test/core/leave-command.test.js`, pre-existing:
a pristine `origin/master` (bd7dd0b) worktree fails the identical 8 by name,
diffed as sorted name lists with zero difference, at 3078 pass / 8 fail. The
merge adds exactly the 3 new #481 passes and no new failures.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe added a commit that referenced this pull request Jul 31, 2026
… finding 2 of 2) (#491)

* Codex workspace-cwd refusal is an ancestor test, not a byte test (#481)

The refusal predicate was "a `workspaces` key was substituted and it is not
byte-equal to the in-band cwd", so the completely ordinary shape "a session
running in a subdirectory of its declared workspace" emitted
`plugin.codex.usage_policy_workspace_cwd_refused` at `warn` on every single
turn, with both directories clean and no `.hypignore` anywhere. A privacy warn
that fires constantly on the common case is read as noise, and the signals
beside it are read as noise with it.

Narrow it to keys off the in-band cwd's ancestor chain. The justification is
not "close enough": when the key is an ancestor, the cwd's `.hypignore` walk
passes through the key and every machine-local entry governing the key also
governs the cwd, so resolving the cwd is at least as restrictive as resolving
the key would have been. The refusal can only tighten, so there is nothing to
report. Off the chain the two walks are incomparable, in both directions - a
sibling tree, and a key BELOW the cwd whose own walk covers strictly more - and
those still warn.

The test is the shared `isEqualOrDescendant` (LLP 0069 R8), not a second copy
of the path rule. Lexical rather than spelling-agnostic on purpose:
`scopeGoverns` buys its extra reach with realpath syscalls this per-exchange
seam must not spend (LLP 0049 R6), and the residue errs toward reporting.

LLP 0160 records the decision, the ancestor-monotonicity argument, and what is
now stale in LLP 0083's #476 limit sentence; LLP 0083 gains the forward-ref.

Does NOT address the other finding deferred from PR #477: a row recorded where
it used to drop still carries an ignored workspace's identity through the key's
surviving enrichment role. That is a privacy-relevant default the corpus does
not settle, and it stays open under #481.

Co-Authored-By: Claude <noreply@anthropic.com>

* Review: the ancestor test's justification was a false monotonicity proof

The behaviour is kept; the reason given for it was wrong and is replaced.

LLP 0160, the `workspaceCoversCwd` JSDoc and the warn-site comment all
claimed that when the key is an ancestor of the in-band `cwd`,
`resolve(cwd)` is at least as restrictive as `resolve(key)`, so refusing
the substitution "can only tighten". That is false on this resolver.
Nearest-governs means a declaration between the key and the `cwd`
overrides the key's and may be less restrictive. Swept against the real
`createUsagePolicyResolver` over `.hypignore` bodies and machine-local
list classes at four depths on one ancestor chain: 131 of 576
arrangements resolve the `cwd` LESS restrictively than the key, spanning
ignore->local-only, local-only->full and ignore->full.

Restated on the ground that actually holds: an ancestor key is not a
guess about where the session ran, it is a less specific name for the
same tree, and nearest-governs makes the `cwd`'s own declaration
authoritative. So the signal reports the location inference, not a
verdict change - and the residue (an ancestor key resolving more
restrictively than its `cwd`, now silent) is disclosed rather than
implicitly denied, and pinned by a test.

Also corrected:
- the symlink residue cited #479 as an open gap; #479 was closed for the
  gate by #482/#484. It is knowingly retained here, at this
  reporting-only predicate, and the converse direction (a lexical
  descendant that is really a symlink out of the tree reads as covered,
  so stays silent) was undisclosed.
- LLP 0160 and LLP 0083 pointed the undecided enrichment question at
  #481, which this PR closes. It is split out to #492.

Co-Authored-By: Claude <noreply@anthropic.com>

* Review round 2: purge the disproved monotonicity claim from the places round 1 missed, and ground the retained justification by execution

Round 1 replaced the false "an ancestor key can only tighten" proof in LLP 0160
§decision and in the `workspaceCoversCwd` JSDoc, but the same claim survived in
three other places, one of them LLP 0160's own abstract, where it contradicted
the §decision body directly:

- `llp/0160-...md:13` - the summary blockquote still said "An ancestor key
  cannot have changed the `.hypignore` verdict".
- `llp/0083-...md:144` - the `Extended-by` block said the same.
- `test/plugins/codex-exchange-projector.test.js` - the `@ref LLP 0160#decision
  [tests]` block said "taking its own `cwd` over an ancestor key can only
  tighten the verdict". A ref that states a disproved premise is exactly what
  CLAUDE.md's "keep refs honest" rule is for.

All three now state the ground that actually holds, and say explicitly that it
is NOT a monotonicity argument.

Round 1 also carried the disposition on a reasoned claim: "in every one of the
131 cases the loosening is the user's own nested declaration". Two changes:

1. A better, EXECUTED ground is now stated and pinned by a test. The warn this
   PR narrows carries no usage class at all. On `origin/master` it fires with an
   identical field set on a subdirectory turn with no `.hypignore` anywhere, on
   one where key and `cwd` both resolve `ignore`, and on the loosening
   arrangement. It could never have distinguished them, so narrowing it removes
   a constant, not privacy information. The genuine-refusal test now asserts the
   warn carries no `class`, `declared` or `governed_by`.

2. The provenance claim is corrected where it was too strong. The machine-local
   list, the only source that reaches an explicit `full`, has exactly two
   writers, both behind explicit `hyp ignore`/`unignore`/`policy set` verbs, and
   LLP 0071 §not-central forbids anything central writing one - so the
   `->full` transitions really are the user's own. But a `.hypignore` is a
   COMMITTABLE file by design (LLP 0071 §not-dotfiles) and the ancestor walk has
   no vendored-tree exclusion, so the `ignore->local-only` transition can come
   from a dependency's own file. Bounded (the walk only goes up, and
   `.hypignore` cannot express `full`), unchanged by this PR, and now disclosed
   rather than asserted away.

Also notes that the 131/576 count's mixed slice is enumeration-dependent; the
two source-pure slices, 50 and 75, reproduce exactly.

No behaviour change: predicate, gate, row and drop path untouched.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: test <test@test.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: test <test@example.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Usage-policy gate is escapable on macOS by path case or Unicode normalization: realpath does not fold either

1 participant