hyp purge <path> reports success and exits 0 while silently retaining every row recorded under a different spelling of that directory. The user asked for their captured LLM traffic to be deleted, the command said it worked, and the data is still in the cache.
This is a defect on master. It is not introduced by PR #484 or #482. It is being filed now because PR #484 closes #483, whose body lists this purge behaviour as one of the symptoms, so closing #483 would retire the only place it was written down.
Reproduction (executed on Linux, master @ 1555f13)
Rows are recorded under one spelling of a directory, the purge names the other. Both spellings are built as string literals, so this is fully witnessable off macOS: the subtree predicate is a byte comparison and never touches the filesystem.
$ hyp purge ~/café/proj --yes # typed NFC; rows were recorded under NFD
purged 0 rows from 0 partitions
$ echo $?
0
| 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 |
Identical on master (1555f13) and on PR #484's head, so #484 ships no regression here.
Why it is worse than the count suggests
Cause
src/core/cache/purge.js, the subtree branch of buildPredicate:
if (!isEqualOrDescendant(path.resolve(row.cwd), base)) return false
isEqualOrDescendant is a pure lexical prefix test and path.resolve folds neither Unicode normalization nor case. Two processes at different times produce the two strings (a CLI resolving the purge target, versus a client that recorded a cwd), so divergence is ordinary rather than user error. Same root cause as #479 (symlinks) and #483 (case / normalization), different call site.
What already covers part of this
So the residue this issue tracks is precisely: the subtree predicate does not fold the spellings #483 is about, after both PRs land.
The fix is not a one-liner, and that is the point
Do not close this by dropping foldPath into the predicate. At the gate, widening the match is free because the resolved class is max(declared, folded), so a wrong fold can only over-restrict. In a deletion predicate widening deletes. On a Linux volume, caf + U+00E9 and cafe + U+0301 are two directories with two inodes that can both exist in one parent (verified on this host: distinct ino, distinct contents, both listed by readdir), so folding NFC inside the purge predicate would delete cached rows for a genuinely different sibling directory the user never named. Same argument applies to case folding on a case-sensitive volume.
A correct fix needs the fold gated on the volume actually being normalization-insensitive. No such probe exists today: createVolumeCaseProbe (src/core/usage-policy/fold.js) answers only the case question. Options, for whoever picks this up:
- Add a per-volume normalization-insensitivity probe alongside the case probe (
mkdir/stat a decomposed spelling of an existing entry and compare dev/ino), and gate the purge fold on it. Most correct, most work.
- Restrict purge-side folding to darwin, matching the existing probe's inertness off darwin. Cheap, and no worse than the platform assumption already shipped.
- Widen the match but require confirmation, i.e. report "N additional rows match a different spelling of this path" and let the user opt in. Preserves the no-surprise-deletion property without needing a probe.
Whichever is chosen, it belongs in the one shared predicate (scopeGoverns, introduced by #482) rather than as a second folding rule at the call site, per #465.
Acceptance
- Purging a directory whose rows were recorded under an aliased spelling either deletes them or tells the user it did not, on a volume that treats the spellings as one directory.
- No over-deletion on a volume that treats them as two: a purge of
caf+U+00E9 must not touch rows recorded under cafe+U+0301 on Linux.
- A regression test in
test/core/purge-command.test.js covering both directions, with the two spellings written as \u escapes so an editor re-normalizing the file cannot collapse them (see the tripwire in test/core/usage-policy-fold.test.js).
Related
#479 (symlink class, PR #482), #483 (case/normalization class, PR #484), #465 (the duplicated-invariant pattern to avoid), LLP 0050 §normalization "Not covered", LLP 0104.
Filed by the round-2 review of PR #484.
hyp purge <path>reports success and exits 0 while silently retaining every row recorded under a different spelling of that directory. The user asked for their captured LLM traffic to be deleted, the command said it worked, and the data is still in the cache.This is a defect on
master. It is not introduced by PR #484 or #482. It is being filed now because PR #484 closes #483, whose body lists this purge behaviour as one of the symptoms, so closing #483 would retire the only place it was written down.Reproduction (executed on Linux,
master@1555f13)Rows are recorded under one spelling of a directory, the purge names the other. Both spellings are built as string literals, so this is fully witnessable off macOS: the subtree predicate is a byte comparison and never touches the filesystem.
purged 1 row from 1 partitionpurged 0 rows from 0 partitionspurged 0 rows from 0 partitions/home/u/proj/home/u/Projpurged 0 rows from 0 partitionsIdentical on
master(1555f13) and on PR #484's head, so #484 ships no regression here.Why it is worse than the count suggests
policy unsetrefuses to remove an opt-out). This one fails away from it: data the user explicitly asked to destroy stays on disk.Cause
src/core/cache/purge.js, thesubtreebranch ofbuildPredicate:isEqualOrDescendantis a pure lexical prefix test andpath.resolvefolds neither Unicode normalization nor case. Two processes at different times produce the two strings (a CLI resolving the purge target, versus a client that recorded acwd), so divergence is ordinary rather than user error. Same root cause as #479 (symlinks) and #483 (case / normalization), different call site.What already covers part of this
hyp purge --ignoredis not affected once PR Usage-policy gate folds path spellings a volume treats as one directory (NFC verified, case unverified) #484 lands, because that target classifies each row throughresolver.resolve(row.cwd)and so inherits Usage-policy gate folds path spellings a volume treats as one directory (NFC verified, case unverified) #484's fold. Verified: with anignoreentry declared NFC and rows recorded NFD,masterpurges 0 rows and Usage-policy gate folds path spellings a volume treats as one directory (NFC verified, case unverified) #484 purges the row. Marking the directory and runninghyp purge --ignoredis the workaround until this is fixed.scopeGovernspredicate. After Usage-policy gate matches directories, not path spellings: canonicalize both sides #482 merges,hyp purge <path>folds symlinks but still not normalization or case.So the residue this issue tracks is precisely: the subtree predicate does not fold the spellings #483 is about, after both PRs land.
The fix is not a one-liner, and that is the point
Do not close this by dropping
foldPathinto the predicate. At the gate, widening the match is free because the resolved class ismax(declared, folded), so a wrong fold can only over-restrict. In a deletion predicate widening deletes. On a Linux volume,caf+ U+00E9 andcafe+ U+0301 are two directories with two inodes that can both exist in one parent (verified on this host: distinctino, distinct contents, both listed byreaddir), so folding NFC inside the purge predicate would delete cached rows for a genuinely different sibling directory the user never named. Same argument applies to case folding on a case-sensitive volume.A correct fix needs the fold gated on the volume actually being normalization-insensitive. No such probe exists today:
createVolumeCaseProbe(src/core/usage-policy/fold.js) answers only the case question. Options, for whoever picks this up:mkdir/stata decomposed spelling of an existing entry and comparedev/ino), and gate the purge fold on it. Most correct, most work.Whichever is chosen, it belongs in the one shared predicate (
scopeGoverns, introduced by #482) rather than as a second folding rule at the call site, per #465.Acceptance
caf+U+00E9 must not touch rows recorded undercafe+U+0301 on Linux.test/core/purge-command.test.jscovering both directions, with the two spellings written as\uescapes so an editor re-normalizing the file cannot collapse them (see the tripwire intest/core/usage-policy-fold.test.js).Related
#479 (symlink class, PR #482), #483 (case/normalization class, PR #484), #465 (the duplicated-invariant pattern to avoid), LLP 0050 §normalization "Not covered", LLP 0104.
Filed by the round-2 review of PR #484.