Skip to content

fix(store): purge-org decides the users file by what deleting the root takes — a mount or link of a directory under the root no longer lets it delete the users file, and "move it out first" works when followed - #163

Merged
MoebiusX merged 39 commits into
developfrom
codex/purge-org-users-file-identity
Oct 15, 2026

Conversation

@MoebiusX

@MoebiusX MoebiusX commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

This closes the two purge-org problems you asked to have fixed in their own PR ("close both in their own PR, keep #161 as is"). #161 is untouched. Cut from origin/develop 4123bc7, with develop merged in since (now 8500b48, #161 and #162 included).

The two problems

  1. Linux/macOS (major, pre-existing). purge-org deleted a users file whose bytes live under the org root while its recorded path resolves outside it. The check compared paths with realpath, and realpath never crosses a mount back to its source. Some cases that hit this:
    • a bind mount of a directory under the root, named directly, through a file link or through a directory link, with the mount up or down;
    • an NFS or SMB loopback mount of such a directory;
    • with nothing recorded, a <base>/users.json that links into the root.
  2. Every platform. The refusal "the recorded users file … lies under <root> — move it out first" had a way out that failed when followed literally. After the move, the record still named the path under the root, and the same refusal came back word for word.

Both were reproduced at develop with real bind mounts in a private mount namespace, through packc store purge-org on a real store:

  • mount up, mount down, file link, directory link, and <base>/users.json linked into the root: the users file was deleted;
  • <root>/m a bind mount of a tmpfs from outside the root: Node 22.22 failed on it with EBUSY before deleting the files there, but Node 24.19 deleted them, the users file among them;
  • the "move it out first" loop.

The fix (server/store/users-file-place.mjs, new; server/store/ops.mjs)

purge-org decides by what deleting the root would take, not by how the path is spelled.

  • It reads the root before deleting it, as rmSync would take it: lstat on each entry, never following a link. Names are read as bytes, so a name that is not valid UTF-8 is read too.
  • It follows the users file's path through every link. The users file lies under the root when an entry is the same file:
    • the same device and inode;
    • or, for a view on another filesystem (NFS, SMB, overlay), the same inode, size and modification time.
  • A recorded users file that does not exist is judged by where its path leads. It passes only when the nearest existing directory on its path is under the root and no entry under the root has its name. A path that runs only through the root's own parent directories passes too. Anything else is refused, because a mount that is down may lead the path under the root. With this, "move it out first" works when followed literally, and moving the file out for one org no longer blocks later purges of other orgs.
  • These are refused before anything is deleted:
    • an entry under the root with the users file's name, because a mount that is down may lead the path there;
    • a users file or directory purge-org cannot read;
    • a mount point under the root, or the root itself mounted. On Linux, this includes same-filesystem bind mounts, which /proc/self/mountinfo lists; mounts a later mount has covered are skipped;
    • a directory that changed while purge-org read the root.
  • Every refusal names the entry and a way out that works when followed literally.
  • Memory: the read keeps what it needs per directory, not per file. Its memory grows with the number of directories, not files.
  • Docs: README "Purge A Removed Org" lists every refusal and its way out. The CHANGELOG and UPDATE_JOURNEY say the same.

Declared default changes (CHANGELOG purge-org-users-file-identity)

  • purge-org decides the users file by what deleting the root takes, on every platform. When nothing is recorded, this covers <base>/users.json too.
  • False refusals, in the safe direction for a deletion:
    • a hard link under the root to the users file;
    • on another filesystem, a copy of the users file that kept its size and modification time;
    • a recorded users file outside the root that was deleted on purpose. The way out: delete the root by hand, then run purge-org again.
  • New refusals: a users file purge-org cannot read (EACCES, a link loop), a mount point under the root, a directory it cannot read, and a directory that changed while it read the root.
  • purge-org reads the whole root before deleting it.

Review

Before anything was built, a design attack tried to break the plan with real mounts. It found that the old check's order kept problem 2 open, plus holes through overlayfs, through an unrecorded <base>/users.json and through mount points. All were taken. Then three rounds followed, each with a proof reviewer (every commit green alone, every fix mutation-checked) and an attacker using real bind mounts, tmpfs, overlay and unprivileged runs.

  • Round 2 fixed:
    • names that are not valid UTF-8, which the walk skipped while rmSync deleted them (major);
    • a down mount whose mount point keeps an old users.json;
    • moving the file out for one org blocking every later purge of another org;
    • memory growing with every file under the root (a regression);
    • the overlay and sshfs ways out;
    • a rename racing the walk;
    • a false refusal of a workspace on an overlay whose layers sit on different filesystems;
    • a test that failed on macOS, and tests a reverted rule did not fail.
  • Round 3 fixed:
    • a covered mountinfo entry refused with an "unmount it" way out that failed (a regression);
    • a bind mount under the root that is down: its way out led to the file being deleted;
    • later purges with a workspace reached through a symlink;
    • four rule parts no test caught;
    • the memory figures in the CHANGELOG.
  • Round 4 fixed what round 3's attacker found:
    • a mount stacked on / taken as covering every mount (dacac21);
    • a users file reached through a mount point under the root by a link of its name: unmounting as told then deleted it (a5ea1d8);
    • a covered mount whose path leads through a mount of its own filesystem, such as mount --bind <base> <base>: rmSync failed with EBUSY part-way. The refusal now names what to unmount first, in order (1b7a9d5);
    • two rule parts no test caught (6a9fcf3).
  • Round 5 fixed what round 4's proof found:
    • a regression of 1b7a9d5: in a chroot into a plain directory, mountinfo does not list the mount the root lies in, so a covered mount was refused, and the named way out unmounted the workspace's own filesystem. Now /'s filesystem decides there (2ff689f);
    • seven rule parts no test caught, each with a real-mount effect (1049f5f);
    • two ways out that failed when followed literally: a covering mount that hides the workspace now says to mount it again (ca89ada), and a mount point holding mounts of its own names them innermost first (bf1ad37).
  • Round 6 closed the two test gaps round 5's attacker found (eef0747, 37b1032). Its proof held every fix, and its attacker found nothing.
  • Every round's commits are green alone, and reverting any fix fails its test. Real mounts in private namespaces (bind, rbind, tmpfs, overlay, chroot) were used throughout; a non-root CI runner runs the injected-filesystem tests.

Gate

Every commit was checked on its own, with both Node versions and Playwright:

  • Lint: 0 errors / 153 warnings, with no new key by file+rule+message.
  • npm test: passes on Node 22.22 and 22.16.0, apart from the 1 known environmental skip.
  • Goldens: byte-identical.
  • Checks: vendor-manifest:check, sync-spec --check and test-doc-test-totals all pass.

The Tests chain ran 1425 → 1448 on the branch's own base. Rounds 1 and 2 were also run at HEAD through the node-floor run and the live gates.

Develop merged in (no rebase)

On the Windows CI legs

The first Windows run (07503ec) failed one of this PR's tests on windows (node 22.16.0) only; windows (node 22) passed.

  • e630147 makes a purge refusal that does not match name what was thrown. The summary a CI log ends with prints a failure's error line alone, and the old helper left only a path there. The next run named the refusal: <w2>\orgs\acme (another filesystem than <w2>\orgs) is a mount point, with orgs\ a junction on the same volume.
  • The cause, in libuv 1.49.x (Node 22.16): its FILE_STAT_BASIC_INFORMATION declares FileId128 before VolumeSerialNumber, swapped against Windows' layout (fixed in libuv 1.51.0, Node 22.23). So a stat by name reports device 0 on NTFS, while a path ending in a junction is opened and reports the real volume serial. No mask can reconcile them.
  • fdb1603 reads a directory's volume through a handle on Windows (openSync + fstatSync + closeSync), which gives the real serial on every libuv and follows a junction to its volume. On Windows only directories are compared, since a volume can only be mounted in a folder. A new test over the injected Windows host models both libuv versions and fails without the fix. POSIX behaviour is unchanged.

Merge with #161 (now on develop)

Develop 8500b48 (#162) merged in

Known, not fixed

  • Off Linux, or with /proc not mounted, two limits follow from having no mountinfo:
    • a same-filesystem bind mount under the root of an outside directory looks like a plain directory. The walk enters it, and a users file seen through it is named as an entry to move;
    • any entry on another filesystem than its directory is refused as a mount point. That includes a lower layer's file of an overlay whose layers sit on different filesystems: a false refusal on Linux without /proc. The way out is to mount /proc.
  • A view that reports neither the inode nor the size and modification time (some FUSE filesystems, e.g. sshfs without use_ino) is not matched as the users file; such a view of a directory is still refused by the file's name. NFS, SMB and FUSE were modelled with an injected filesystem only, because this container has none of them.
  • After a move, the store still records the old path, where the in-place export writes the file. The refusal and README say to make that path lead to the file again before a rollback.
  • Check-then-act: a change by another process between the final re-read and rmSync is not seen.
  • A directory under the root whose dentry is a mount point listed at another path (a bind of <root>/sub elsewhere with something mounted on that other path) is passed over. This is pre-existing (the same on develop), outside the two problems this PR fixes, and found by round 5's attacker.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ


Generated by Claude Code

claude added 30 commits October 13, 2026 21:24
…by what deleting the root takes, not by the path's spelling — /mnt/b/users.json, /mnt/b a bind mount of <root>/sub (named directly, through a file link or a directory link), compared as outside the root since realpath never crosses a mount back to its source, and was deleted; a mount point under the root, or the root mounted, is refused

purge-org walks the root as rmSync takes it (lstat, never through a link)
and refuses when an entry is the users file a pre-store build reads (the
recorded one, else <base>/users.json): the same device and inode (on
Windows the serial's low 32 bits), or on another device the same size and
modification time at the coarser clock's precision (NFS, SMB, overlay and
FUSE views). It names every such entry, hard links included. A mount point
under the root, the root itself mounted, and a directory the walk cannot
read are refused before anything is deleted: on Node 22 rmSync stops
part-way at a mount point, on Node 23 and later it first empties the
filesystem mounted there (reproduced on 24.19: a users file outside the
root whose directory was mounted under it was deleted). The checks by
path, and the Windows null refusal, run first and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…st by where its path leads, so "move it out first" works — after the move the record still named the path under the root, realpath fell back to it as written and the same refusal came back; through a mount that is down (an empty mount point) the users file under the root was deleted; and one it cannot read (EACCES, a link loop), compared as written, was purged

The comparison by path now holds only while the users file exists. One
that does not is followed as the kernel resolves a path, every link that
exists followed and a dangling one by its target, to the nearest directory
that exists: a directory under the root (by identity) means nothing of it
is left there and the purge goes on; any other is refused, failing closed,
naming the ways out (mount it; or delete the root by hand when it does not
lie under it). A users file outside the root deleted on purpose is refused
alike (declared). An absent <base>/users.json, which nothing records, is
nothing to keep, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ore it deletes anything — the users file a pre-store build reads, decided by what deleting the directory takes; a recorded users file that does not exist whose path does not lead under it; what it cannot read; a mount point — with each way out, and no longer says it deletes the directory "at once"

The k8s note points there, and the purge-org docs test reads both and pins
that each way out the README names is one purge-org's refusals name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ollows a link under the root, passes an entry of a directory it may list but not search, passes an error finding where a missing users file's path leads, or drops the directory view rule — each of these changes to server/store/users-file-place.mjs stayed green

Four new cases, each in the existing test it belongs to, so the total stays
1440:
- links under the root that lead out (to the users file, to its directory,
  to a directory on another filesystem): the purge passes. When the walk
  stat()ed through links, each gave a false refusal ("lies under", "a mount
  point").
- a directory it may list but not search (r--): readdirSync names the
  entries, lstatSync of each fails with EACCES, and the purge is refused
  naming that entry. This needs deny(p, code, { list }) in
  server/fixtures/mount-fs.mjs.
- a part of the users file's path that stops answering once statSync found
  nothing there: the purge is refused, naming that part.
- the NFS and SMB views' way out, followed: with the users file moved out of
  the root, its path leads to the view of the directory under the root (the
  same inode and modification time), and the purge passes.

The code is unchanged. The CHANGELOG Tests bullet names the four cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…recorded, for absent only when nothing is at its path — a link there that leads nowhere now (<base>/users.json → /mnt/b/users.json, /mnt/b a bind mount of <root>/sub that is down) was taken for absent and <root>/sub/users.json was deleted; it is now placed as a recorded path is, and refused

With nothing recorded, users-file-place.mjs returned "absent, nothing to
keep" as soon as statSync of <base>/users.json found nothing. A dangling
link is found as nothing by statSync too. With the link leading through a
bind mount of a directory under the root, and that mount down, the purge
went on and deleted the users file behind the empty mount point, as develop
did. Reproduced at 90e0fc5 with a real bind mount, in a private mount
namespace: exit 0, and <root>/sub/users.json was missing.

Now the default counts as absent only when lstatSync finds nothing at its
path (ENOENT or ENOTDIR). A link that exists goes through lead(), as a
recorded path does. It passes when it leads to a directory under the root.
Otherwise it is refused: "the users file <base>/users.json (the one a
pre-store build reads: …) does not exist, and nothing on this host tells
where its path leads (to /mnt/b/users.json) …". Both ways out were followed
literally against the real mount:
- mount it: the lies-under refusal; move the file out; purged, with the
  moved file kept;
- delete the root by hand: purged, and the org's records dropped.
A dangling default link into the root, and no <base>/users.json at all,
still pass.

Test: the mount-down case in server/test-store-ops.mjs adds the default
link. It is refused while the mount is down, refused as under the root once
mounted, and passes once the file is moved out. It fails without the fix.
Total stays 1440. README "Purge A Removed Org" and the CHANGELOG say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ot itself, that /proc/self/mountinfo lists — a bind mount of a directory from outside the root on its own filesystem is a directory to lstatSync, so the walk entered it, rmSync deleted that directory's files outside the root (Node 23 and later) before failing with EBUSY, and when it held the users file, moving it out as the "lies under" refusal said led to the same

Reproduced at ad93aea with real bind mounts, in a private mount namespace,
through tools/cli.mjs:
- <X>/srv7 (same device as the root) bind-mounted at <root>/m7, the users
  file outside: Node 24.19 deleted <X>/srv7/other-data and threw EBUSY;
  Node 22.22 threw a raw EBUSY at rmdir <root>/m7.
- <X>/srv6 holding the recorded users file, bind-mounted at <root>/m6:
  refused "Move <root>/m6/users.json out of <root>, then run purge-org
  again". Followed literally (the mv crosses mounts), the next purge on
  Node 24 deleted <X>/srv6/other-data and threw EBUSY; on Node 22 it threw
  a raw EBUSY. The way out did not work.

users-file-place.mjs now reads /proc/self/mountinfo (off Windows; no such
file, as off Linux or with /proc not mounted, leaves the walk alone) and
compares the root by its real path, as the kernel lists mount points, with
its octal escapes undone. An entry listed there is a mount point, after the
device check: "<root>/m6 (the same filesystem as <root>, listed in
/proc/self/mountinfo)" with the existing "Unmount what is mounted there"
refusal, and the root itself with "… is a mount point … Unmount it". A
/proc/self/mountinfo that exists but cannot be read is refused as an
unreadable directory is.

After, on Node 22.22 and 24.19, each refused deleting nothing, and its way
out followed literally (umount, purge again) passed with the outside files
kept: a directory bind with a space in its name, one holding the users file,
the root itself bound from outside, a file bound at <root>/u.json, and a bind
under a workspace reached through a symlink. A plain purge still passes.

Test: the mount-point test in server/test-store-ops.mjs. The fixture
(server/fixtures/mount-fs.mjs) gains realpathSync and, with { proc: true },
/proc/self/mountinfo generated from its mounts. Without mountinfo the walk
still enters a same-filesystem bind (the declared limit). With it, binds at
"<root>/m 6" (holding the users file) and <root>/m7 (other files only) are
refused, and so is <root>/m7 under a root reached through a link, and the
root itself. Each passes once unmounted. An unreadable mountinfo is refused.
With users-file-place.mjs at ad93aea the test fails ("holding the users
file"). Each of these mutations fails it too: no realpath, no unescape, a
decimal unescape, the root not listed, an unreadable file taken for absent.
The test count is unchanged (1440).

Docs: the module header and the CHANGELOG no longer say /proc/self/mountinfo
is not read. The "Known, not fixed" item now applies only where there is no
such file, and the mount-point, Docs and Tests bullets were updated. The
journey section and README "Purge A Removed Org" now say the same: on Linux
such a bind is refused, and elsewhere it is unmounted by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…e it goes on, and refuses one that changed while it read the root — a directory renamed, while the walk ran, from one it had not read yet into one it had read already was in neither listing, so the users file in it (/mnt/b/users.json, /mnt/b a bind mount of <root>/z/sub, <root>/z/sub moved into <root>/a) was deleted

The walk read each directory once, at its own moment, and the module
declared "a rename by another process between the two is not seen".
Reproduced at 941c92c:
- injected fs (the verifier's probe): a hard link of the users file at
  <root>/z/u.json moved into <root>/a, read already, when the walk reaches
  <root>/packs: the refusal was null; the same tree with no rename is
  refused.
- real ext4, a real bind mount, in a private mount namespace: the users
  file recorded as <X>/mnt/b/users.json, <X>/mnt/b a bind mount of
  <root>/z/sub, and a real renameSync of <root>/z/sub into <root>/a made at
  that step of the walk: the refusal was null, and the rmSync purgeOrg runs
  next deleted the users file (gone through the mount and from the disk).

users-file-place.mjs now keeps, for each directory the walk lists, its
device, inode and change time (from the lstat before the listing) and its
names. When the users file exists and no entry is it, each of those
directories is read again before the purge goes on, after the users file
was placed. A directory that is not the same directory with the same change
time and the same names is refused: "<dir> under <root> changed while
purge-org read <root> (an entry added, removed or renamed in it, or a mount
over it), so purge-org cannot tell what deleting <root> takes — nothing was
deleted. Stop what changes files under <root>, then run purge-org again".
After, on the same real bind mount, the rename is refused, deleting
nothing, and run again with nothing changing it, the walk finds the users
file at <root>/a/sub/users.json and refuses it as lying under the root.
The verifier's probe is refused too.

Test: one more in server/test-store-ops.mjs, over server/fixtures/mount-fs.mjs,
whose inodes now carry a change time that moves with every entry added,
removed or renamed in a directory, and whose { coarse: true } keeps every
change time the same. With users-file-place.mjs at 941c92c it fails. Each
part of the second reading is pinned:
- no second reading: the rename into <root>/a fails;
- no change time: a rename on into the root before <root>/a is read again
  (its names as the walk found them) fails;
- no names: the rename on a coarse clock fails;
- no inode: on a coarse clock, a directory from outside mounted over <root>/a
  once it is read, holding the same names, fails.
The rename into the root itself is refused naming the root.

Docs: the module header, a CHANGELOG bullet (Suite total 1440 → 1441) and
the "Known, not fixed" bullet, which now says what is not seen: what is
moved, linked or mounted under the root after the check, a directory changed
and changed back within one tick of a coarse filesystem clock, and a
same-filesystem bind mount made under the root after /proc/self/mountinfo
was read, before the walk reaches it. README "Purge A Removed Org" lists
the refusal with its way out (the purge-org docs test pins it against the
module's text), and the journey section closes with Tests: 1440 → 1441.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ystem names the second step its way out may take — moved out as it said, /ovl/users.json (/ovl an overlay mount with lowerdir=<root>/sub and an upper directory, whose merged directory shows the upper's inode and modification time) was refused again as a users file that does not exist, while README and CHANGELOG said the purge then goes on

Reproduced at 4e5e31f with a real overlay, in a private mount namespace,
through tools/cli.mjs: the users file recorded as <X>/ovl/users.json, <X>/ovl
an overlay with lowerdir=<root>/sub and an upperdir outside the root, xino
on or off. The first run refused it as lying under the root: "Move
<root>/sub/users.json out of <root>, then run purge-org again". Followed
(moved out, the overlay mounted again), the second run refused again: "the
recorded users file <X>/ovl/users.json does not exist, and nothing on this
host tells where its path leads: the nearest part of it that exists,
<X>/ovl, is no directory under <root> ...". The merged directory shows the
upper directory's inode and modification time, not <root>/sub's, so nothing
ties the path back to the root. That refusal's own way out (delete <root>
by hand, then run purge-org again) works, and a read-only overlay (lower
layers only) shows <root>/sub's inode and time and purges; but README said
"After the first refusal's move, with any mount on its path up, that path
leads under orgs/<id>/ ... so the purge goes on", and CHANGELOG "The way out
holds followed literally".

The rule for a users file that does not exist still fails closed: the
merged directory is no directory the walk read, and nothing ties it to
<root>/sub short of parsing each kind of mount's options or source (an
overlay's layers, an sshfs or SMB server's path), which this check does
not. So a refusal naming an entry by its size and modification time on
another filesystem now names the second step: "Move <entry> out of <root>,
then run purge-org again; if it then refuses because <path> does not exist
(a mount whose directories show another inode or modification time than
theirs under <root>, as an overlay mount with an upper directory does,
cannot be traced back there), delete <root> by hand, then run purge-org
again: it drops org <id>'s records. If <path> then no longer leads ...". A
refusal naming entries on the users file's own filesystem is unchanged.
After, on the same real overlay, xino on and off: the first refusal names
the second step; followed literally (moved out, run, refused as above,
<root> deleted by hand, run) the purge drops the records, exit 0, and the
moved users file stays. With the overlay left mounted through the move, and
with a read-only overlay, the second run purges.

Test: server/test-store-ops.mjs's view test (extended, no new test; the
total stays 1441) adds an overlay at /mnt/merged over <root>/sub with an
upper directory outside the root, its merged directory showing the upper
directory's inode and time; pins each view's refusal with the second step;
and follows the way out: through that overlay, the xino overlay and the
sshfs-like view, the moved-out path is refused as a users file that does
not exist (the real-filesystem test of a recorded users file that no longer
exists deletes the root by hand and passes). With users-file-place.mjs at
4e5e31f it fails, and so does the purge-org docs test, which now pins the
second step in README and in the module. unknownPlace moves up beside
underRoot.

Docs: README "Purge A Removed Org" names the second step, and says the
purge goes on after the move when purge-org can trace the path back (a bind
mount, a link, or a mount that shows that directory's own inode and
modification time) and is refused otherwise; the CHANGELOG bullets quote
the way out with its second step and say when it holds; the module header
and the journey section say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ectory for a mount point only when /proc/self/mountinfo lists it — a workspace in an overlay mount whose layers lie on different filesystems (xino=off) shows a lower layer's files on that layer's device, so it was refused as holding a mount point at <root>/packs/a1.pack.yaml, where nothing is mounted and unmounting, its way out, could not be followed

Reproduced at 6b15327 with a real overlay, in a private mount namespace,
through tools/cli.mjs: the deployment made on a tmpfs lower layer, the
upper and work directories on ext4, the overlay mounted with xino=off and
OBSERVOGRAM_WORKSPACE set to the workspace in its merged directory. The
directories report the overlay's device (48), the lower layer's files
another (50), as overlayfs reports a non-directory when its layers are not
on one filesystem and xino is off. purge-org refused: "<root> holds a mount
point, <root>/packs/a1.pack.yaml (another filesystem than <root>/packs) …
Unmount what is mounted there, then run purge-org again", exit 1. Nothing
is mounted there, /proc/self/mountinfo lists nothing under the root, and
running it again gave the same refusal. develop purges this workspace.

users-file-place.mjs now takes a directory on another device than the
directory that holds it for a mount point, as before, and any other entry
on another device only when /proc/self/mountinfo lists it: a non-directory
is a mount point only as a file mounted on a file, which Linux alone makes,
and lists. A listed entry on another device keeps the "another filesystem
than <dir>" text; one on the same device keeps "the same filesystem as
<dir>, listed in /proc/self/mountinfo". Where there is no
/proc/self/mountinfo (off Linux, /proc not mounted) nothing changes: such
an entry is refused as before. After, on the same real overlay, the purge
deletes <root> and rewrites the marker, exit 0. Also real, in private
namespaces: a tmpfs file bind-mounted on a file under the root is still
refused ("another filesystem than <root>/packs"), and purges once
unmounted with the tmpfs file kept; an ext4 file bind-mounted on a file
under the root is refused as listed in /proc/self/mountinfo; and the
overlay with /proc unmounted is refused as before, and purges once proc is
mounted at /proc again — the residual, declared below. Everything made
was unmounted and removed; findmnt lists nothing of it.

Test: server/test-store-ops.mjs's mount-point test (extended, no new
test; the total stays 1441) adds, where /proc/self/mountinfo is read, two
files under the root on another device than their directories with no
mount there (an overlay's lower layer), and the purge passes; and a file
from another filesystem mounted on a file under the root, which
/proc/self/mountinfo lists, refused as another filesystem, passing once
unmounted. server/fixtures/mount-fs.mjs's file() takes a dev for that.
With users-file-place.mjs at 6b15327 the test fails ("an overlay's
lower-layer files: no mount point"); with the new condition's listed
check dropped the file mount fails, and with its no-mountinfo check
dropped the existing no-mountinfo file mount fails.

Docs: the module header says what a mount point is now, and that without
/proc/self/mountinfo any entry on another device than its directory is
refused, a lower layer's file included — on Linux with /proc not mounted a
false refusal, whose way out is to mount proc at /proc (mount -t proc proc
/proc), then run purge-org again. The CHANGELOG mount-point bullet, its
"Known, not fixed" and Tests bullets, and README "Purge A Removed Org" say
the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…s file outside the root that no longer exists as the host resolves its path — it spelled the path's nearest part as created and named no place, so with the temporary directory reached through a link (macOS: /var → /private/var) it failed, though the refusal named truly where the path leads; the case now runs through a directory link on every host too

Reproduced at f5bc546 on Linux, TMPDIR set to <X>/var/tmp with <X>/var a
link to private/var, as macOS reaches /var/folders through /var →
/private/var: node --test server/test-store-ops.mjs fails test 77 ("a
recorded users file outside the root that no longer exists ..."). The
refusal read "the recorded users file <X>/var/tmp/…/users.json does not
exist, and nothing on this host tells where its path leads (to
<X>/private/var/tmp/…/users.json): the nearest part of it that exists,
<X>/private/var/tmp/…, is no directory under <root> …", and the test
expected the nearest part as spelled at its creation and no "(to …)".
users-file-place.mjs follows every link on the path (lead()), as the
kernel resolves it, and the refusal names where it leads when that is not
where it is spelled: true on every host, so the expectation was wrong, not
the refusal. Tests 12 and 27 (the export ones) fail under that TMPDIR at
develop 4123bc7 too; they are not this branch's and are left as they are.
Linux and Windows CI were not affected: their temporary directory is its
own real path.

Test: that test (no new test; the total stays 1441) now expects the place
and the nearest part as this host resolves them (realpathSync of the
users file's directory), naming the place when it differs from the
spelling, and runs the refusal twice: the users file named as created,
and through a directory link to its directory (a junction on Windows, as
the directory-link test above makes one), which differs on every host.
With the expectation spelled as before, the linked case fails on a plain
Linux temporary directory ("(to /tmp/observogram-ops-users-…/users.json)"
not expected); now the test passes there and under the linked TMPDIR,
where test 77 failed. The product is unchanged.

Docs: the CHANGELOG Tests bullet says the case runs named directly or
through a directory link, the refusal naming where its path leads as the
host resolves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…TF-8 as the bytes the filesystem holds — listed as a string it came back with U+FFFD in place of each such byte, a name no lookup finds, so the walk took it for one gone while it ran and read nothing under it, and the users file there (<root>/c\xffd/users.json, recorded as a link to it) was deleted

Reproduced at 84c12b5 and at develop 4123bc7, through purgeOrg on ext4: org
acme removed, <root>/c\xffd/users.json holding the users file, the recorded
users file <X>/cfg/users.json a symlink to it. Both purged it (deleted=true,
the bytes gone). The same layout named cxd is refused at 84c12b5 ("it is
<root>/cxd/users.json there"). The walk called readdirSync(at) with the
default utf8 encoding: 'c\xffd' came back as 'c�d', lstatSync of it
failed with ENOENT, and the walk's "gone while the walk ran" skip dropped
the directory and everything under it, while rmSync (Node 22's rimraf lists
Buffer names) deleted it. The same skip hid a mount point below such a name:
in a private mount namespace, a bind mount of a same-filesystem directory
and a tmpfs, each at <root>/c\xffd/inner/m, both gave a raw "EBUSY: resource
busy or locked, rmdir '<root>/c�d/inner/m'" at 84c12b5.

users-file-place.mjs now lists each directory with { encoding: 'buffer' }.
A name that is valid UTF-8 joins its directory's path as a string, as
before. Any other name, and every path under it, is joined as bytes with
path.sep; the mount point text names the directory the walk came from
rather than path.dirname of a Buffer, the /proc/self/mountinfo key of such
a path is its bytes after the root read as UTF-8 (as the file itself is
read), and the second reading compares names by their bytes. A refusal
names such a path as a POSIX shell's $'…' spells it, each byte that is not
part of a UTF-8 character as \xNN, a backslash or a quote escaped, so it can
be typed. The paths of names that are valid UTF-8, and every refusal about
them, are unchanged.

After, same scripts and namespaces (under /tmp/claude-0/fu/wt-y-pu-r21,
removed; findmnt lists nothing of it):
- c\xffd, and run\xe9/inner/users.json: refused, "it is
  $'<root>/run\xe9/inner/users.json' there — nothing was deleted. Move
  $'<root>/run\xe9/inner/users.json' out of <root>, then run purge-org
  again …", nothing deleted. Followed literally in bash (mv
  $'<root>/run\xe9/inner/users.json' <X>/keep/): purged, the moved file
  kept. cxd is refused as before.
- the users file outside, other files under run\xe9/inner: purged.
- the bind mount below c\xffd: "$'<root>/c\xffd/inner/m' (the same
  filesystem as $'<root>/c\xffd/inner', listed in /proc/self/mountinfo)";
  the tmpfs: "(another filesystem than $'<root>/c\xffd/inner')". After
  umount, each purged with the mounted directory's files kept.

Test: one more in server/test-store-ops.mjs (1441 → 1442), over
server/fixtures/mount-fs.mjs. The fixture now spells a byte that is not part
of a UTF-8 character as U+DCNN, takes a path as a Buffer, and answers as
node:fs does: readdirSync lists strings with U+FFFD for each such byte, or
bytes with { encoding: 'buffer' }; readlinkSync, realpathSync and
/proc/self/mountinfo likewise. The test covers the users file in
<root>/c\xffd reached through a link, refused naming it as $'…', with its
way out followed; with the users file outside, a tree of such names,
purged; and below such a name a mount from another filesystem, a
same-filesystem bind mount that /proc/self/mountinfo lists, and a directory
it cannot read, each refused. With users-file-place.mjs at 84c12b5 it fails
(refusal null). So does each of these mutations: a refusal naming the path
with U+FFFD, the mountinfo key taken from the whole path, a name joined as
its UTF-8 decoding, and a byte spelled as U+FFFD in $'…'. The test needs
no root, no mount and no such name on disk: it runs over the fixture on POSIX
paths on every platform.

Docs: the module header says the walk lists names as bytes and how a
refusal names such a path. A CHANGELOG bullet (Suite total 1441 → 1442)
and a journey paragraph (Tests: 1441 → 1442) describe the change. README
"Purge A Removed Org" says it reads every name as the bytes on disk, and how
it names such a path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…file's name — a mount that is down keeps what was written in its mount point meanwhile, so the recorded path (/mnt/b/users.json, /mnt/b a bind mount of <root>/sub that is down, a users.json written there) led to that file outside the root, and <root>/sub/users.json, the users file the path leads to once the mount is back, was deleted

Reproduced at b656e5a and at develop 4123bc7, through purgeOrg on ext4: org
beta removed, the users file at <root>/sub/users.json, users_file recorded
as <X>/mnt/b/users.json, a users.json of its own written in <X>/mnt/b. With
<X>/mnt/b a bind mount of <root>/sub (in a private mount namespace) it is
refused at b656e5a ("lies under <root>: it is <root>/sub/users.json
there"); with the mount down it was purged (deleted=true) and
<root>/sub/users.json was gone — mounting it again failed, its source gone.
develop purged it alike. Over server/fixtures/mount-fs.mjs,
file('/ws/orgs/beta/sub/users.json', { size: 300n }) and
file('/mnt/b/users.json', { size: 40n }) passed (exists=true, refusal
null). Failing closed through a mount that is down (b83d0ff) held only
while the users file did not exist: statSync found the file written in the
mount point, and no entry under the root was it.

users-file-place.mjs: when the users file exists, no entry is it and no
directory changed, an entry under the root whose name is one the users
file's path ends in — as written, and at each link it leads through to the
file — compared ignoring case and Unicode normalization (NFC, lower case;
as a case-insensitive filesystem may look a name up), is refused, on every
platform, recorded or <base>/users.json alike. One that is a link names
what it leads to under the root too (statSync of it, matched as entries
are), which rmSync deletes once the link is moved out. The refusal: "<who>
is no file under <root>, but <entry> under it has its name[, and <x> is
what a link of that name leads to]: a mount that is down keeps what was
written in its mount point meanwhile, and mounted again it may lead the
path there — nothing was deleted. Move <entries> out of <root>, then run
purge-org again; if it then refuses because <path> does not exist (the
users file seen there through a mount that cannot be traced back), delete
<root> by hand, then run purge-org again: it drops org <id>'s records. If
the users file is among them, make <path> lead to it again before a
pre-store build or the in-place `packc store export` uses it". A declared
false refusal: an entry of that name that is not the users file; moving it
out is the way out. A view of a directory under the root that reports
neither the inode nor the size and modification time (some FUSE
filesystems), which passed, shows the directory's names and is refused by
the name; moved out, its path leads where nothing traces it back, the
second step the refusal names. Not seen (declared): a file mounted on a
file of another name whose mount is down, its mount point a file of its
own, and what a link of that name under the root leads to back through the
mount that is down.

server/test-store-ops.mjs, one more test, over mount-fs.mjs: through a
mount that is down whose mount point holds a file of its name, refused —
named directly, through a file link, through a link of another name, in
another case or Unicode normalization, and, none recorded, <base>/users.json
a link there — a link of that name naming what it leads to; mounted again,
refused as lying under the root; moved out, passing with the mount up or
down. It fails at b656e5a (refusal null), and with each part of the rule
taken out in turn (the case fold, NFC, the link chain, the link's target).
The place suite's users file outside the root shared its name with
betaVolume's <root>/sub/users.json: it now has a name no entry under the
root has (/srv/accounts.json, /srv5, /srv6, /priv, /other and C:\out
likewise), the UTF-8 case takes <root>/sub/users.json out of its volume,
the coarse-clock mount over <root>/a (which must hold /srv5's names)
expects the name refusal with nothing mounted, and the FUSE view's case
expects the name refusal.

After, same script and private namespaces (under
/tmp/claude-0/fu/wt-y-pu-r24a and -r24b, removed; findmnt lists nothing of
it): the mount down, refused ("is no file under <root>, but
<root>/sub/users.json under it has its name …"); <root>/sub/users.json
moved out as it says, then purged, and the moved file (alice, bob, carol)
and the file in the mount point stay. The mount down, refused; mounted
again, refused as lying under the root; moved out with the mount up,
purged, the moved file kept.

Docs: README "Purge A Removed Org" names the refusal and its way out;
CHANGELOG bullet (Tests 1442 → 1443), its Known-not-fixed (the FUSE view,
what the name does not tell), Docs and Tests text; UPDATE_JOURNEY
paragraph, Tests: 1442 → 1443; the module's and the fixture's headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ose path is looked up only in directories the root lies in, with no entry under the root of its name — moved out of acme's root as its refusal said and acme purged, the record named a path in a directory that no longer exists, and every later purge of another org was refused ("the nearest part of it that exists, <base>/orgs, is no directory under <base>/orgs/beta") until the path led to the moved file again, which meant re-creating directories in the purged root

Reproduced at 13d77ca through purgeOrg (the new test) and through `packc
store purge-org` on ext4 (a store set up through bootStore, under
/tmp/claude-0/fu/wt-y-pu-r25-*, removed): users_file recorded as
<ws>/orgs/acme/sub/users.json; purge acme refused ("lies under
<ws>/orgs/acme: it is <ws>/orgs/acme/sub/users.json there …"); the file
moved to <X>/keepA; purge acme purged; beta removed; purge beta refused,
exit 1: "the recorded users file <ws>/orgs/acme/sub/users.json does not
exist, and nothing on this host tells where its path leads: the nearest
part of it that exists, <ws>/orgs, is no directory under <ws>/orgs/beta
…" — and so for every org purged afterwards. After, the same script:
"purged org beta: deleted <ws>/orgs/beta", exit 0, keepA intact.

users-file-place.mjs: lead() records each directory it looks a name up in
('..' is not counted: from a mount it leads to its mount point's
directory, as from the directory there now). A users file that does not
exist, every such directory one the root lies in — its parent and theirs
on the root's real path, each by its path (case folded on Windows) and its
device and inode — and whose name (as written or at each link it leads
through, folded as 13d77ca folds it) no entry under the root has, is
nothing to keep: a filesystem mounted again on one of those directories
covers the root too, so through it the path leads under the root only by
a directory under the root mounted above it, which shows the users file
by its name. The name check 13d77ca applies to a users file that exists
is factored out and shared. Any other path is refused as before, with the
same refusal and ways out. Declared not seen: a filesystem down now,
mounted on a directory the root lies in, whose link leads into the root
another way (through a second mount of the root's filesystem).

Real mounts, inside `unshare -m --propagation private` (under
/tmp/claude-0/fu/wt-y-pu-r25atk-*, removed; findmnt lists nothing of
it), at 13d77ca and after: <ws>/orgs a bind mount of <root>/x that is
down, the users file written through it at <root>/x/gamma/sub/users.json
and recorded as <ws>/orgs/gamma/sub/users.json — refused at both, the file
kept (with the name check taken out, purged and the file deleted); an
empty mount point outside the root, and a link in <X>/cfg into a
sibling's directory that does not exist — refused at both; recorded
directly in a sibling's directory that does not exist — refused at
13d77ca, purged after.

server/test-store-ops.mjs, one more test, on the real filesystem through
purgeOrg (no root, no mount): the users file recorded under acme's root
refused, moved out, acme purged, beta removed and purged, the moved file
kept — it fails at 13d77ca. The place suite's path in a sibling org's
directory that does not exist now expects the purge to go on (deeper in
it, a file deleted in /ws, a link in /ws, the root named through a link),
and it adds refusals: through a link in a directory the root does not lie
in, to a file or to a directory, and with an entry of its name under the
root (passing once that is moved out). The Windows-paths case adds a
sibling's directory by any spelling, passing, and a directory that differs
from one the root lies in by case alone, on a case-sensitive directory,
refused. Each part taken out in turn fails a test: the name check, every
directory (only the nearest checked), the real path (the root as
spelled), the device and inode, the case fold on Windows.

Docs: README "Purge A Removed Org" says when a users file that does not
exist lets the purge go on through directories the root lies in;
CHANGELOG bullet (Suite total 1443 → 1444), the earlier bullet's declared
false refusal and "any other is refused" narrowed to it, Known-not-fixed,
Docs and Tests text; UPDATE_JOURNEY paragraph, Tests: 1443 → 1444; the
module's header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…f a directory's entries only the listings of the directories it is in — it kept every entry's stats until it ended, so with 100 directories of 1,000 empty files under the root it died out of memory in a 40 MB heap, nothing deleted, where rmSync of the same tree completed

Reproduced at 4439b0e on ext4 (under /tmp/claude-0/fu/wt-y-pu-mem,
removed): <ws>/orgs/beta/runs/r0..r99 with 1,000 empty files each,
<ws>/users.json outside; usersFilePlace(<ws>/users.json, <ws>/orgs/beta)
under node --max-old-space-size=40 ended "FATAL ERROR: Reached heap limit
Allocation failed - JavaScript heap out of memory", while
fs.rmSync(<ws>/orgs/beta, { recursive: true }) of a copy under the same
flag printed "rmSync done". Unlimited, the walk took 785 ms and the heap
held 90 MB at the end. entries[] kept { path, BigIntStats } for every entry
(eighteen bigints, some 770 bytes) because the users file was stat'ed only
after the walk.

users-file-place.mjs: the users file is stat'ed before the walk, and each
entry is compared with it — and with the names its path ends in (computed
before the walk now, as namesakesOf computed them after it) — as the walk
reaches it; only matching entries are kept (found, namesakes). It is
stat'ed again after the walk, with every refusal in the order it had: when
it changed meanwhile (another device, inode, size or modification time —
created, replaced, written), each entry is read again in the walk's order
and compared with it, as entries were. The targets of a namesake link are
found by the same reading. Of a directory the walk keeps its device and
inode (one joined string, the walked key), its change time, a sha256 digest
of its names and its modification time — what the second reading and the
place of a users file that does not exist compare — about 340 bytes a
directory, half of what template-literal ropes took. The walk now takes a
directory's entries from its listing as it goes (a frame per directory it
is in, its names kept as strings unless not valid UTF-8, in the order it
had) instead of pushing every child ahead of it, so it holds only the
listings of the directories it is in, as Node 22's rmSync does. No refusal,
text or default changes; the place suite passes unchanged.

After, same trees and flag: usersFilePlace passes in 16 MB (heap 10 MB at
the end, 761 ms). Measured under --max-old-space-size against rmSync: one
directory of 100,000 files passes in 32 MB (rmSync 24 MB; before: not in
96 MB), 100,000 empty directories in 40 MB (rmSync 16 MB; before: not in
96 MB). The memory grows with the directories under the root and the
longest listing, not with the files.

server/test-store-ops.mjs, one more test: over an injected filesystem that
makes up a root of 250,000 files — 150 directories of 1,000 and one of
100,000, stats with node's eighteen bigints — as each is asked for, the
check in a child with --max-old-space-size=32 passes and reads every
entry. With users-file-place.mjs at 4439b0e the child dies (SIGABRT, "heap
out of memory"); so it does with this commit's per-entry comparison but
each directory's children pushed ahead of the walk. The test runs as any
user on every platform (POSIX paths over the injected filesystem). It also
pins the second stat, over server/fixtures/mount-fs.mjs, the change made
once the walk has read <root>/packs: the users file replaced by a hard
link to a file under the root that kept its size and time (another inode
alone; its two names there named in the walk's order, as with nothing
changing it), replaced by a link to a file of another filesystem with the
same inode number, size and time while a copy under the root kept them
(another device alone), created as a hard link, and written in place
through an NFS view (size alone, time alone) — each refused as lying
under the root, as at 4439b0e. Taking out the second reading, or any one of
device, inode, size and modification time from the comparison of the two
stats, or reading entries again directory by directory instead of in the
walk's order, fails it.

Docs: the module's header; CHANGELOG bullet (Suite total 1444 → 1445);
UPDATE_JOURNEY paragraph, Tests: 1444 → 1445.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…directory on another filesystem with the inode number of one under the root for a view of it whatever its modification time, drops the rest of a missing users file's path after an absolute link, or follows fewer than 40 links — each of these changes to server/store/users-file-place.mjs stayed green

Reproduced at 0f25ead, each change applied alone to users-file-place.mjs
(scratch worktree under /tmp/claude-0/fu/wt-y-pu-mut, removed), with
`node --test-name-pattern=purge-org server/test-store-ops.mjs` and
`node server/test-store-paths.mjs` at 0 failures for: a directory view
without the inode check (M7), without the time check (M8), an error finding
where a missing users file's path leads passed (M19), the rest of the path
dropped after an absolute link (M25), and a link-hop limit of 1 in that
lookup (M26). At cc5c468, M7, M19 and M25 fail already (the overlay and
sshfs views' second step, the part of the path that stops answering, the
refusal's "(to …)"); M8 and M26 stayed green, and M25 failed only by the
place a refusal names, never by the pass it gives: <cfg>/d a link to
<root>/sub, <root>/sub/l a link to a mount point that is down, the record
<cfg>/d/l/users.json missing — refused, under M25 passed.

Three cases, in the test of a users file that does not exist, so the total
stays 1445:
- a mount point that is down on another filesystem than the root's, its
  directory with the inode number of <root>/sub (each filesystem numbers
  its own inodes) and its own modification time: refused as a users file
  that does not exist. Without the time check it passed, the mount-down
  hole again. This needs dir(p, { ino }) in server/fixtures/mount-fs.mjs.
- /cfg/ds/l/users.json, /cfg/ds a link to <root>/sub and <root>/sub/l a
  link to /mnt/b, a mount point that is down: refused naming /mnt/b and
  "(to /mnt/b/users.json)". Without the rest of the path it passed.
- a chain of 40 links (as many as Linux follows in a path: 40 resolve, 41
  fail with ELOOP, checked here) to the users file's place under the root,
  moved out: the purge passes. With a limit of 1, or 39, it is refused as
  a link loop.

Each case fails alone under its change and passes as the module is. The
code is unchanged. The CHANGELOG Tests bullet names the three cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…that a later mount covers — with a tmpfs mounted on the workspace over a bind mount at <root>/m, <root>/m was a plain directory of the tmpfs, yet purge-org refused it as a mount point, and unmounting, its way out, failed with "not mounted"

Reproduced at 4da1239 with real mounts in a private mount namespace,
through tools/cli.mjs: D/outside bind-mounted on D/ws/orgs/acme/m, a tmpfs
mounted on D/ws, the removed-org deployment made on the tmpfs and
D/ws/orgs/acme/m created there (the same device as its parent).
/proc/self/mountinfo still lists D/ws/orgs/acme/m (mount 186, parent 98)
beside D/ws (187, parent 98). purge-org refused: "<root> holds a mount
point, <root>/m (the same filesystem as <root>, listed in
/proc/self/mountinfo): … Unmount what is mounted there, then run purge-org
again", exit 1; `umount D/ws/orgs/acme/m` said "not mounted" (exit 32), and
the next purge refused word for word. 941c92c took every path mountinfo
lists under the root's real path for a mount point there, and the kernel
lists a mount that a later one covers too.

users-file-place.mjs now reads each mountinfo line's mount ID and parent
mount ID beside its mount point, and passes over a covered mount: one with
a mount in the same parent mount on a directory above it, or whose parent
is covered so. Through that parent the path walk crosses into the mount
above before it reaches the covered mount point, so nothing is mounted at
its path. A mount stacked on another at the same path has that mount as
its parent and stays listed, as does every mount the walk can reach.
After, real, in private namespaces (everything made unmounted and
removed; findmnt lists none of it): the case above purges, exit 0, with
D/outside/f kept; so do a bind mount inside the covered one
(D/ws/orgs/acme/m/n, parent 186) and the root itself covered (a bind on
D/ws/orgs/acme, then the tmpfs on D/ws); a same-filesystem bind mount made
on the tmpfs at D/ws/orgs/acme/m (parent 187, over the covered one) is
refused as before, and followed literally — umount D/ws/orgs/acme/m — the
purge passes with the bind's source kept; the root likewise ("… is a mount
point … Unmount it"; umount; purged).

Test: server/test-store-ops.mjs's mount-point test (extended, no new test;
the total stays 1445), where /proc/self/mountinfo is read: two mounts
covered by a filesystem mounted on /ws — one in the other — listed still,
their paths directories of the filesystem above, unmounting them EINVAL;
the purge passes; a same-filesystem mount made there on the filesystem
above refused, and once unmounted as its refusal says, passing; the root
covered passing, the root mounted on the filesystem above refused and
passing once unmounted. server/fixtures/mount-fs.mjs now places each mount
in the mount its target was in (its parent), so a mount on a path mounted
already stacks on it and one over a directory above covers it;
/proc/self/mountinfo lists each mount's ID and its parent's, a covered one
too; unmount fails with EINVAL where nothing is mounted (umount(8): not
mounted) and EBUSY where a mount lies in it. With users-file-place.mjs at
4da1239 the test fails ("covered mounts: none is a mount point, purged",
the refusal above); with the parent-covered check dropped it fails on the
mount in the covered one (<root>/m/n refused); with the covering mount
taken from any parent, not the same one, it fails at once ("holding the
users file": the bind at <root>/m 6 passed over).

Docs: the module header says a covered mount is no mount point, and adds
to what is not seen a same-filesystem bind mount uncovered between the
mountinfo read and the walk (a bind mount from the same filesystem over a
directory above the root, unmounted). The CHANGELOG mount-point bullet, its
"Known, not fixed" and Tests bullets, UPDATE_JOURNEY's mount paragraph and
README "Purge A Removed Org" say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…path leads into a directory under the root while an entry under the root has its name — with <root>/other bind-mounted on <root>/sub/m and the users file recorded through it, the mount was refused as a mount point, and unmounted as that refusal said, the purge deleted <root>/other/users.json

Reproduced at c86f545 with a real bind mount in a private mount namespace
(unshare -m --propagation private, under /tmp/claude-0/fu/wt-y-pu-r32-*,
removed; findmnt lists none of it), through tools/cli.mjs on a removed-org
deployment: <root>/sub/m and <root>/other made, the users file copied to
<root>/other/users.json, `mount --bind <root>/other <root>/sub/m`.
(a) Recorded as D/cfg/users.json, a link to <root>/sub/m/users.json:
purge-org refused "<root> holds a mount point, <root>/sub/m (the same
filesystem as <root>/sub, listed in /proc/self/mountinfo) … Unmount what is
mounted there, then run purge-org again"; after `umount <root>/sub/m` it
printed "purged org acme: deleted <root>", exit 0, and
<root>/other/users.json was gone. (b) Recorded as <root>/sub/m/users.json:
the same. At develop 4123bc7, (a) was refused mounted ("lies under <root> —
move it out first") and purged once unmounted, the file deleted; (b) was
refused mounted, unmounted and after the move alike.

A users file that does not exist whose path leads into a directory the
walk read — here the empty mount point <root>/sub/m — passed at once: the
name check 13d77ca made because a mount that is down tells nothing held
only for a users file that exists and for a path through directories the
root lies in.

users-file-place.mjs: that pass now also needs no entry under the root
with the name the path ends in (as written or at each link it leads
through, folded as before). With one it is refused: "the recorded users
file <path> does not exist, and its path leads into <dir>, under <root>,
but <entry> under it has its name: a directory on its path may be the
mount point of a mount that is down, and mounted again it may lead the path
there — nothing was deleted. Move <entry> out of <root>, then run purge-org
again. If the users file is among them, make <path> lead to it again before
a pre-store build or the in-place `packc store export` uses it", naming
what a link of that name leads to under the root too. The refusal of a
users file that exists with an entry of its name is built by the same
function now and reads byte for byte as before. After, the same script:
both cases refused after the umount, exit 1, the file kept; followed
literally — <root>/other/users.json moved to D/keep — "purged org acme:
deleted <root>", exit 0, the moved file intact. The mount-point refusal is
unchanged. Problem (2)'s way out (the users file recorded under the root,
moved out) passes as before while no other entry under the root has its
name; one that has it is refused alike (a declared false refusal), and
moving it out too is its way out.

Test: server/test-store-ops.mjs, one more (1445 → 1446), with no root and
no mount (a junction for the directory link, so Windows runs it too): over
server/fixtures/mount-fs.mjs with /proc/self/mountinfo, that bind mount up
refused as a mount point and, unmounted, refused by the name — recorded
through a file link, directly and through a directory link — a link of
that name naming what it leads to, and moved out passing; on the real
filesystem through purgeOrg, what that mount leaves once it is down
(<root>/sub/m empty, the users file at <root>/other/users.json) refused,
recorded directly and through a junction, nothing deleted, then moved out
and purged with the moved file kept. With users-file-place.mjs at c86f545
it fails at "/cfg/users.json, unmounted" (refusal null), and with the
injected part taken out, at the real-filesystem part ("Missing expected
rejection"). Run as uid 65534 (setpriv) it passes. The place suite's view
case, whose fixture holds <root>/up/users.json besides the users file, now
expects the NFS and SMB views' way out refused by that name, passing once
it is moved out too.

Docs: the module header; README "Purge A Removed Org" (the name bullet
covers a users file that does not exist whose path leads into a directory
under the root, and the path after the first refusal's move passes while
no entry has the users file's name); CHANGELOG bullet (Suite total 1445 →
1446), the earlier "the purge goes on" narrowed; UPDATE_JOURNEY paragraph,
Tests: 1445 → 1446, the earlier paragraph narrowed alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ose path is looked up only in directories the root's own path is looked up in too — with the workspace reached through a link in a directory the root does not lie in (/home/u/ws → /data/ws), or its orgs/ such a link, the users file moved out of acme's root as its refusal said and acme purged, every later purge of another org was still refused ("the nearest part of it that exists, /data/ws/orgs, is no directory under /home/u/ws/orgs/beta")

Reproduced at 0f53b12 on the real filesystem through purgeOrg (node
22.22), no mount involved: D/home/u and D/data/ws made, D/home/u/ws a link
to D/data/ws, a removed-org deployment at D/home/u/ws, the users file
written at <ws>/orgs/acme/sub/users.json and recorded there. purge acme
refused ("lies under"); moved out as it said, acme purged; beta removed,
purge beta refused: "the recorded users file D/home/u/ws/orgs/acme/sub/
users.json does not exist, and nothing on this host tells where its path
leads (to D/data/ws/orgs/acme/sub/users.json): the nearest part of it that
exists, D/data/ws/orgs, is no directory under D/home/u/ws/orgs/beta …". The
same with the workspace at D/data/ws and its orgs/ a link to D/data/orgs;
with the workspace named by its real path, beta purged.

4439b0e passed such a path only when every directory it is looked up in
is one the root lies in, by the root's real path: the recorded path is
looked up in D/home/u (the link ws) and D/home, which the root's real path
does not pass.

users-file-place.mjs: the directories that pass are now also those a name
is looked up in on the root's own path as purge-org names it
(<base>/orgs/<id>, every link on it followed, as lead() follows the users
file's), each by its path and its device and inode as before. A
filesystem mounted again on one of them covers the root, or hides the link
the root's own path is reached through. Declared in the header's "Not
seen", CHANGELOG's "Known, not fixed": a filesystem down now, mounted on a
directory only the root's path as named is looked up in, whose link leads
the path to the root's real path. The name check stays: with an entry of
the users file's name under beta, purge beta is refused as before;
followed literally — beta deleted by hand — the purge passes and drops its
records. A path through a directory neither the root nor its path lies in
(/cfg, or D/home/u/ws with the root named by its real path) is refused as
before. After, the same script: purge beta passes in all three layouts,
the moved users file kept. No refusal text changes.

Test: no new test (the total stays 1446). server/test-store-ops.mjs's
real-filesystem later-purge test now runs with the workspace named
directly, through a directory link in a directory the roots do not lie in,
and with its orgs/ such a link (junctions, which need no privilege on
Windows); the place suite's sibling-directory case names the root
through /home/u/ws → /ws and /w2/orgs → /ws/orgs, passing paths through
/home/u/ws (deeper, a file deleted in /ws and in /home/u) and /w2/orgs, and
refusing a path through /home/u/ws with the root named by its real path
and one through /w2/orgs with the root named through /home/u/ws. With
users-file-place.mjs at 0f53b12 both fail (the place suite at
"/home/u/ws/orgs/beta2/users.json, the root named /home/u/ws/orgs/beta",
the real-filesystem one with the refusal above).

Docs: the module header; README "Purge A Removed Org"; CHANGELOG's
later-purge bullet (and its test narrative), "Known, not fixed", the Docs
bullet and the forward references in the "move it out first" bullet;
UPDATE_JOURNEY's later-purge paragraph (Tests: 1443 → 1444 unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… file of the root's own filesystem mounted on a file, names a path below a name that is not valid UTF-8 with a quote or a backslash unescaped, misses an entry of the users file's name below such a name, or misses a mount below one under a root whose path is not ASCII — each of these changes to server/store/users-file-place.mjs stayed green

Reproduced at b76ff7e, each change applied alone to users-file-place.mjs
(scratch worktree under /tmp/claude-0/fu/wt-y-pu-mut, removed), with
`node --test --test-name-pattern=purge-org server/test-store-ops.mjs` (26
tests) and `node --test server/test-store-paths.mjs` at 0 failures:
(1) `if (mountedHere && st.isDirectory())`, which drops 941c92c's "or a
file mounted on a file" for one of the same filesystem; (2) spelled()
without escaping `\` and `'`; (3) nameOf() taking a path held as bytes
whole; (4) relative() slicing such a path by the root's characters, not
its bytes.

On the real filesystem through purgeOrg (node 22.22, in a private mount
namespace, under /tmp/claude-0/fu/wt-y-pu-real-*, removed), at b76ff7e
and with each change:
- (1) `mount --bind <out>/src.json <root>/m.json`, one filesystem: refused
  ("<root>/m.json (the same filesystem as <root>, listed in
  /proc/self/mountinfo)"); unmounted as it says, purged, src.json kept.
  Changed, rmSync threw "EBUSY: … unlink '<root>/m.json'"; with src.json
  the recorded users file it was refused as "lies under … Move
  <root>/m.json out", and mv failed: Device or resource busy.
- (2) the users file at <root>/it's\c\xffd/users.json, recorded as a link
  to it: the refusal names $'<root>/it\'s\\c\xffd/users.json'; that mv,
  evaluated by bash as typed, moved it, and the purge passed. Changed, it
  named $'<root>/it's\c\xffd/users.json', which bash rejects ("unexpected
  EOF while looking for matching `''"), and the purge stayed refused.
- (3) the users file recorded as <T>/mnt/b/users.json (a mount point that
  is down, a users.json written there), <root>/c\xffd/users.json under the
  root: refused by its name, named as $'…'; moved out as typed, purged.
  Changed, the purge deleted <root>/c\xffd/users.json.
- (4) a workspace at <T>/données/ws, <T>/outside6 bind-mounted at
  <root>/c\xffd/m6: refused as listed in /proc/self/mountinfo; `umount
  $'<root>/c\xffd/m6'` as typed, purged, outside6/data.json kept. Changed,
  the walk entered it and rmSync threw "EBUSY: … rmdir '<root>/c�d/m6'".

Four cases, each in the existing test it belongs to, so the total stays
1446:
- the mount-point test: a file of the root's own filesystem bind-mounted
  on a file under the root, the users file elsewhere and the users file
  itself mounted there: refused as a mount point /proc/self/mountinfo
  lists; unmounted, the purge passes.
- the non-UTF-8 test: <root>/it's\c\xffd/users.json through a link,
  refused naming $'<root>/it\'s\\c\xffd/users.json'; moved out, it passes.
  server/fixtures/mount-fs.mjs split a POSIX path at a backslash too; it
  now keeps a backslash in a name on POSIX paths (Windows paths as before).
- the non-UTF-8 test: the users file /mnt/b/users.json outside the root and
  <root>/c\xffd/users.json under it: refused by that name; moved out, it
  passes.
- the non-UTF-8 test: the root /srv/données/orgs/beta, /srv6 bind-mounted
  at <root>/c\xffd/m6 and listed: refused; unmounted, it passes.

Each case fails alone under its change ("a file of the same filesystem
mounted on a file", "a quote and a backslash escaped", "an entry of its
name below such a name", "under a root whose path is not ASCII") and
passes as the module is. The code is unchanged. CHANGELOG: the Tests
bullet names the file mount; the non-UTF-8 bullet's test narrative the
escaping and the root that is not ASCII; the name bullet's the entry
below such a name. README, UPDATE_JOURNEY and the module header describe
no case changed here; no Tests pair moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…hape and the root's path length — one directory of 100,000 empty directories needs 60 MB with a 40-character root path (rmSync 24 MB), not the 40 MB (rmSync 16 MB) it gave for any 100,000 empty directories; the users-file place suite reads such a root in a 64 MB heap

Reproduced at 93382ab (node 22.22 and 22.16) on real trees — tmpfs in a
private mount namespace under /tmp/claude-0/fu/wt-y-pu-mem, and the
files on disk under /tmp/claude-0/fu/wt-y-pu-ab (both removed) —
usersFilePlace(<ws>/users.json, <ws>/orgs/beta) under
--max-old-space-size, the users file outside the root, the root's path
40 characters long; the smallest heap in 4 MB steps it passed in, the
same on both nodes, three runs each at the directories' edges:
- 100 directories of 1,000 empty files: 16 MB (12 too; rmSync 8 MB)
- one directory of 100,000 empty files: 28 MB (rmSync 24 MB, out of
  memory in 16)
- 100 directories of 1,000 empty directories: 40 MB, not 36 (rmSync 8)
- one directory of 100,000 empty directories: 60 MB, out of memory in
  40, 48 and 56 (rmSync: out of memory in 16, done in 24)
With a 91-character root path the last two need 44 and 64 MB. The walk
keeps of each directory its path, device and inode, times and a digest
of its names: measured with gc() over the injected filesystem, some 280
bytes and its path a directory, so 100,000 directories take about 1 MB
more for each 10 characters of the root's path; one directory of 100,000
adds its listing, held while the walk reads each. The CHANGELOG gave
"100,000 empty directories in 40 MB (rmSync: 16 MB)" with neither the
shape nor the path length.

CHANGELOG: of a directory the walk keeps its path too; its memory grows
by a few hundred bytes and the path for each directory under the root,
with 100,000 about 1 MB for each 10 characters of the root's path; with
the root's path 40 characters long (Node 22.16 and 22.22) the 100 x
1,000 files root passes in 16 MB, one directory of 100,000 files in 32
MB (rmSync 24), 100 directories of 1,000 empty directories in 40 MB
(rmSync 8) and one directory of 100,000 empty directories in 64 MB
(rmSync 24). The module header, the walk's comment and UPDATE_JOURNEY
name the path among what the walk keeps of a directory; nothing else
there states a figure that changes.

Test: the memory test's child takes a shape — 'files', the 250,000-file
root as before (identical paths, inode numbers and 250,460 lstatSync
calls; it needs 20 MB, run in 32), or 'directories', one directory of
100,000 empty directories under /var/lib/observogram/workspace/orgs/beta
(40 characters) — and the test runs both, the second in a 64 MB heap. In
the same test, so the total stays 1446 and no Tests pair moves. Over the
injected filesystem it needs 52 MB (out of memory in 48) on both nodes;
run in 40 MB, the figure the CHANGELOG gave, or in 48, the test fails:
"directories: the child ended SIGABRT: FATAL ERROR: ... JavaScript heap
out of memory" (checked in a scratch worktree, removed). The module's
code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… stacked on / — a lookup never crosses a mount on the process's root, yet one listed at / was taken for covering every mount in its parent mount, so with a tmpfs mounted on / in a chroot, or `mount --bind / /`, the bind mount at <root>/m was passed over and rmSync failed there with EBUSY; holding the users file, the "lies under" refusal named <root>/m/users.json, in the bind's source, to move out

Reproduced at 2edb379 with real mounts in a private mount namespace,
through tools/cli.mjs on Node 22.22: a chroot (into a plain directory, and
into one bound on itself), a removed-org deployment at /work/ws in it, and
/work/outside (the same filesystem; files f and g) bind-mounted on
/work/ws/orgs/acme/m. purge-org refused <root>/m as a mount point. Then
`mount --bind / /` (mountinfo: "193 98 /work/ws/orgs/acme/m", "194 98 /"),
or `mount -t tmpfs tmpfs /` over the self-bound root ("194 186 …/m",
"195 186 /"): `ls /work/ws/orgs/acme/m` still showed f and g, yet purge-org
passed the check and rmSync threw "EBUSY: resource busy or locked, rmdir
'/work/ws/orgs/acme/m'" (exit 1; on Node 22 nothing was deleted). With the
users file recorded as /work/outside/users.json it refused "… lies under
<root>: it is <root>/m/users.json there … Move <root>/m/users.json out",
and followed, that moves the users file out of its bind source into the
same rmSync.

Cause: c86f545's covered() takes a mount in the same parent mount on a
directory above another for covering it, and above('/', x) held for every
x. A path is looked up from the process's root, and a lookup never crosses
a mount stacked on it (`umount /` takes the topmost; the kernel lists such
a mount at / all the same), so a mount on / covers nothing. above() now
never holds for /.

After, real, the same namespaces (every mount under
/tmp/claude-0/fu/wt-y-pu-r4-*, all unmounted and removed): the four cases
(bind or tmpfs on /, users file recorded outside or not) refuse "<root>
holds a mount point, <root>/m (the same filesystem as <root>, listed in
/proc/self/mountinfo) … Unmount what is mounted there, then run purge-org
again", and followed literally — `umount /work/ws/orgs/acme/m` — the purge
passes, exit 0, /work/outside's f, g and users.json kept. c86f545's cases
are unchanged: a tmpfs on the workspace over a bind mount at <root>/m, a
mount in that one, and the root itself covered purge; a mount made there on
the tmpfs, from another filesystem or the same one, and the root likewise,
are refused and pass once unmounted.

Test: server/test-store-ops.mjs's mount-point test (extended, no new test;
the total stays 1446): with a tmpfs-like filesystem mounted on /, and with
/ bound on itself — each listed at / in the root's mount — a bind mount at
<root>/m, holding other files or the users file, is reached at its path,
refused as a mount point, and once unmounted as its refusal says, passes.
server/fixtures/mount-fs.mjs modelled a mount on / as crossed; on POSIX
paths a lookup now starts at / and never crosses a mount stacked on it,
while a mount and an unmount there take the topmost (a Windows root, a
drive or a share, is still the volume mounted there). With
users-file-place.mjs at 2edb379 the test fails at once ("a tmpfs mounted on
/: /srv7 mounted there": { exists: true, refusal: null }); the other three
cases fail too (passed over, or the "lies under" refusal).

Docs: the module header, CHANGELOG's mount-point and Tests bullets,
UPDATE_JOURNEY's mount paragraph and README "Purge A Removed Org" say a
mount on / covers nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… the users file under the root as well, to move out with the unmounting — with /src bind-mounted on <root>/sub/m, /src/users.json a link to <root>/x/data.json (the users file's bytes) and the users file recorded as <root>/sub/m/users.json, unmounted as that refusal said, the path led into the empty mount point, where no entry under the root has the users file's name, and the purge deleted <root>/x/data.json

Reproduced at dacac21 with a real bind mount in a private mount namespace
(every path under /tmp/claude-0/fu/wt-y-pu-r4-*, unmounted and removed),
through purgeOrg on Node 22.22: D/src, outside the root, bind-mounted on
<root>/sub/m; D/src/users.json a link to <root>/x/data.json, which holds
the users file's bytes; the users file recorded as <root>/sub/m/users.json,
or as D/cfg/users.json, a link to it. Mounted, the record leads to the
users file, and purge-org refused "<root> holds a mount point,
<root>/sub/m (the same filesystem as <root>/sub, listed in
/proc/self/mountinfo) … Unmount what is mounted there, then run purge-org
again". After `umount <root>/sub/m`, as it says: "PURGED deleted=true",
<root>/x/data.json gone. develop (4123bc7) refused it while mounted;
unmounted it refused the direct record by its path as written (the
refusal whose way out failed) and deleted data.json recorded through the
link.

Cause: the mount-point refusal returned before the users file was placed,
and its way out, unmounting, takes away the path to the users file: the
record then leads into <root>/sub/m, an empty directory the walk read,
and no entry under the root has the name the path ends in (data.json is
the bytes, the link of the users file's name lies in D/src), so the purge
went on.

Fix (server/store/users-file-place.mjs): when the walk finds mount points,
the users file is placed as for its own refusal (entriesOf: the entries
the walk found, or, if it changed meanwhile, each entry read again — none
at a mount point, which the read-again pass now passes over as the walk
does). With none, the refusal reads as before. With some, it names them:
"…, and a directory mounted from outside <root> may hold the users file;
and the recorded users file <path> lies under <root>: it is
<root>/x/data.json there — nothing was deleted. Move <root>/x/data.json
out of <root> and unmount what is mounted there, then run purge-org again
(unmounted, its path may no longer lead there, and nothing would keep the
purge from deleting it). If <path> then no longer leads to the users
file, make it lead there again before …" (a view named as the refusal of
a users file under the root names one, with its second step; liesUnder
now builds both). exists is true then.

After, real, the same namespace: both records refused as above; followed
literally — `mv <root>/x/data.json D/keep/; umount <root>/sub/m` — the
purge passes, D/keep/data.json intact. 0f53b12's shape (<root>/other
bound on <root>/sub/m, the users file at <root>/other/[deep/]users.json or
u.json, recorded through a file link or directly) is refused naming
<root>/other/… alike, and followed, passes with the moved file kept.

Test: server/test-store-ops.mjs's test of 0f53b12's shape (extended, no
new test; the total stays 1446), over server/fixtures/mount-fs.mjs: that
mount up, refused naming <root>/other/users.json, and followed — moved
out and unmounted — passing; /src mounted there from outside, its
users.json a link to <root>/x/data.json, recorded directly and through a
file link, refused naming data.json, and followed, passing with the
moved file kept; unmounted before purge-org saw it up, passing (declared).
The docs test pins the new way out in README and in the module. With
users-file-place.mjs at dacac21 the test fails at once (0f53b12's shape,
mounted: the plain mount refusal, exists null), and with that assertion
left out, at /src's ("mounted from outside").

Docs: the module header, README "Purge A Removed Org" (the mount-point
bullet; the namesake bullet no longer says the mount-point refusal says
only to unmount), CHANGELOG (a bullet, the mount-point bullet's pointer,
and Known, not fixed) and UPDATE_JOURNEY say the refusal names the users
file, and declare a mount that was down already when purge-org first ran,
whose directory from outside holds such a link, as not seen; where they
tell 0f53b12's shape, unmounted as the mount-point refusal "said", in the
past.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…th leads through a mount of its own filesystem, naming what to unmount first — with /srv/x bind-mounted on <root>/m and `mount --bind <base> <base>` over it (or `mount --rbind`, its copy at <root>/m unmounted as the refusal said), the path showed the very directory the mount sits on, yet purge-org passed it over as covered and rmSync deleted part of the root, then failed with EBUSY

Reproduced at a5ea1d8 with real mounts in private mount namespaces,
through tools/cli.mjs on Node 22.22: a removed-org deployment at D/ws,
D/outside (the same filesystem; file f) bind-mounted on
D/ws/orgs/acme/m, and <root>/a.txt, <root>/zz.txt added.
(a) `mount --bind D/ws D/ws`: mountinfo lists "186 98 254:0 …/acme/m"
and "187 98 254:0 …/ws"; purge-org failed "EBUSY: resource busy or
locked, rmdir '<root>/m'" (exit 1) with a.txt deleted, zz.txt, packs/
and m left and the org's records kept. (b) `mount --rbind D/ws D/ws`:
the copy at <root>/m (188, parent 187) refused as a mount point;
followed literally (`umount <root>/m`, rc 0) the next run failed the
same way. (c) A bind of <root> on itself, then the self-bind of D/ws:
"EBUSY … rmdir '<root>'". D/outside/f was kept each time.

Cause: c86f545's covered() passes over every mount that a later one in
the same parent mount covers, reasoning that its path then shows a
directory of the filesystem mounted above. When the path leads through
a mount of the covered mount's own filesystem it may show the very
dentry the covered mount sits on, and the kernel refuses rmdir and
unlink of a dentry that is a mount point anywhere in the namespace.

users-file-place.mjs now reads each mountinfo line's device (major:minor)
too, and finds the mount a lookup of the covered mount's path ends in:
the deepest uncovered mount on it or on a directory above it, the
topmost of a stack (on /, the lowest, the process's root, as dacac21
has it). Of another device than the covered mount's parent mount, the
covered mount is passed over as before; of the same, it is refused:
"<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo,
covered by a later mount through which its path may show what it is
mounted on: first unmount <base>) … Unmount what is mounted there, then
run purge-org again" — the mounts to unmount first, in order: what
covers its parent mount, each covering mount in that parent (outermost
first), and the mounts in the covered one, each after the mounts in it,
which keep it busy. A visible mount at the same path is refused as
before, so (b) first names the copy. mountinfo's devices are compared
with each other, not with lstat's, so it holds where stat reports
another device than mountinfo lists (each btrfs subvolume has its own);
a mount of the same filesystem that shows another directory there is a
declared false refusal.

After, real, the same namespaces on Node 22.22 and 22.16 (every mount
under /tmp/claude-0/fu/wt-y-pu-r4*, unmounted with the namespace and
removed; /proc/self/mountinfo lists none of it): (a) refused "first
unmount D/ws", and followed literally — `umount D/ws`, `umount
<root>/m` — purged, exit 0, D/outside/f kept; (b) the copy refused, then
"first unmount D/ws", followed likewise and purged; (c) "<root> (…:
first unmount D/ws) is a mount point …", `umount D/ws`, `umount <root>`,
purged; a bind in the covered mount (<root>/m/n): "first unmount D/ws,
then <root>/m/n", followed, purged; a tmpfs on D/ws holding a bind of
the original D/ws/orgs at D/ws/orgs (a5ea1d8: EBUSY likewise): "first
unmount D/ws/orgs, then D/ws", followed, purged. c86f545's case (a tmpfs
on D/ws over the bind at <root>/m) still purges, and dacac21's
(`mount --bind / /`) is still refused and passes once unmounted.

Test: server/test-store-ops.mjs's mount-point test (extended, no new
test; the total stays 1446): /ws bound on itself, a recursive bind's
copy at <root>/m unmounted, and a filesystem mounted on /ws holding a
bind of the original /ws/orgs — <root>/m showing the directory it is
mounted on, unmounting it there EINVAL, /ws busy (EBUSY) while the copy
is mounted — refused naming what to unmount first, and passing once
unmounted in that order; the root bound on itself under /ws bound on
itself likewise. The README way-out check reads the new way out in both
README and the module. server/fixtures/mount-fs.mjs lists each mount's
filesystem device in /proc/self/mountinfo (it listed the mount ID
there). With users-file-place.mjs at a5ea1d8 the test fails at once
("/ws bound on itself": { exists: true, refusal: null }); each of the
four cases purges there.

Docs: the module header, README "Purge A Removed Org", CHANGELOG's
mount-point and Tests bullets and UPDATE_JOURNEY's mount paragraph say
a covered mount whose path leads through a mount of its own filesystem
is refused, naming what to unmount first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…mount on /w for one on a directory above /ws, or follows a covered mount's parents one level up only — each of these changes to server/store/users-file-place.mjs stayed green, and on real mounts the first passed a bind mount under the root over (rmSync failed there with EBUSY) and the second refused a mount point where nothing is mounted

Reproduced at 1b7a9d5, each change applied alone to users-file-place.mjs
(scratch worktrees /tmp/claude-0/fu/wt-y-pu-m1 and -m2, removed), with
`node --test --test-name-pattern=purge-org server/test-store-ops.mjs` (26
tests) and `node --test server/test-store-paths.mjs` at 0 failures:
(1) above() without the path separator, `b.startsWith(a)`; (2) covered()
following the parent chain one level only, the parent's own siblings
asked in place of covered(parent).

On the real filesystem through purgeOrg (Node 22.22, in private mount
namespaces under /tmp/claude-0/fu/wt-y-pu-r44*, every mount gone with its
namespace, removed), at 1b7a9d5 and with each change:
- (1) The reported setup — D/outside bind-mounted on D/ws/orgs/acme/m, a
  tmpfs on D/w — is refused as a mount point either way since 1b7a9d5;
  changed, the refusal adds a false first step ("covered by a later mount
  through which its path may show what it is mounted on: first unmount
  D/w"). With the workspace on a tmpfs mounted on D/ws (or its orgs/ on
  one), a tmpfs on D/w, and D/ws/src, on the workspace's tmpfs,
  bind-mounted on <root>/m: 1b7a9d5 refuses <root>/m ("the same filesystem
  as <root>, listed in /proc/self/mountinfo"), and followed literally —
  `umount <root>/m` — purges with D/ws/src's file kept. Changed, D/w is
  taken for a directory above D/ws, the tmpfs and the bind in it for
  covered, and their path leads through / (another device than the
  tmpfs): passed over, rmSync threw "EBUSY: resource busy or locked, rmdir
  '<root>/m'".
- (2) D/srv6, D/srv7 and D/srv8 bind-mounted on <root>/m, <root>/m/n and
  <root>/m/n/o in turn, then a tmpfs on D/ws, the deployment made on it
  and <root>/m/n/o created there: 1b7a9d5 purges, D/srv8's file kept.
  Changed, it refuses "<root> holds a mount point, <root>/m/n/o (the same
  filesystem as <root>/m/n, listed in /proc/self/mountinfo) …", and
  `umount <root>/m/n/o` says "not mounted" — c86f545's dead end. The
  reported shape (a bind on D/ws/orgs, one on <root>/sub in it, one on
  <root>/sub/m in that, then the tmpfs on D/ws) reads the same: 1b7a9d5
  purges; changed, <root>/sub/m is refused and umount says not mounted.

Test: server/test-store-ops.mjs's mount-point test (extended, no new test;
the total stays 1446), over server/fixtures/mount-fs.mjs: the covered
chain goes a level deeper (<root>/m/n/o, in <root>/m/n, in <root>/m:
listed still, a directory of the filesystem above, unmounting it EINVAL;
purged), and with the workspace on a filesystem mounted on /ws and
another mounted on /w, a bind mount at <root>/m of a directory of the
workspace's filesystem is reached at its path, refused, and passes once
unmounted as its refusal says. With change (1) the test fails at
"/w beside /ws" ({ exists: true, refusal: null }); with change (2) at
"covered mounts: none is a mount point, purged" (<root>/m/n/o refused).

Docs: CHANGELOG's Tests bullet names both cases. Nothing purge-org does
changes, so README, the module header and UPDATE_JOURNEY stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… chroot into a directory that is no mount point — there /proc/self/mountinfo lists neither / nor the mount the root lies in, and a covered mount whose parent it does not list was refused as one whose path may show what it is mounted on, so a tmpfs on the workspace over a bind mount at <root>/m, which purges outside a chroot, was refused with "first unmount <base>", and followed literally that unmounted the filesystem holding the whole deployment

Reproduced at 6a9fcf3 with real mounts in a private mount namespace,
through tools/cli.mjs on Node 22.22, in a chroot into a plain directory
(no mount point): a removed-org deployment at /work/ws, /work/outside
bind-mounted on /work/ws/orgs/acme/m, then a tmpfs mounted on /work/ws
and the workspace made on it (c86f545's case). mountinfo listed
"193 98 254:0 /work/ws/orgs/acme/m" and "194 98 0:48 /work/ws" and no
line with ID 98; <root>/m was a plain tmpfs directory (dev 48) and
`umount <root>/m` said "not mounted". purge-org refused "<root>/m (the
same filesystem as <root>, listed in /proc/self/mountinfo, covered by a
later mount through which its path may show what it is mounted on:
first unmount /work/ws)" (exit 1). Followed literally, `umount /work/ws`
(rc 0) unmounted the tmpfs: /work/ws then showed only the underlying
"orgs". At 2edb379 the same run purged; so did 6a9fcf3 with the
chroot's root bind-mounted on itself (the parent listed, as 186).

Cause: 1b7a9d5's shows() read a covered mount whose parent mount is
not listed as showing what it is mounted on (`parent === undefined`),
and only a code comment said so. The kernel lists a mount only when its
mount point is reachable from the process's root, so in a chroot into a
directory that is no mount point the mount the root lies in is listed
nowhere while every mount in it names its ID as their parent, and every
covered mount in it was refused, its way out naming the covering mount
— the workspace's own filesystem.

users-file-place.mjs: a covered mount whose parent is not listed takes
that parent for the mount the process's root lies in (the only mount a
listed mount's unlisted parent can be), whose filesystem / shows, since
a lookup of / never crosses a mount stacked on it (dacac21), and
compares it with the device of the directory at the covered mount's path
now — the filesystem the lookup ends in. Another device: the covered
mount is passed over, as outside a chroot. The same (`mount --bind
/work/ws /work/ws` over it, in the chroot): refused naming /work/ws
first, as before; followed literally — `umount /work/ws`, then
`umount <root>/m` as the next refusal says — the purge passes and
/work/outside/f stays (reproduced so). A parent that is listed reads as
before; nothing at the covered mount's path reads as showing it.

server/fixtures/mount-fs.mjs: a `chroot` option, under which
/proc/self/mountinfo lists neither / nor the root's mount, the mounts in
it naming its ID (TOP) as their parent, as the kernel lists a chroot
into a plain directory.

server/test-store-ops.mjs (one more): over that fixture, the tmpfs over
a bind mount at <root>/m, and over the root itself, passing (with
users-file-place.mjs at the parent commit the first is refused, naming
/ws first); <root>/m mounted again on the tmpfs refused and passing once
unmounted; /ws bound on itself refused naming it first and, followed,
passing; a tmpfs on / in the chroot covering nothing.

Docs: the module header states the rule; CHANGELOG's purge-org entry
names the parent mount's device, or / in a chroot, and a new entry this
change with its reproduction; UPDATE_JOURNEY likewise. Tests: 1446 →
1447.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… covered mount by its own filesystem in place of its parent mount's or by the lowest mount of a stack on its path, or names what to unmount first without what covers its parent mount, innermost first, after what is above it in that parent, or without the mounts in it — each of these changes to server/store/users-file-place.mjs stayed green, and on real mounts the first two passed a bind mount under the root over (rmSync failed there with EBUSY) and the others named a first step that failed (not mounted; target is busy) or none

Reproduced at 2ff689f, each change applied alone to users-file-place.mjs
(a detached scratch worktree /tmp/claude-0/fu/wt-y-pu-r52, removed), with
`node --test --test-name-pattern=purge-org server/test-store-ops.mjs` (27
tests) and `node --test server/test-store-paths.mjs` at 0 failures:
(1) shows() comparing the mount the path ends in with the covered mount's
own device in place of its parent mount's; (2) endsIn() taking the first
line of a stack, or its lowest mount, for the mount the lookup ends in;
(3) firstOff() without uncover(parent), what covers the covered mount's
parent mount; (4) firstOff() with the coverers in one parent innermost
first; (5) firstOff() with what covers the parent after what is above the
covered mount in that parent; (6) firstOff() without the mounts in the
covered mount. 1b7a9d5's other readings stay caught (a covered mount never
refused, shows() always true or false, endsIn() counting covered mounts,
the device read from the mount ID, the hint dropped, within() listing a
mount before the mounts in it), and 2ff689f's chroot test catches shows()
reading a covered mount with an unlisted parent as not showing it. Two
readings survive with no effect: endsIn() finding no mount (a lookup
always crosses one uncovered mount, / or the outermost coverer, so it
cannot), and nothing at a covered mount's path reading as not showing it
(the walk never reaches such a path).

On the real filesystem through tools/cli.mjs (Node 22.22, in private mount
namespaces under /tmp/claude-0/fu/wt-y-pu-r52-proof-*, every mount gone
with its namespace, removed), D/outside (holding f) bind-mounted on
<root>/m unless said otherwise, <root> holding a.txt and zz.txt, at
2ff689f and with each change:
- (1) A tmpfs on <root>/m, then `mount --bind D/ws D/ws`: 2ff689f refuses
  "<root>/m (… covered by a later mount through which its path may show
  what it is mounted on: first unmount D/ws)"; followed (`umount D/ws`,
  `umount <root>/m`), it purges. Changed: "EBUSY: resource busy or locked,
  rmdir '<root>/m'", a.txt deleted.
- (2) `mount --bind D/ws D/alt`, a tmpfs on D/ws, `mount --bind D/alt
  D/ws`: 2ff689f refuses "first unmount D/ws, then D/ws", and followed
  (both mounts at D/ws, then <root>/m) purges with f kept. Changed, either
  way: EBUSY at <root>/m, a.txt deleted.
- (3) `mount --bind D/ws/orgs D/ws/orgs`, the bind at <root>/m in it, then
  `mount --bind D/ws D/ws`: 2ff689f refuses "first unmount D/ws", and
  followed purges. Changed: no first step, and `umount <root>/m`, the way
  out then, says "not mounted"; the same refusal comes back.
- (4) The bind at <root>/m, then D/ws/orgs and D/ws each bound on itself:
  2ff689f refuses "first unmount D/ws, then D/ws/orgs", and followed
  purges. Changed: "first unmount D/ws/orgs, then D/ws", and `umount
  D/ws/orgs` says "not mounted"; after `umount D/ws` the refusal names
  D/ws/orgs still.
- (5) D/ws/orgs bound on itself, the bind at <root>/m in it, a tmpfs on
  D/ws/orgs (stacked in the self-bind), then `mount --bind D/ws D/ws`:
  2ff689f refuses "first unmount D/ws, then D/ws/orgs", and followed
  (D/ws, the tmpfs at D/ws/orgs, <root>/m) purges with f kept. Changed:
  "first unmount D/ws/orgs, then D/ws", and `umount D/ws/orgs` says "not
  mounted".
- (6) A bind at <root>/m/n in the one at <root>/m, then `mount --bind D/ws
  D/ws`: 2ff689f refuses "first unmount D/ws, then <root>/m/n", and
  followed purges with f and n kept. Changed: "first unmount D/ws", and
  after it `umount <root>/m` says "target is busy".

Test: server/test-store-ops.mjs's mount-point test (extended, no new test;
the total stays 1447), over server/fixtures/mount-fs.mjs: a tmpfs on
<root>/m under /ws bound on itself (listed on its own device) refused
naming /ws first; a stack on /ws (/ws bound on /alt, a tmpfs on /ws, /alt
bound over it) refused naming /ws twice; /ws/orgs bound on itself with the
bind at <root>/m in it, then /ws bound on itself, refused naming /ws; two
coverers in one parent refused naming /ws, then /ws/orgs (unmounting
/ws/orgs first EINVAL, as umount says not mounted); the parent covered and
a tmpfs stacked above in it refused naming /ws, then /ws/orgs; a bind at
<root>/m/n in the covered mount refused naming /ws, then <root>/m/n
(unmounting <root>/m before it EBUSY) — each passing once unmounted in the
order named. With change (1) the test fails at "a tmpfs on <root>/m, /ws
bound on itself" ({ exists: true, refusal: null }), with (2) at "a stack
on /ws, the root's filesystem on top" (the same), with (3) at "in a
covered /ws/orgs" (no first step), with (4) at "two coverers in one
parent" (/ws/orgs, then /ws), with (5) at "the parent covered, and a mount
above in it" (/ws/orgs, then /ws), with (6) at "a mount in the covered
mount" (<root>/m/n left out).

Docs: CHANGELOG's Tests bullet names the cases. Nothing purge-org does
changes, so README, the module header and UPDATE_JOURNEY stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…s to mount the workspace there again once the covered mount is unmounted, where what covers it showed the workspace's directory and not the one it is mounted on — with the deployment bind-mounted on <base> over the directory holding a bind at <root>/m (the declared false refusal), "first unmount <base>" followed literally took the workspace away, and purge-org then said "no database at <base>/observogram.db — nothing to purge"

Reproduced at 1049f5f with real mounts in a private mount namespace,
through tools/cli.mjs on Node 22.22: a removed-org deployment at
D/data/ws holding <root>/m as a plain directory; the original D/ws
holding a bind mount of D/outside at D/ws/orgs/acme/m; then `mount
--bind D/data/ws D/ws` over it (the same filesystem, another
directory). purge-org refused "<root>/m (the same filesystem as <root>,
listed in /proc/self/mountinfo, covered by a later mount through which
its path may show what it is mounted on: first unmount D/ws)" (exit 1).
Followed literally — `umount D/ws` (rc 0), `umount <root>/m` (rc 0),
purge-org again — it failed with "no database at D/ws/observogram.db —
nothing to purge" (exit 1): D/ws showed the original directory again,
and the refusal said nothing of mounting the workspace back. With the
workspace mounted on D/ws again, the purge passed and D/outside/f
stayed.

Cause: the hint 1b7a9d5 added names the mounts to unmount first and
nothing after. A covering mount of the covered mount's filesystem may
show the very directory the covered mount is mounted on (`mount --bind
D/ws D/ws`, where unmounting it leaves the workspace in place) or
another directory of that filesystem (the deployment bind-mounted over
the directory that holds the covered mount), which purge-org cannot
tell apart; in the second case the workspace lives on the covering
mount and goes with it.

users-file-place.mjs: the hint goes on "; if the workspace goes with
it (a mount that showed another directory there than the one it is
mounted on — the workspace's), mount it there again once <root>/m is
unmounted, before you run purge-org again" ("one of them" with several
mounts to unmount first). Followed literally on real mounts: the
deployment over the original directory — `umount D/ws`, `umount
<root>/m`, `mount --bind D/data/ws D/ws`, purge-org — purges with
D/outside/f kept; D/ws bound on itself, and D/ws/orgs then D/ws each
bound on itself ("first unmount D/ws, then D/ws/orgs; if the workspace
goes with one of them …"), unmounted in that order with the workspace
staying, purge without any remount. Nothing purge-org deletes or
passes over changes.

server/test-store-ops.mjs: the mount-point test (extended; the total
stays 1447) models the case over server/fixtures/mount-fs.mjs — the
deployment at /data/ws (observogram.db, <root>/m a plain directory)
bound on /ws over the original directory holding a bind at <root>/m —
and reads the refusal naming /ws first and the workspace to mount
again; unmounting /ws and <root>/m as it says leaves nothing at
/ws/observogram.db, and with /data/ws bound on /ws again the purge
passes, the mounted directory's files kept. Every covered-mount
expectation in that test and the chroot test reads the new wording;
the docs test reads the new way out in README and the module. With
users-file-place.mjs at the parent commit, the mount-point test, the
chroot test and the docs test fail (the refusal ends at "first unmount
/ws").

Docs: README's "Purge A Removed Org", the module header, CHANGELOG's
purge-org entry (with the reproduction) and Tests bullet, and
UPDATE_JOURNEY name the step. No new test, so the Tests chain stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… the mounts in it to unmount first, in order — with /work/outside bind-mounted on <root>/m and /work/inner on <root>/m/n, it named <root>/m alone, since the walk never enters a mount point, and followed literally, `umount <root>/m` said target is busy and every later run came back with the same refusal; a covered mount's refusal named them already

Reproduced at ca89ada with real mounts in a private mount namespace,
through tools/cli.mjs on Node 22.22: a removed-org deployment at D/ws,
D/outside bind-mounted on D/ws/orgs/acme/m and D/inner on
D/ws/orgs/acme/m/n. purge-org refused "<root>/m (the same filesystem as
<root>, listed in /proc/self/mountinfo)" (exit 1); `umount
D/ws/orgs/acme/m` failed with "target is busy", and purge-org, run
again, refused word for word. The way out worked only with `umount
D/ws/orgs/acme/m/n` first, or `umount -R`.

The mounts in a mount point keep it busy, and /proc/self/mountinfo
lists them, so the refusal now names them to unmount first, in order,
each after the mounts in it — firstOff's within() (the order a covered
mount's refusal gives the mounts in it) shared with the uncovered case
as inside(m): "<root>/m (the same filesystem as <root>, listed in
/proc/self/mountinfo, holding mounts, which keep it busy: first unmount
<root>/m/n/o, then <root>/m/n, then <root>/m/p)"; on another
filesystem, "(another filesystem than <root>, holding a mount, which
keeps it busy: first unmount <root>/m/n)". A mount in it that a later
one over a directory above it covers comes after the one that covers
it. With a mount stacked on <root>/m, umount takes the topmost, so the
mounts in that alone are named (the lower mount's are covered by it),
and once it is off, the lower mount's. Followed literally with the real
mounts — a bind at <root>/m holding binds at <root>/m/n, <root>/m/n/o
and <root>/m/p, unmounted in the named order, then <root>/m — the purge
passes (exit 0), and the mounted directories' files stay.

server/test-store-ops.mjs (one more, 1447 → 1448) models the case over
server/fixtures/mount-fs.mjs, whose unmount already fails with EBUSY
while a mount is in the one unmounted: the nested binds refused naming
the three in that order, part-way naming what is left in it, and
passing once unmounted so with the mounted directories' files kept; one
in it covered by a later mount named after what covers it; a tmpfs on
<root>/m holding a bind alike; a mount stacked on <root>/m naming the
mounts in its topmost alone, and once that is off, the lower mount's.
The docs test reads the new way out in README. With
users-file-place.mjs at the parent commit the new test fails, refused
naming <root>/m alone.

Docs: README's "Purge A Removed Org", the module header, a CHANGELOG
entry (with the reproduction) and UPDATE_JOURNEY name the step; the
Tests chain goes on 1447 → 1448.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…root into a directory that is no mount point, compares a covered mount's path through a link there with the filesystem / shows — that change to server/store/users-file-place.mjs stayed green, and on real mounts a link at <root>/m in the tmpfs covering it, to a directory of the root's filesystem, was refused naming the tmpfs holding the whole deployment to unmount first, the very outcome 2ff689f fixed

Reproduced at bf1ad37 with real mounts in a private mount namespace,
through tools/cli.mjs on Node 22.22, in a chroot into a plain directory
(no mount point): /work/outside bind-mounted on /work/ws/orgs/acme/m, a
tmpfs mounted on /work/ws and the workspace made on it, then <root>/m in
the tmpfs replaced by a link to /work/inner, a directory of the root
filesystem holding g. mountinfo listed "194 98 254:0 /work/ws/orgs/acme/m"
and "195 98 0:49 /work/ws" and no line with ID 98. bf1ad37 purged, with
/work/inner/g kept (rmSync deletes the link and never follows it). With
shows() reading dev(stat(m.at)) in place of dev(lstat(m.at)) — the link
followed to its target, on the filesystem / shows — purge-org refused
"<root>/m (the same filesystem as <root>, listed in /proc/self/mountinfo,
covered by a later mount through which its path may show what it is
mounted on: first unmount /work/ws; …)" (exit 1): /work/ws is the tmpfs
holding the database and every org, 2ff689f's outcome again. That change
applied alone to users-file-place.mjs (a detached scratch worktree
/tmp/claude-0/fu/wt-y-pu-r6, removed) left `node --test
--test-name-pattern=purge-org server/test-store-ops.mjs` at 28 pass, 0
fail: 2ff689f's chroot test covers <root>/m in the covering tmpfs as a
directory and as a bind mount, never as a link.

Test: server/test-store-ops.mjs's chroot test (extended, no new test; the
total stays 1448), over server/fixtures/mount-fs.mjs: /srv6 bound on
<root>/m, a tmpfs bound on /ws over it, and <root>/m in the tmpfs a link
to /srv7, a directory of the root's filesystem holding a file — lstatSync
answers a link on the tmpfs's device, statSync /'s device, umount says not
mounted (EINVAL), and the purge passes. With the change the test fails
there, refused naming /ws first. The module header and the comment at
shows() say what the fallback compares: what is at the covered mount's
path as rmSync takes it, a link there a link of the filesystem above and
not what it leads to; the CHANGELOG entry for 2ff689f says the same and
names the case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…e mounts in a mount in a mount point under the root the innermost first — that change to server/store/users-file-place.mjs stayed green, and on real mounts the refusal of <root>/m named a covered mount in <root>/m/n first, `umount` of it said no mount point specified, <root>/m/n and <root>/m stayed busy, and the same refusal came back

Reproduced at eef0747 with real mounts in a private mount namespace,
through tools/cli.mjs on Node 22.22: a removed-org deployment at D/ws,
D/outside bind-mounted on D/ws/orgs/acme/m, D/inner on <root>/m/n,
D/innermost (holding h) on <root>/m/n/x/y, then D/outside2 on <root>/m/n/x
— in <root>/m/n too, covering y — and D/deep on <root>/m/n/x/z, in that.
mountinfo listed 186 at <root>/m, 187 (in 186) at <root>/m/n, 188 and 189
(both in 187) at <root>/m/n/x/y and <root>/m/n/x, 190 (in 189) at
<root>/m/n/x/z. eef0747 refused "<root>/m (…, holding mounts, which keep
it busy: first unmount <root>/m/n/x/z, then <root>/m/n/x, then
<root>/m/n/x/y, then <root>/m/n)", and followed literally — the four,
then <root>/m — every umount returned 0 and the purge passed, h kept.
With within()'s `.sort(outermost)` over the mounts in a mount changed to
`.sort(outermost).reverse()` — the innermost first — it refused "first
unmount <root>/m/n/x/y, then <root>/m/n/x/z, then <root>/m/n/x, then
<root>/m/n": followed literally, `umount <root>/m/n/x/y` said "no mount
point specified" (rc 32; it is covered by <root>/m/n/x), the next two
returned 0, `umount <root>/m/n` and `umount <root>/m` said "target is
busy", and purge-org refused again naming "<root>/m/n/x/y, then
<root>/m/n". That change applied alone to users-file-place.mjs left `node
--test --test-name-pattern=purge-org server/test-store-ops.mjs` at 28
pass, 0 fail (the same change to inside()'s sort, over the mounts in the
mount point itself, fails one): bf1ad37's holding-mounts test orders
siblings in <root>/m alone, and gives each mount in them one mount at
most, so within()'s order of two mounts in a mount in it — one covering
the other — was never exercised.

Test: server/test-store-ops.mjs's holding-mounts test (extended, no new
test; the total stays 1448), over server/fixtures/mount-fs.mjs: /srv6 on
<root>/m, /srv7 on <root>/m/n, /srv8 (holding h) on <root>/m/n/x/y, /srv9
on <root>/m/n/x, which covers it (unmount: EINVAL) and holds /srv10 on
<root>/m/n/x/z; <root>/m/n busy (EBUSY); refused naming "<root>/m/n/x/z,
then <root>/m/n/x, then <root>/m/n/x/y, then <root>/m/n" — part-way, the
first two off, "<root>/m/n/x/y, then <root>/m/n" — and, unmounted so, the
purge passes with /srv8's h kept. With the change the test fails there,
refused naming <root>/m/n/x/y first (the mutation on inside()'s sort and
the one naming a mount before the mounts in it fail it too). The
CHANGELOG entry for bf1ad37 names the case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…org-users-file-identity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…op's 1472 — #157's 1425 → 1438 → 1439, #158's 1439 → … → 1468 and #159's 1468 → 1472 come first, so the follow-up's pairs in its CHANGELOG entry and its journey section read 1472 → 1487 → 1488 → … → 1495, and the total that stays reads 1493 (they read 1425 → 1440 → … → 1448, and 1446); 1495 is the measured npm test total at this head

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…n — refusedOp returned false on a mismatch, so assert.rejects reported only its message (the deployment's path) and the summary a CI log ends with, which prints a failure's error line alone, said nothing of the refusal purge-org gave; now the mismatch is an AssertionError naming the error's code and message. The Windows leg on Node 22.16.0 failed the junction-linked later-purge test at its first refusal with only the path to show for it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
… handle — on Node 22.16.0 (libuv 1.49.2) a root reached through a junction to its workspace's orgs was refused as a mount point

The Windows leg on Node 22.16.0 refused the junction-linked later-purge test's
first purge with '<d>\data\w2\orgs\acme (another filesystem than
<d>\data\w2\orgs) is a mount point', where Node 22.23 (libuv 1.51.0) went on.
users-file-place.mjs compared the root's lstatSync device with its directory's
statSync device, and that directory was the junction. libuv answers a stat by
the path's name (GetFileInformationByName) unless its last name is a reparse
point, which it opens and reads the volume's serial from
(FILE_FS_VOLUME_INFORMATION). libuv 1.49.0 to 1.50.x declared
FILE_STAT_BASIC_INFORMATION with FileId128 and VolumeSerialNumber swapped, so
the stat by name reported the upper half of the file's 128-bit id, 0 on NTFS,
and no 32-bit mask could turn that into the serial.

On Windows a volume is mounted only in a folder, so purge-org now compares
only directories there. It reads each one's volume through a handle
(openSync, fstatSync, closeSync), which reports the serial on every libuv and
follows a junction to the volume it leads to. A file or a link under the root
lies on its directory's volume: on libuv 1.49.0 to 1.50.x a junction under the
root was refused as a mount point too. A volume mounted in a folder under the
root, or on the root, is refused as before ('another volume mounted in it').

The test models both libuv behaviours over the injected Windows host
(mount-fs.mjs: fastDev, plus openSync/fstatSync/closeSync). At the parent
commit it fails on 1.49.2's stat with the Windows leg's refusal. Tests:
1495 -> 1496.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…ers-file-identity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…op's 1476 — #161's 1472 → 1476 comes first, so the follow-up's pairs in its CHANGELOG entry and its journey section read 1476 → 1491 → 1492 → … → 1500, and the total that stays reads 1497 (they read 1472 → 1487 → … → 1496, and 1493); 1500 is the measured npm test total at this head

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…file link into the root once it is moved out — #161's unresolvable-path check runs first and refuses a dangling link, which this follow-up's entry, journey and README said purge-org follows by its target and lets through; README "Purge A Removed Org" names that check and its ways out

In the merged tree purge-org runs #161's check (compared(usersFile, root,
{ deleting: true }) answering null on Windows) before usersFilePlace. With
the users file recorded as C:\cfg\users.json, a file link to
<root>\sub\users.json, and moved out of the root as the refusal says, the
native realpath cannot open the link's target and lstat answers the link,
so #161's check refuses it; a directory link (C:\cfg\d -> <root>\sub) still
resolves and passes. Checked with server/fixtures/ntfs.mjs. Its way out,
deleting the root by hand and purging again, holds: with no root left
purge-org checks no users file. The README list now opens with that
refusal and its two ways out, and the docs test pins the second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
@MoebiusX
MoebiusX marked this pull request as ready for review October 14, 2026 21:05
…identity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
…op's 1495 — #162's 1476 → … → 1495 comes first, so the follow-up's pairs in its CHANGELOG entry and its journey section read 1495 → 1510 → 1511 → … → 1519, and the total that stays reads 1516 (they read 1476 → 1491 → … → 1500, and 1497); 1519 is the measured npm test total at this head

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
MoebiusX added a commit that referenced this pull request Oct 14, 2026
…ugh a handle — on Node 22.16.x (libuv 1.49.2) a path through a share of the workspace compared as outside it

server/store/paths.mjs placed a path reached through a share by a file id
it shares with a directory of the path it is checked against, and judged
one nothing places by its volume, both read with statSync. libuv answers a
stat by the path's name (GetFileInformationByName, Windows 11 24H2 and
Server 2025) unless the path is reached through a share or its last name
is a reparse point, which it opens (FILE_FS_VOLUME_INFORMATION: the
serial). libuv 1.49.0 to 1.50.x declared FILE_STAT_BASIC_INFORMATION with
FileId128 before VolumeSerialNumber (fixed in 1.51.0), so a stat by name
reported the upper half of the 128-bit file id, 0 on NTFS. A local path
and its share then had no file id in common and their volumes never
matched: a directory export to \\myhost\ws\exports\new with the workspace
at C:\ws was written into the workspace.

On Windows a file id and a volume are now read through a handle (openSync,
fstatSync, closeSync), which reports the serial on every libuv, as #163
reads a directory's volume in users-file-place.mjs. Every handle is closed
before the comparison goes on.

server/fixtures/ntfs.mjs models both libuvs (fastDev: 0n for 1.49.2, null
for 1.51.0's 64-bit serial by name; a path through a share or ending in a
link or reparse point is opened) and returns openSync, fstatSync,
closeSync and opened(). Its header no longer says a local stat may report
all 64 bits on every libuv. The new test in server/test-store-paths.mjs
loops both models. At the parent commit it fails on 1.49.2 (the export
through \\myhost\ws was written). Tests: 1495 -> 1496.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MoebiusX
MoebiusX merged commit abd56a7 into develop Oct 15, 2026
7 checks passed
MoebiusX pushed a commit that referenced this pull request Oct 15, 2026
…chain this follow-up's Tests pair from develop's 1519 — the develop merge cd24602 dropped #163's entry (17 lines, now byte-for-byte develop's again, below this entry) and left this entry's pair at 1495 → 1496, which test-doc-test-totals matched against #163's journey pair from 1495; 1519 → 1520 is the measured npm test total

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhPGnqVJ24goSVtbKpdkLJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants