claustrum — deliberate divergences¶
Almost everything claustrum does is byte-identical to the reference daemon. This file is the canonical catalog of the exceptions. The exceptions are the behaviours that knowingly change a frame or an action. This file also gives the rules that gate every one of them.
The catalog table is the fast path. It gives one row per divergence, with its default, how to activate it, and its reopen trigger. The rules come first for a reason: they decide which shape an entry can take. The three shapes are always-on, opt-in, and conditional.
Per-method wire facts (params, result field order, error strings) live in PROTOCOL.md. Driver-claim provenance lives in ARCHITECTURE.md. We condensed the exhaustive per-entry measurement forensics out of the committed docs.
The one hard rule¶
Byte-identical JSON-RPC frames are the product. A divergence needs a reason. Matching does not need a reason. "Off by default" must mean byte-identical, not almost byte-identical. An opt-in divergence that is not active leaves the wire exactly as the reference leaves it.
PROTOCOL.md and the PR that shipped it also document every intentional divergence below. The out-of-scope list at the end gives the inverse: the changes we will never make.
How we decide (the rules)¶
The standard, in priority order:
- Claude Desktop must keep working. Claustrum is a drop-in. If a behaviour is reachable by Desktop, and matching the reference is what keeps Desktop working, we match. How ugly the reference's behaviour is does not matter.
- Match by default. A wart Desktop tolerates is the contract, not a bug. Divergence needs a reason. Matching does not. The burden of proof is always on the divergence.
- A divergence earns ALWAYS-ON only if one of three conditions holds. (a) Two things hold together. The reference's behaviour on that path is not a frame at all, which means an unbounded wait, unbounded memory, or unrecoverable data loss. And no honest caller can observe the difference. (b) The trigger itself is unreachable on an honest path. (c) The trigger is reachable, but both binaries fail the operation and the only delta is diagnostic text.
- Anything else that changes an honest-path frame is OPT-IN, default off, or it does not ship.
Corollaries:
- Keeping a wart does not mean hiding it. We match the wart and we document it, so a user can see the edge before it causes damage.
- "The reference does it too" is a reason to match, never a reason to call it safe. D2 is the standing counter-example: the reference wipes a home directory, and we still refuse to do it.
- Every always-on divergence owes a reopen trigger. The reopen trigger is the observation that makes us take the divergence back out. An always-on divergence with no reopen trigger is a preference, not a decision.
Reading the clauses¶
Clause (a) is an AND. Both halves must hold: the reference's behaviour is not a frame, and no honest caller observes the difference. The second half is where thresholds fail. A bound is a threshold. A threshold cannot separate a hostile input from an input that is only slow or large, so an honest input trips it too. The test is this question:
When the guard fires on an honest input, who pays, and can they decline?
If the honest input is reachable, and the caller cannot turn the guard off, then always-on is not justified. This holds no matter how the reference behaves on the hostile path. This test flipped every timeout and size cap from always-on to opt-in (D3, D5, D10, D12, and the retired D11 and D14). The retired D4 was the non-threshold sibling.
Canonical example: D2 satisfies both halves. The reference's home-wipe is unrecoverable data loss, and no honest caller has a legitimate use for deleting home. A caller can still reach that path by accident, which is exactly what the guard is for.
Clause (b): the trigger is unreachable on an honest path. Five always-on entries use this form (D6, D8, D9, D18, D19). Each entry has its own trigger, and the glosses are not interchangeable. Three of the five are asserted rather than enumerated: nobody ever enumerated Desktop's per-method param set against D9's binding, and D6 and D18 rest on an observed value plus a measured accepted-set. Read those three as unenumerated, not established (rule 2 puts the burden on the divergence). D19 holds for worktrees that either daemon creates, because both creates refuse the junction. A worktree made outside the daemon can still sit behind it.
Canonical example: D6. A -cli-version naming a destructive path outside the
cli-dir is not something any correct client emits.
Clause (c): both binaries fail, and the only delta is diagnostic text.
This clause is deliberately narrow, and we wrote it for D13. D13 did not meet it
when measured, so the clause justifies no entry in this file today. A reader must
not take two things on trust. First, an error.code is not diagnostic text,
because a client branches on it. Second, on-disk state that a caller can
files.stat is not diagnostic text either. D13's honest-path rows differed in
on-disk state when they were measured. The clause keeps its literal wording and
D13 sits unresolved. We did not widen the clause to fit its one
candidate.
Canonical example: none currently qualifies.
The "Desktop owns the argv" premise¶
Every opt-in tag rests on one claim about the driver: Claude Desktop owns the
daemon's argv on both -serve and -install. Therefore an operator cannot reach
a flag-only knob, and the claustrum.conf key (read beside the executable) is the
reachable one. This claim is the premise under D3, D5, D10, D12 and
under the "(opt-in)" tag itself.
So much rests on the claim that it gets one canonical record. Its provenance, its
current evidence and its reopen trigger live in
ARCHITECTURE.md → Driver claims and their provenance.
That section also holds the other two driver claims. Desktop parses cliError,
and Desktop uses the reported libc to choose which CLI build it downloads. If a
way for an operator to influence the daemon's argv is found, the claim reopens. A
Desktop release that adds such a way reopens it too. Such a route makes a
flag-only opt-in sufficient for Desktop-driven hosts. It does not moot the config
key, which serves every other driver.
Conventions for opt-in divergences¶
These conventions hold for every opt-in entry. This section states them once rather than repeating them in each entry:
- A flag and a matching
claustrum.confkey. The config key is the reachable knob (see the argv premise above). Precedence is explicit CLI flag > config > default. claustrum resolves it withflag.Visit. - Default off is the zero value, and disabled bypasses the guard entirely. A
cap set to
0skips itsio.LimitReader. A timeout set to0skipscontext.WithTimeout. For the download,0instead relies onhttp.Client{Timeout: 0}, which is the stdlib's own "no timeout". Never use a huge-but-finite value. Thecap+1/ armed-cancel arithmetic is what defines the boundary. Routing the unlimited case through that arithmetic invents a boundary. - No opt-in bound is a hang detector. Each bound is a threshold, so an honest-but-slow or honest-but-large input trips it too. That is precisely why they are off by default.
- At the shipped defaults, each
-installwall-clock bound in this list is a bound of the reference. The direct--versionrun is stopped at 30 s on a cache hit and at 120 s after an install in the same run. A managed launcher run is stopped at 33 s and at 123 s. The download gives up after 60 s without response headers, and after 60 s without a body byte. Thelddlibc probe has a 5 s bound on ldd itself and a 2 s drain after ldd exits. PROTOCOL.md →-installnames the measurement behind each one. One clock is claustrum's own choice and is not measured: the wait of up to 2 s for the output of a stopped launcher run. The stdlib transport clocks (net.Dialer{Timeout: 30s},TLSHandshakeTimeout: 10s) apply on-cli-urlonly. They are always-on, unnumbered, and unprobed on the reference.
Catalog¶
| ID | What it does | Default | How to activate | Why (rule / clause) | Reopen trigger |
|---|---|---|---|---|---|
| D2 | Refuse a destructive path that is or contains $HOME, on three methods and on -install |
always-on | always-on | rule 3 clause (a). The sweep and prune part: maintainer decision of 2026-10-07. The files.extract_tar wipe through the parent handle and its two tests: maintainer decision of 2026-10-10 |
an honest caller legitimately targeting a path that is/contains home, or a Windows destDir with the \\?\ prefix and a dot or a space after its last name |
| D3 | Cap files.extract_tar output size |
off (0 = unlimited) |
-max-extract-bytes / max-extract-bytes |
rule 4 (who-pays) | operator's cap refuses a legit extraction, or default lets a bomb through |
| D5 | Deadline on every git invocation |
off (0) |
-git-timeout / key |
rule 4 | opt-in kills an honest slow git |
| D6 | -cli-version must be a single path component |
always-on | always-on | rule 3 clause (b) | Desktop passing a multi-component -cli-version |
| D8 | Never follow or write a foreign or symlinked remote-server.log. Both halves hold on Linux and macOS only |
always-on | always-on | rule 3 clause (b): unreachable on the deployed path | a shared socket dir that also needs the log file, or the reference adding the same refuse-to-follow |
| D9 | Namespace-wide params binding (type error in an unread field → -32602) |
always-on | always-on | rule 3 clause (b) | a real client sending a type-mismatched unread namespace field |
| D10 | Cap -install CLI size (decompressed + download body) |
off (0) |
-max-cli-bytes / key |
rule 4 (who-pays) | Desktop ceasing to treat a disk-full message as terminal |
| D12 | Bound on the -install download exchange |
off (0) |
-cli-download-timeout / key |
rule 4 | operator with the bound set reporting an honest slow download failed |
| D13 | Verify checksum before decompressing (-cli-url, and -cli-zst with a checksum) |
always-on | always-on | UNRESOLVED: clause (c) written for it, measured not met | any change to how Desktop classifies cliError |
| D15 | Verify a run-dir lock holder is our serve process before signalling it, in the serve eviction and in -stop (macOS) |
always-on | always-on | rule 3 clause (a) | the reference adding the same macOS check, or a macOS holder legitimately un-inspectable via KERN_PROCARGS2 |
| D16 | git.status of a linked worktree returns the status on Windows, where the reference errors exit status 128 (cause: core.excludesFile=NUL in its status call, when the user has no global excludes file) |
always-on (Windows) | always-on | claustrum-more-correct (D2/D8 pattern). REACHABLE | the reference fixing its Windows git.status, a Git for Windows release that accepts NUL there, or a decision to reproduce its failure for strict 1:1 |
| D17 | An abandoned lsof run reads as busy, not idle (macOS) |
always-on (macOS) | always-on | rule 3 clause (a) | a measurement of the reference with an lsof run that cannot be killed, or an operator reporting a run dir the cleaner will not tidy on such a host |
| D18 | -cli-version must not start with .blob-. The -cli-keep prune does not count or remove a .blob- name, where 89cb6289 counts a planted one (cell C-12, Windows VM) and removes it (cells B1, Linux, macOS and Windows VMs) |
always-on | always-on | rule 3 clause (b). The prune part: maintainer decision of 2026-10-07 | Desktop passing a -cli-version that starts with .blob- |
| D19 | git.worktree_remove refuses a junction at any directory between baseRepo and the leaf. At .claude or .claude\worktrees, f6010b97 answers success and deletes only the branch, and 89cb6289 does the same for a branch that another ref reaches. At a level above .claude, 89cb6289 answers success with no leaf and no branch there (Windows) |
always-on (Windows) | always-on | rule 3 clause (b): the create of both daemons refuses that junction. Maintainer decisions of 2026-09-27 and, for the levels above .claude, of 2026-10-07 |
the reference refusing the junction or deleting through it, or a Windows client that depends on the success reply |
| D20 | Wait 50 ms and read again before the group SIGKILL of a child-group leader that reads as gone, at the reap of a -serve start (Linux and macOS) |
always-on (Linux and macOS) | always-on | rule 3 clause (a) | a measurement that shows the reference waiting before that SIGKILL, or a report of a child that outlived a restart because it replaced its program |
| D21 | A second daemon on a live socket appends to remote-server.log, where 89cb6289 truncates it and loses the earlier lines of the first daemon (Windows) |
always-on (Windows) | always-on | Maintainer decision of 2026-10-02. No frame, reply or exit status differs. A reader of the log file sees the kept lines | a measurement that shows the reference keeping those lines, or a reader of the log that needs the file to start with the lines of the second daemon |
| D22 | git.worktree_remove refuses a worktree locked in the .git folder of baseRepo, in four states where 89cb6289 answers success. Two have a daemon GIT_DIR and GIT_COMMON_DIR of another repository, with the folder present or gone. One has a daemon GIT_DIR alone. One has a baseRepo that does not exist as sent. With worktreeRoot, rows q2 to q4 (Linux VM) differ in the frame too |
always-on | always-on | Maintainer decision of 2026-10-03. The frame differs from 89cb6289 in those four states and in rows q2 to q4 |
a caller that needs the removal of a locked worktree there, or a measurement that shows the reference refusing there |
| D23 | The environment block of a child is in name order (Windows). The reference does not sort it | always-on (Windows) | always-on | Maintainer decision of 2026-10-13: claustrum stays on Go 1.26, which sorts the block | the reference moves to Go 1.26 or newer |
| D24 | The rollback of git.worktree_create keeps a folder that replaced the new registration. 89cb6289 removes it in cells B6 and B6b (Linux and macOS VMs), and in B6b it holds the registration of a live sibling worktree |
always-on (Linux and macOS) | always-on | Maintainer decision of 2026-10-07. The frames are equal in the measured cells (Linux and macOS VMs). The disk differs | a measurement that shows the reference keeping such a folder, or a decision to match the disk of cells B6 and B6b |
| D25 | With CLAUDE_SSH_PEER_CHECK=1, server.capabilities answers "peerCheck":"unavailable" and the daemon serves every caller. 5fd08069 answers "peerCheck":"on","peerCheckBy":["network","pidfd"] there (Linux VM) |
always-on (Linux) | always-on | Maintainer decision of 2026-10-10. The frame differs from 5fd08069 in one of the listed values: 1 on Linux |
a client that needs the "on" answer on Linux, or a decision to answer as 5fd08069 does there |
| CT-1 | Opt-in wantPid → pid + startTime on spawn/reattach |
off (fields omitted) | caller sends "wantPid":true |
sanctioned optional-param extension | — (additive, degrades both ways) |
| CT-2 | -keep-children leaves the child tree running on shutdown |
off | -keep-children / keep-children key |
off-wire opt-in extension | — |
| CT-3 | claustrum.conf config file |
absent ⇒ stock | create the file | the opt-in mechanism itself | — |
| CT-4 | Hardened token persistence | not built (deferred idea) | — | deferred | — |
| CT-5 | -listen-pipe Windows named-pipe transport |
off | -listen-pipe / listen-pipe key (Windows) |
additive opt-in transport | — |
Tags: opt-in = operator-declinable (flag + config key). conditional = activated by the caller. No current entry is conditional. always-on = no switch. The CT block uses "opt-in" in the looser sense of "off unless somebody asks for it". CT-1 is caller-activated and CT-3 is the config mechanism, so neither one is operator-declinable. Only CT-2 and CT-5 carry a flag and a key.
Entries¶
D2 · Refuse a home directory as a destructive path target (always-on)¶
files.extract_tartests the folder that it opened.wipesHomeDirjudges thedestDirpath as text. The entries and the remove of the.syncedentry act through a handle ofdestDir, which claustrum opens after the wipe and the mkdir of the new folder. Another process can replacedestDirwith a link or a junction in that time. So claustrum tests after the open thatdestDiris still a real folder and is the folder of the handle. If not, it answersopen destDir: "<name>" changed while it was opened. It writes no entry and removes nothing. claustrum opens that handle through a handle of the parent folder. A link that leaves the parent folder fails that open first, withopen destDir: openat <name>: path escapes from parent. This test is part of this entry: it keeps the delete on the folder that the guard judged. A request alone cannot stage the swap, and no measurement says what the reference does there. AdestDirthat is a link or a junction before the request is not refused: the wipe removes the link first, as on the reference. The wipe removes the last name ofdestDirthrough a handle of the parent folder, so it follows no link and no junction. The maintainer approved this delete path on 2026-10-10.files.extract_tartests the entry that the wipe acts on. Before the wipe claustrum reads the last name ofdestDirinside the handle of the parent folder. Two tests follow, and they run again after the mkdir of the new folder. If the views differ there, the entry at that name goes with a plain remove, never as a tree. First, a folder at the last name that is the home folder, or a parent folder of it, gets the home refusal by identity. That refusal comes after the archive open, so the archive is gone. On Linux and macOS that covers a path through a link above the last name. On Windows the text compare refuses a dot or a space after home or after a folder above it, because the path is resolved first. It lets a path with the\\?\prefix and the short 8.3 name of home pass, and the test by identity refuses those. These Windows rows are runs of the test binary on a Windows VM, with a temp home and a stubbed wipe. A Windows VM also ran them on a daemon, one run each. A link or a junction at the name itself is not followed: the wipe removes the link, and its target stays. Second, the path as the system resolves it and the name inside the handle must name one entry. If not, claustrum answersclean destDir: "<name>" is not the entry that the path names, or the same text aftermkdir destDir:, and deletes nothing. On Windows the two views differ for a path with the\\?\prefix and a dot or a space after its last name. Six cells are measured: each of the two names withdestalone, with a folder of the exact name too, and with neither.89cb6289extracts into the folder with the exact name, which it makes if there is none.5fd08069extracts into the folder with no dot or space, which it makes if there is none (Windows VM). A seventh cell is the last name...behind the prefix.89cb6289makes a folder with that exact name and extracts there.5fd08069answersclean destDir: RemoveAll ...: invalid argument. claustrum answers the text of the two views, and the tree is equal with5fd08069(Windows VM). claustrum refuses, because the home guard compares the text of the path. These tests are for this method. They do not cover the other callers ofwipesHomeDir.-installis guarded too. The install removes a folder at the CLI path,<cli-dir>/<version>, as a tree before the new CLI runs. With a cli-dir that is the parent of the home folder and a version that is its leaf name, that path is the home folder. D6 does not cover that case. claustrum refuses a CLI path that is the home folder or contains it:cli path must not be or contain the home directory: "<path>". The folder is not removed, the blob stays and no CLI runs. The reference removes the home folder as a tree and puts the CLI file there. It does so for a folder that contains the home folder too. When the new CLI then fails, the folder is gone and nothing is left at the path (Linux cell H2, macOS cell HX2L). This guard compares folders, not only path texts. The lexical test of the RPC paths (wipesHomeDir) runs first. Then the guard compares the folder at the CLI path with the home folder and with each parent folder of it, by file identity. It does that for the home path as given and for its resolved path. On the RPC pathswipesHomeDirstays lexical. Measured with-cli-zst. In each cell of the list below claustrum refuses and89cb6289removes the folder. Linux, 39 runs: H1, H2 and H5 once, and three runs each of twelve HS cells. HS1, HS2, HS3, HS4 and HS6 have a symlink in the home path or in the cli-dir. HS7 has a link that points below the folder. HS8 has a chain of two symlinks, and HS9 has a relative symlink. HS10a has a trailing slash on the cli-dir, and HS10b hassub/..in it. HS11 has a relative-cli-dir. In HS12 the home path is a bind mount of the real folder.f6010b97ran 25 of those 39 runs, and it removes the folder in each. macOS, 51 runs: HX1, HX2, HX2L, HX5L and HX5 have both paths in one spelling. HX1S, HX1R, HX2S and HX2R have the/tmpsymlink in one of the two paths. HXL1 and HXL2 have a home path that is a symlink. HXK1 to HXK5 have another letter case, and HXKS and HXKR have it with the/tmpsymlink. HXB has a link that points below the folder. HXTS has a trailing slash on the cli-dir, and HXDD has..in it. Windows, 38 runs: HX1b and HX2, and three runs each of HJ1 to HJ8 and HJ10 to HJ13. The HJ cells are a junction in the home path or in the cli-dir, a directory symbolic link and another letter case. They are also an 8.3 short name, a junction that points below the folder, a chain of two junctions and asubstdrive. A folder at the CLI path that is not the home folder and does not contain it is replaced, as on the reference. The control cells are H3, HS5 and HS13 on Linux, HX3, HXC, HXKC and HXKC0 on macOS, and HX3, E11b, HJ9 and HJ14 on Windows. With no source flag neither binary removes anything. A second mount of the same folder is measured in one shape, on Linux: the home path is the mount point, and the final name is the mount source (HS12). No cell covers the reverse shape or a mount of a parent folder. No cell covers a home folder that does not exist, a hard link, or Unicode normalization forms. No cell covers a case-sensitive volume on macOS or a UNC path. Not measured:-cli-url. The sweep and the-cli-keepprune of-installhave the same guard (cliEntryHoldsHome). Each removes with one plain remove, so a home folder with content cannot go there. An entry of the cli-dir that is the home folder or holds it is not removed. A folder gets the two tests above, and every other kind of entry gets the lexical test alone. In the prune that entry still takes its place in the order. That is claustrum's own choice.89cb6289removes an empty home folder that is an entry of the cli-dir. In the prune it is the oldest entry, past the keep value (cells H1, Linux, macOS and Windows VMs). In the sweep it has the nameh.zstand is 20 minutes old (cells H3, the same three systems). claustrum keeps it in each of those cells. A home folder with content stays on both (cells H2, the same three systems). The difference stays by the maintainer's decision of 2026-10-07. This guard andwipesHomeDirtake the home folder fromos.UserHomeDir. That is theHOMEvariable, orUSERPROFILEon Windows. With that variable unset or empty they refuse nothing. This follows from the code and is not measured.- Behavior. Three methods hand a caller-supplied,
~-expanded path to a recursive delete.files.extract_tarwipesdestDir.git.worktree_removedeletesworktreePathitself, through anos.Rooton its parent. A worktree locked in the.gitfolder ofbaseRepois refused, not deleted. Whengit.worktree_createrolls back a worktree, it deletesworktreePath. A rollback follows a callertimeoutMsthat expired during a successful add, the checkout or the copy step. It also follows a post-checkout drain that exceeded the callertimeoutMs, and a failed read-tree checkout. On Linux and macOS it also follows a failed placement of the index of the new worktree. That rollback deletes the leaf's entries, then the empty leaf. After a failedgit worktree add, create only removes an empty leaf, with an rmdir. On every rollback arm the guard is defense-in-depth behind create's own containment.wipesHomeDir(homeguard.go) refuses any target that is or contains the home directory. Descendants stay allowed, because extracting into~/.claude/…is the daemon's own install path. On Windows a create also removes old.claude-new-worktree-*entries of the git directory ofbaseRepo.5fd08069does so in cell 48 of rounds3p2b-edges3(Windows VM).wipesHomeDirruns on each such entry before its removal, and a refused entry stays. That guard is claustrum's own there: no cell of5fd08069has such an entry that holds the home folder. The deletes inside the foldergit-shadowof the daemon run the same guard on the full path. - Containment is the test, and the predicate resolves relative paths
(
filepath.Abs) before it compares them. This is whywipesHomeDirresolves the path. Without that resolution,"worktreePath":".."from a daemon whose cwd is home reachedos.RemoveAllon home's parent (measured pre-7d193f89). Ongit.worktree_removethat..no longer reaches the delete, because the containment check below refuses a".."component first. But the resolution still guardsfiles.extract_tar, and it is the guard's own design invariant regardless. - Since
7d193f89,git.worktree_removerefuses a home path on its own, as parity, not as this divergence. That build confined session worktrees to inside the repository. AworktreePaththat is not strictly underbaseRepois now refused with the reference's own "not inside the repository" wording. The refusal comes before git, the delete, orwipesHomeDiris reached. Every~-expanded home path is such a path. On that method's default branchwipesHomeDiris now defense-in-depth behind the reference's containment. It can still fire only in the exotic case of a repository that is itself an ancestor of home. On theworktreeRoot/external_rootbranch the in-repo containment does not apply, because the worktree is placed under the caller's root. So therewipesHomeDiris again the active home guard, and it runs before the delete, as it does on the default branch.files.extract_targained no such containment. So there toowipesHomeDirremains the primary and only home-directory guard, which is why this divergence stays always-on. - This fired. On 2026-08-02 an in-repo fuzzer sent
"destDir":"~"at a live daemon and destroyed the maintainer's home directory."~"is the first value in the adversarial list that survives the oldIsAbs && !isFilesystemRootgate. A home directory is exactly "absolute and not a filesystem root". - Why always-on. D2 satisfies both halves of rule 3 clause (a). At
7d193f89the reference's behaviour is still unrecoverable data loss onfiles.extract_tar. Measured,"destDir":"~"wipes the home directory and answers{"success":true}, and that method gained no containment.git.worktree_removeno longer reaches it, because its own containment refuses the home path first. So therewipesHomeDiris defense-in-depth on the default branch, for the exotic repo-is-an-ancestor-of-home case. It is the active guard on theexternal_rootbranch, which skips that containment. And no honest caller has a legitimate use for deleting home. A caller can still reach that path by accident, which is the point. The same holds for-install: no honest install replaces the home folder with the CLI file, and the delete is not recoverable. The sweep and prune part of-installis not under that clause. From the code: without the guard its plain remove takes at most an empty home folder or a link at the home path. That part stays by the maintainer's decision of 2026-10-07. - Not a security boundary. The socket + token already grant
process.spawn(SECURITY.md). This guard stops the accidental, generated, or mistyped path. On the RPC paths it does not resolve symlinks. The-installguard compares folders by identity. - Reopen trigger. An honest caller legitimately naming a destructive target that is or contains a home directory.
- Pointers. The page files.extract_tar, and PROTOCOL.md →
git.worktree_remove(thegit.worktree_createguard emits no frame). Alsohomeguard.goandhomeguard_test.go(wipeDestDirseams the destructive call, so the suite is safe against an unfixed tree). For-install:stageAndInstallandcliEntryHoldsHomeininstall.go, and PROTOCOL.md →-install→ Staging and cleanup. Measurement: forensics.
D3 · Make the files.extract_tar size cap opt-in¶
- Behavior.
maxExtractBytescaps extraction output. An over-cap extraction returns{"success":false,"fileCount":0,"error":"extraction size limit exceeded"}and removes the truncated entry. - Default.
0= unlimited = byte-identical. Activate:-max-extract-bytes <n>or themax-extract-byteskey. The disabled state bypassesio.LimitReader(io.Copy(out, tr)). - Why opt-in. Measured, the reference completes a 629 MB extraction with no cap
at
5db5e4a. That is a frame, not an unbounded wait, so a non-zero default fails an extraction the reference completes, and Desktop owns the argv (rule 4). - Reopen trigger. An operator's cap refusing a legitimate extraction, or the default letting a size bomb through in normal use.
- Pointers. The page files.extract_tar and
methods_files.go. Measurement: forensics.
D5 · Make the gitTimeout deadline opt-in¶
- Behavior. With the deadline on, claustrum bounds every git invocation (shared
gitCtxacrossgit/gitStdoutErrand the hardened helpers). That method runs nogit worktree remove. Ongit.worktree_removethe deadline starts no delete. A kill refuses or skips a step. One skipped step is the daemon's own read of the repository config. After a kill of that call the remove goes on as it did before that read. If the worktree directory exists, a hit on the config or repository check answers the lock-check refusal. For a gone worktree, the hit gives the lock-check text with the hooks refusal, or skips the registration step. WithworktreeRoota hit answers the work-tree refusal (cannot determine the repository's work tree), and so does a hit onrev-parse --show-toplevelor the firstworktree listcall. If the D5 deadline stops the secondworktree listcall, the answer iscannot list the repository's worktrees: signal: killed. If the deadline expires in the listing before that call, the text ends withcontext deadline exceeded. That call runs only after the first one failed. The second text has no test. A D5 stop of a call of the branch step keeps the branch, and the reply adds"branchKept":true. In agit.worktree_createrollback such a stop adds an undo text instead. It is one of the branch-step texts in PROTOCOL.md → The branch step. Ongit.list_branchesa hit surfaces as-32603 signal: killed. Ongit.statusthe frame of a hit depends on the call that it stops: a hit in one of the four commands surfaces as-32603 signal: killed. The frames of a hit in the other calls are from the code and not measured. Ingit.statusone deadline covers the 16 git calls of the answer, or 18 whenHEADnames no commit (from the code). It covered 2 calls before. A killed repo-detection call answersisRepo:false. A killedbranch --show-currentingit.infoleaves out thebranchmember. A killeddefaultBranchverify there givesdefaultBranch"". On Windows a killed root pair ofgit.infofalls back to the walk root. A killed first listing of a method runsgit version, and the method answers by that failure class. Two more cases are from the code and exist only with the deadline on. Their textsignal: killedis the one of Linux and macOS. A killed listing in a folder that holds no repository refuses with the hooks refusal that ends insignal: killed. A listing that cannot start in an existing path runsgit version. If the deadline kills that call, the method refuses withgit cannot run on this host; git not run: signal: killed. Ongit.worktree_removeeither text is inside the lock-check frame, or inside the work-tree frame withworktreeRoot. From the code: the deadline also bounds the calls of the settings copy. Those are the calls of the start test, the call about the repository, thegit versioncalls after them and the--unset-allcalls of a create. Each has a bound of 30 seconds of its own, so only a shorter deadline counts. A test call that it ends fails the start test, and that failure is kept 10 seconds. A call about the repository that it ends answers thedid not finish within 30 secondstext. None of these is measured. - Default.
0= no deadline (byte-identical). Activate:-git-timeout <dur>or the key. The disabled state bypassescontext.WithTimeout. - Never read a timeout as "git refused." On
git.worktree_removea kill refuses or skips a step. One skipped step is the daemon's own read of the repository config. After a kill of that call the remove goes on as it did before that read. Ongit.worktree_createa kill leads to a delete: its rollback deletes after a failed or killed read-tree checkout, and it removes an empty leaf after a failed add. A caller that deletes on a git failure must tell the deadline apart from the verdict of git first. The cap is also softer than it reads on the general git sites.CombinedOutput/Outputwaits on git's output pipe. So a git that leaves a surviving child stays blocked past the deadline ongit.status,git.list_branchesand the repo probes. Those paths are unmeasured for a descendant-orphan case, so the drain is recorded rather than capped there. The one measured exception isgit.worktree_create's read-tree checkout, which can run a smudge/hook filter that backgrounds a pipe-holding descendant. That path alone caps the post-exit drain at a fixed ~5 s from git's own exit. It also SIGKILLs the process group (hardenedGitCheckout/worktreeCreateDrainCap). That reproduces4534d86. When the callertimeoutMsexceeds that drain, the answer is success. Otherwise the answer iserrorCode:"timeout"("deadline expired after the checkout finished", nosignal: killed) with the worktree rolled back. Measured inscratch/probe/wt-success-lingering-4534d86.md. This is parity, not a divergence. - One opted-in arm loses data silently, on either seeding pass.
populateWorktreeruns two seeding passes,copyWorktreeIncludes(worktreecopy.go) andcopyClaudeDir(worktreeclaude.go), and both are best-effort. A killed call loses what that call lists, and the error is dropped. Thereforegit.worktree_createstill answers{"success":true}with files missing. Each git call gets its own deadline fromgitCtx, so a kill loses one pass and not the other: the manifest copy can succeed while the.claude/seed is lost, or the other way round. The.claude/pass has no manifest precondition. It runs git on every create where.claude/holds a child other thanworktrees. A killedgit versionselects the full scan. No frame moves. This arm is absent at the default. git.worktree_createunder both deadlines. The add and the read-tree checkout run under the shared deadline. A D5 hit on the add answersgit worktree add failed: …witherrorCode:"worktree_add_failed". That is the same failure arm as any other git error, and it is distinct from the 4534d86 caller-timeoutMsarm (errorCode:"timeout"). A D5 hit on the read-tree checkout is a failed checkout. It answersgit worktree add failed (checkout): …witherrorCode:"worktree_add_failed", and the create rolls back. With aworktreeRoot, a D5 hit onrev-parse --show-toplevelorworktree liststops the create withmkdir_failed, and nothing is made. A D5 hit on one of thesourceBranchsteps counts as a failed step. It can change the start commit. If both candidate lookups are killed, the HEAD fallback runs. The echoedsourceBranchthen becomes the current branch, or is omitted on a detached HEAD. When a caller supplies atimeoutMsLONGER than-git-timeout, the tighter D5 deadline fires first during the add. claustrum still answersworktree_add_failed, because it attributes the kill to the deadline that actually fired. A callertimeoutMsearnserrorCode:"timeout"in one case only. That case is the one where the callertimeoutMsis the deadline that fired. So the caller's longer duration is never quoted for a kill D5 caused. This interaction is claustrum-only, because the reference has no-git-timeout. It is absent at the default. Implemented with a distinct context cause (errCallerTimeoutMs, checked bycallerTimeoutFiredinmethods_git.go).- Why opt-in. The reference showed no deadline at or below 75 s on
worktree_remove, outside the branch step. That step has its own bounds on both sides since89cb6289(PROTOCOL.md → The branch step). An honest 61 s git was never measured. The deadline cleared clause (a)'s not-a-frame half, but the-32603 signal: killedarm is an honest caller observing the difference (rule 4). - Reopen trigger. An operator with
-git-timeoutset reporting an honest slow git killed by it. The-32603arm makes a single report enough. - Pointers. PROTOCOL.md →
git.worktree_remove, and the page git.list_branches. Alsomethods_git.go,worktreecopy.goandworktreeclaude.go.
D6 · -cli-version must name a single path component (always-on)¶
- Behavior. The install's clearing step is an
os.RemoveAllof the CLI path,<cli-dir>/<version>(<version>.exeon Windows), so a version that reaches outside the cli-dir deletes unrelated data. claustrum answerscli version "…" must be a single path component. It answers before the install writes or removes anything at a path built from the version. The two sweeps of the cli-dir name only the cli-dir, and they are not behind this rule. From the code: the*.zst.partsweep runs before the rule answers, and the 10 minute sweep runs after the refusal. Cell P5 (Linux VM) has-cli-version ../x, ap.zst.partfile that is 8 days old and a.fetch-dfile that is 20 minutes old. claustrum and89cb6289both remove the two files and exit0. claustrum answers the refusal, writes nothing and keeps the blob.89cb6289installs a file at<parent>/x. That rule runs on the install path. A regular file that is already at the joined path is first run with--version, as the cache-hit check. A run that passes answers"cliWasPresent":truewith that path, as on the reference. That is measured with-cli-version ../xon Linux againstf6010b97and89cb6289, and on macOS against89cb6289. A run that is stopped answers the unresponsive text and sweeps the cli-dir. D6 answers only when that run fails, or when no file is there. The reference has no such rule. With../xand a blob it installs to<parent>/x, outside the cli-dir, and runs that file. That is cell V1c on Linux, macOS and Windows. It refuses.,.., and both/and\on every OS, so the accepted set does not change with the platform. - A single component rather than a lexical containment check. A lexical check
accepts
link/1.0.0(an intermediate symlink under the cli-dir, followed at open time). If this path ran anEvalSymlinkscheck, that check adds only a TOCTOU window before theRemoveAll. A final component that is itself a symlink stays legal, becauseos.RemoveAllunlinks it rather than follows it. So the rule is narrower than "no symlinks". - Why always-on. Rule 3 clause (b). Measured, the reference destroys the
target on
../victim. The real client passes bare version strings (1.0.86, a commit sha,latest, all measured accepted). The evidence is an observed value plus a measured accepted-set, not an enumeration. - Reopen trigger. Desktop passing a
-cli-versionthat is not a single path component. - Pointers. PROTOCOL.md →
-install, andinstall.go.
D8 · claustrum never follows or writes a foreign/symlinked remote-server.log (always-on)¶
- Behavior. claustrum rotates the prior log to
remote-server.log.oldand creates a fresh own log (os.RenamethenO_EXCLcreate). That matches4534d86, which keeps the previous session's log as.oldon every restart. That part is parity, reachable on the ordinary per-restart path. The divergence is how claustrum treats a log it does not own. For a planted symlink in a writable directory,os.Renamemoves the link itself (not its target) toremote-server.log.old.O_EXCLthen creates a fresh regular log. The link is never followed, and the victim file stays untouched. On a sticky directory where claustrum cannot rename the existing entry (another user's file or symlink), the rename cannot proceed and the exclusive create fails. claustrum then declines the log and falls back to inherited stdio. In both cases, on Linux and macOS, claustrum never follows the link or writes into a file it does not own. - Windows. After a failed rename and a failed create, the Windows launcher
opens an existing regular
remote-server.logfor append. That is the path of D21. A path that is not a regular file is refused. The type test and the open are two calls, and the open has no owner test. Both halves of this entry therefore hold on Linux and macOS only. This follows from the code and is not measured. - The upstream state changed, and this entry was revised to match it. Measured
2026-09-06:
4534d86no longer plain-truncates a foreign regular file.5db5e4adid, measured 2026-08-06. claustrum now matches the.oldrotation on the common path. But in a root-owned sticky directory the reference still FOLLOWS a plantedremote-server.logsymlink. It writes its own log into the victim, or it refuses to start. claustrum refuses to follow it. So a narrower hardening survives, and it is a D2-style case: "the reference does it too" is not a reason to call it safe. - Hardening, not a defect claim. To reach it you need a local user who can already plant a file or symlink in that directory. The justification is a design position: a daemon must not follow a link it did not plant or write into a file it does not own. It does not depend on the reference being wrong.
- Why always-on. Rule 3 clause (b). The trigger is unreachable on the
deployed path (
~/.claude/remote/is per-user, not world-writable). If a flag existed, it gates a branch that no honest deployment reaches. - Reopen trigger. A deployment that puts the socket directory somewhere shared and needs the log file. Or the reference gaining the same refuse-to-follow (then this becomes parity, not a divergence).
- Pointers. PROTOCOL.md → Daemon log, and
server.go(openDaemonLog).
D9 · Namespace-wide params binding is stricter than the reference's (always-on)¶
- Behavior. claustrum binds
paramsinto one struct per namespace (pathParams,gitParams), so a field that is valid for the namespace but unused by this method still participates in decoding. A type-mismatched value there answers-32602, for examplefiles.stat {"maxBytes":"{"}. The reference answers that request with defaults, as measured onf6010b97. Both binaries ignore a genuinely unknown key. - Why always-on. Rule 3 clause (b): the trigger is a type error in a field the method does not read (a client bug). Stated honestly, this is narrower than "a real client never sends them". Nobody ever enumerated Desktop's per-method param set against this binding, so it is unenumerated, not established.
- Reopen trigger. Any real client sending a type-mismatched value in a namespace field the target method does not read. That observation is also the measurement this entry owes and does not have.
- Pointers. PROTOCOL.md → Params presence and typing.
D10 · Make the -install CLI size cap opt-in¶
- Behavior.
maxCLIBytesgoverns two reads: the decompressed CLI (decompressing: decompressed CLI exceeds <n> bytes) and the HTTP download body (download failed: response exceeds <n> bytes). - Default.
0= unlimited (byte-identical). Activate:-max-cli-bytes <n>or the key. When disabled, both call sites bypass theirLimitReader. - claustrum streams the blob and never buffers it. It writes a
.blob-<random>temp in the cli-dir (or$TMPDIR/claustrum-fetch-<random>if the cli-dir is unwritable) and hashes it in one pass. Therefore "cap off" does not mean unbounded memory (measured 886 MB → 10 MB on a 400 MiB payload). The prefix.blob-, not.fetch-, is the one that matters..fetch-*is the swept namespace. If the staging blob used the.fetch-prefix, the sweep of a concurrent install deletes it, and that defeats the staging retry (errStagingVanished). The creator, both housekeeping passes, andvalidateCLIVersionall readblobTempPrefix. - Who pays for opting in. A cap set below free space replaces a disk-full report
with the cap's own message, which costs the user the free-space hint. But that
cost turns on the
cliErrordriver claim (ARCHITECTURE.md). At the default, claustrum preserves the disk-full report and matches the reference. - Why opt-in. Measured, the reference takes a 600 MiB payload to the runnability check on both the decompress and download paths. That is a frame, not an unbounded wait, so a non-zero default fails an install the reference completes, and Desktop owns the argv (rule 4).
- Reopen trigger. Desktop ceasing to treat a disk-full message as terminal
(which removes the cost). One plausible Desktop change fires this trigger and
D13's at once: Desktop broadening its terminal match to any
decompressing:error. - Pointers. PROTOCOL.md and
install.go(fetchToFile,zstdDecompress). RSS and cap-below-free-space tables: forensics.
D12 · Make the -install CLI download bound opt-in¶
- Behavior. The download once ran with
http.Client{Timeout: 5m}. It now runs withTimeout: cliDownloadTimeout, which defaults to0. The bound covers the whole exchange, including the body read. A real body over a link that needs six minutes therefore trips it exactly as a black hole does. - Default.
0= no bound (byte-identical).http.Client{Timeout: 0}is the stdlib's own "no timeout" sentinel. Activate:-cli-download-timeout <dur>or the key. - Zero frees the body read, not every clock.
fetchToFileuses a clone ofhttp.DefaultTransport, which still appliesnet.Dialer{Timeout: 30s}andTLSHandshakeTimeout: 10son-cli-url. A SYN-black-holed host therefore fails at 30 s with the bound off. Both clocks are always-on stdlib defaults, unnumbered and unprobed on the reference. The wait for the response headers has a 60 s limit, always on. That limit is parity, not this divergence:f6010b97and89cb6289give up there too (measured on Linux and Windows). A bound set below 60 s fires first. No row measures that pair. - Why opt-in.
4534d86bounds a fully STALLED body itself, at a 60 s read-idle abort that claustrum reproduces always-on as parity. That is NOT this divergence (see PROTOCOL.md →-installdownload). What a non-zerocliDownloadTimeoutadds beyond the read-idle abort is a TOTAL-exchange cap. No total cap was observed on the reference within the window measured. VM-measured against4534d86, a body trickling 1 byte every 30 s was still downloading at 150 s. Each byte resets the read-idle clock, so the read-idle abort never fires. The honest slow-but-progressing case a total cap penalizes was measured on5db5e4a: a valid blob dribbled to completion over ~324 s installed there, while claustrum at the retracted 5 m failed it at 300 s. Such a download pays under a non-zero bound, and Desktop owns the argv (rule 4). The earlier "no bound at or below 400 s on a stalled body" evidence was also5db5e4a, before the read-idle abort existed. On4534d86that same never-sent body aborts at 60 s via the read-idle path, not this deadline. - Reopen trigger. An operator with the bound set reporting an honest slow download failed by it.
- Pointers. PROTOCOL.md and
install.go(fetchToFile). Straddle and stall tables, confounders: forensics.
D13 · -install verifies the checksum before decompressing (always-on, UNRESOLVED)¶
This is the one entry that does not currently clear rule 3.
We record it rather than explain it away. It stays in the code, labelled, rather than justified by a rule bent around it.
- Behavior. On the
-cli-urlpath the reference decompresses first and aborts on the first invalid bytes. claustrum hashes the response as it streams to disk, verifies the checksum, and then decompresses. On a blob that is both undecompressable and wrong-checksummed the reference saysdecompressing: unexpected EOFwhere claustrum sayschecksum mismatch. A connection reset in the body is not a D13 row. It answersdownload interrupted after <got>/<total> bytes: <err>on both binaries. Measured on a Linux VM againstf6010b97and89cb6289. A body that ends before its length is not measured. - One more measured effect. The order also shows without a text delta. The
body was 4096 bytes that are not a zstd archive, with the right checksum for
those bytes. Both binaries answer
decompressing: invalid input: magic number mismatch. The reference reports"fetch":{"bytes":4,…}and prints one progress line. claustrum reports"bytes":4096and prints two progress lines, because it reads and verifies the whole body before it decompresses. Measured on a Linux VM againstf6010b97and89cb6289. - Why it is unresolved. We wrote clause (c) for this entry and, measured, it
did not meet it. On both honest-path rows the reference created an empty
cli-dir where claustrum created nothing (when the cli-dir did not already
exist). The delta was therefore not confined to diagnostic text: an empty
directory is state a caller keeps, and a caller can distinguish it with a
files.stat. The reopen fixture run 2026-08-08 did not meet its condition. - That on-disk delta is gone. claustrum now creates the cli-dir before any
network access, as the reference does (measured on
f6010b97). The-cli-urlrow was measured again on89cb6289, on Linux, macOS and Windows. The body was the first half of the blob, with the checksum of the full blob. The texts differ as above, and both binaries leave an empty cli-dir. The-cli-zstrow was not run again. Whether D13 now meets clause (c) is open, and D13 stays unresolved. - The trigger is reachable (not "an input no honest caller produces", which was measured wrong): a bad mirror, a partial upload, or a stale short proxy object is undecompressable and checksum-mismatched with no adversary. This is not the generic "flaky network" case: a connection reset answers the same text on both binaries, as above.
- Why still always-on despite being unresolved. The delta stays cheap because
neither string is disk-full-shaped. Therefore Desktop (per the
cliErrordriver claim) classifies both the same way and retries rather than fails terminally. That is a claim about a third binary, and the parity harness cannot settle it (ARCHITECTURE.md). If it is ever falsified, the cheapness argument fails and this entry owes an opt-in flip. - Reopen trigger. Any change to how Desktop classifies
cliError. For example, distinguishingchecksum mismatchfrom a decompression error, or matching either as terminal. - The
-cli-zstpath too. If the caller supplies a-cli-checksum, claustrum hashes the-cli-zstblob before it decompresses it. The reference reports a decompress failure before a checksum mismatch. So a corrupt blob with a wrong checksum answerschecksum mismatchon claustrum anddecompressing: unexpected EOFon the reference. Both binaries leave an empty cli-dir there. The reference half is measured on a Linux VM against7d193f89throughf6010b97. - Pointers. PROTOCOL.md → Staging and cleanup, and
install.go. Ordering, clause-(c), and reopen-fixture tables: forensics.
D15 · Verify a run-dir lock holder is our serve process before signalling it (macOS)¶
- Behavior. On the run-dir lock's eviction path, before a new
-servedaemon signals a live lock holder, it verifies that the holder is one of our own-serveprocesses bound to this socket. On Linux both the reference and claustrum verify the holder's command line (claustrum reads/proc/<pid>/cmdline). On macOS there is no/proc, and the reference does not verify the holder before signalling: it sends SIGTERM to the recorded pid unverified. Measured on a macOS VM: the reference signals even a lock holder that is not a serve process. claustrum instead reads the holder's argument vector fromsysctl KERN_PROCARGS2and refuses to signal a pid whose argv is not our-servefor this socket. -stoptoo.-stopruns the same holder check before it signals a lock holder. On macOS the reference-stopsignals any live holder whose lock record says roleserveon this host's node. It does not check the holder's command line. claustrum does not signal such a holder, and printssurvivor. Measured on a macOS VM againstf6010b97and90fca6e6. Both references sentSIGTERMto a python holder with a validserverecord, andSIGKILL4 s later if it ignoredSIGTERM. claustrum left it alone. On Linux the references also refuse that holder, so Linux is parity.- Default. Always-on, macOS only. On the honest path the live lock holder wrote its own pid into the record, so the verification passes and the outcome is identical to the reference (the predecessor is evicted).
- Why always-on (rule 3 clause (a)). Signalling an unverified pid is
unrecoverable harm. A crash can leave a stale record whose pid is reused by an
unrelated process. A foreign process can also hold the flock. The reference then
SIGTERMs that innocent process. No honest caller benefits from skipping the check.
The same guard is already always-on on Linux (via
/proc), so macOS matches Linux's safety rather than the reference's macOS gap. - Cost. None on the honest path. In the one case where the reference evicts and claustrum does not, the holder is un-inspectable or is not a serve process. There claustrum serves without run-dir ownership instead of killing the holder, which is the safe outcome.
- Reopen trigger. The reference adding the same holder check on macOS (then this
becomes parity, not a divergence). Or a legitimate macOS holder that
KERN_PROCARGS2cannot read, reported as a failed handover. - Not the only identity gate. This entry covers the run-dir lock's eviction path
and
-stopalone. claustrum's host cleaner has its own gate inretireAbandoned, which re-reads the pid's identity before its SIGTERM. That gate is not a numbered divergence. On a Linux VM,f6010b97and89cb6289refused a live listener that is not their daemon binary (row HC04). They also refused one that serves another socket (row ST06). On that Linux VM claustrum refused both with the same texts. On a macOS VM the references refused the listener of row HC04 too. Row ST06 is not measured on macOS. In row GSg2 on a macOS VM, claustrum and89cb6289refused the same listener with thenot our daemon binarytext. Do not read the two as one divergence. - Pointers. PROTOCOL.md → Run-dir lock and
-stop. Alsodaemon_runlock_unix.go(holderSignalRefusal,stopRunDirHolder),daemon_runlock_darwin.go(realIsServeCmdline,procArgv),daemon_runlock_linux.go.
D16 · git.status of a linked worktree returns the status on Windows, where the reference errors (Windows)¶
- Behavior.
git.statuswith abaseRepoand apaththat names a linked worktree returns the porcelain status of that worktree. On Linux and macOS the reference and claustrum are byte-identical. On Windows, when the user has no global excludes file, the reference returns-32603 "exit status 128". claustrum returns the status result ({"isRepo":true,"clean":false,"changes":[…]}). - Measured. Windows VMs gave that error on
19f30c46and onf6010b97. On5fd08069, with no user excludes file, 18 of 18 requests answerexit status 128(Windows VM). - Mechanism, measured on
f6010b97. The status call of the reference passes-c core.excludesFile=NUL, the Windows null device. With that value,git statusof Git for Windows 2.55.0 exits 128 withfatal: cannot use NUL as an exclude file. With/dev/nullit prints the status. - With a user excludes file. The reference passes that file, not
NUL. Its status then works, and D16 does not arise (89cb6289, Windows VM, row K1). - The one value that differs. On Windows claustrum passes
-c core.excludesFile=/dev/nullto thestatus,ls-filesanddiff-indexcalls ofgit.status(statusExcludesFileingitharden.go). Every other call that passes-c core.excludesFilepassesNULthere, as the reference does. - Why diverge. The path is reachable: a Windows client that runs status on a session worktree gets it by design. The reference answers an error for a valid status there. That is the D2 and D8 pattern. D16 stays by the maintainer's decision of 2026-10-13.
- Cost. A Windows client that diffs frames against the reference sees a result where the reference sends an error.
- Reopen trigger. The reference fixing its Windows
git.status. A Git for Windows release that acceptsNULthere. Or a decision to answer 128 as the reference does: that change is the valueNULin those three calls. The reference stops at itsstatuscall, so its value for the other two is not measured there. - Pointers. PROTOCOL.md →
git.status. Alsogitstatus.go(statusChanges) andgitharden.go(statusExcludesFile).
D17 · An abandoned lsof run reads as busy, not idle (macOS)¶
- Behavior. The macOS host cleaner asks
lsofwhether a daemon has a live client. That run is bounded, and the last bound gives up on a run that never returns. A run that gave up produces no output, and so does a run that finished and saw nothing. claustrum keeps them apart. A run it gave up on reads as busy, because it is not evidence about the pid at all. The reference side of this entry is not probe-measured. - Default. Always-on, macOS only. The command
deadline kills a slow run, and the wait delay bounds its pipe. Such a run therefore
completes with no output well before the abandon bound. Reaching the abandon arm
needs a process
SIGKILLcannot end. - Why always-on (rule 3 clause (a)). The harm it refuses is unrecoverable. If an
abandoned run reads as not busy,
retireAbandonedtakes that as permission to continue. After the identity re-check it SIGTERMs the daemon. Where anlsofrun cannot be killed, that ends a session that is in fact serving a client. The loss is that session's state, which extends rule 3's list rather than sitting inside it, the same extension D15 made and the same reason. The other half: a slowlsofcompletes before the abandon bound (see Default), so no honest caller reaches this arm. It is the same shape as D15, which refuses to act on an identity the daemon cannot verify. It is also the same shape as two spares claustrum already has. It spares a daemon whose open files it cannot inspect, and an orphan whose descriptors it cannot inspect. That is an argument for D17 rather than a premise this entry rests on, because the clause-(a) case stands on the harm alone. - Cost. A daemon whose
lsofcannot be killed is never retired by the busy gate, so its run dir stays untillsofworks again. That is the conservative direction. It also costs three wedged runs on such a host: one for the pass line and two in the sampler. Each abandoned run leaves a process and a goroutine behind. claustrum caps neither. - A run that the 15 s deadline ends is outside this entry. The lock read then
keeps the run dir, and the busy read does not retire the daemon.
5fd08069does both under anlsofthat hangs (cells L1, L2, B1 and B2, macOS VM). No staging reached the 17 s arm. - Only the abandoned run is this entry. Two other
lsofoutcomes are parity with89cb6289on a macOS VM, 3 of 3 runs each: a command that does not start, and a run that writes to stderr and exits 1. In both, the daemon gets no SIGTERM, and a run dir with a lock file stays. - Linux is outside this entry. On Linux the busy read comes from
/proc, notlsof. If/proccannot read a daemon's descriptors or itsnet/unixtable, the retire refuses that daemon. The reference's retire refusal, captured on the Linux run for a busy daemon, uses one text for both cases: "a connection is attached to it right now, or that could not be read". The Linux run did not stage an unreadable/proc, so neither arm is measured. Neither arm is a numbered divergence, because neither is known to differ from the reference. - Reopen trigger. A measurement of the reference with an
lsofrun that cannot be killed. Or an operator reporting a run dir the cleaner will not tidy on such a host. - Pointers. PROTOCOL.md → Host cleaner. Also
hostclean_darwin.go(runLsof,hcBusy),hostclean.go(hcSettledBusy,retireAbandoned).
D18 · -cli-version must not start with .blob- (always-on)¶
- Behavior. claustrum downloads a
-cli-urlblob to<cli-dir>/.blob-<random>. The sweep and the-cli-keepprune both skip that prefix, so an in-flight blob is never deleted or counted. A CLI installed under such a name is never pruned or swept either. claustrum therefore answerscli version "…" collides with the install download bloband installs nothing. - Reference side. The prefix is claustrum's own name. Whether the reference
installs such a version is not measured. One difference in the prune is
measured.
89cb6289counts a planted.blob-plantedfile (cell C-12, Windows VM). With-cli-keep 2and two older files beside it,89cb6289removes both older files. claustrum does not count the planted file, so it removes the oldest file only.89cb6289also removes the planted file when it is past the keep value (cells B1, Linux, macOS and Windows VMs). There it is older than two other files and-cli-keepis 1. claustrum keeps it. - Why always-on. Rule 3 clause (b). The real client passes bare versions
(
1.0.86, a commit sha,latest). It is on the same evidence as D6. The prune difference stays by the maintainer's decision of 2026-10-07, not by a rule 3 clause. The name is claustrum's own temporary name of a download in progress, and a download of a second install must not use a keep place. - Remark: the stderr text of a negative
-cli-keep. This remark is not part of D18, and the text has no entry of its own. A good install with a negative-cli-keepexits2with no result line on89cb6289and on claustrum (cells Kneg and Kn-2, Linux, macOS and Windows VMs).89cb6289writes a Go runtime error to stderr there. claustrum writes one line of its own:claustrum: -cli-keep -1 is not a valid keep count. That is the maintainer's decision of 2026-10-07. - Why not part of D6. D6 guards a destructive path that leaves the cli-dir. D18 guards a name that claustrum itself reserves. The reasons and the reopen triggers differ. The number D7 is retired and is not reused.
- Reopen trigger. Desktop passing a
-cli-versionthat starts with.blob-. - Pointers. PROTOCOL.md →
-install, andinstall.go(isDownloadBlobName,validateCLIVersion).
D19 · Refuse a junction between baseRepo and the leaf on remove (Windows, always-on)¶
- Behavior. On Windows,
git.worktree_removerefuses a request with a junction at any directory betweenbaseRepoand the leaf. The reply is{"success":false,"error":"failed to remove worktree: openat <path from baseRepo>: path escapes from parent"}.<path from baseRepo>is the folder that holds the leaf, relative tobaseRepo. It is.claude\\worktreesin the frame for a junction at.claudeor at.claude\worktrees. That text is claustrum's own, the error ofos.Root. From the code: claustrum opens that folder in oneos.Rootstep. Nothing is deleted, and the branch stays. - Reference side, measured. Measured against
f6010b97on a Windows VM (rows J03, J04 and J05 for.claude\worktrees, row JC for.claude). The reference answers{"success":true}and deletes nothing. It still deletes the branch. When the junction leads to a live worktree, that worktree is left on a deleted branch. With no worktree behind the junction, it answers{"success":true}too (rows JCR1 and JCR2, after the refused create). Neither daemon deleted anything outside the fixture. The whole-build check of issue 442 measured89cb6289the same (Windows rows JC, J03 to J05, JCR1 and JCR2), for a branch that another ref reaches. - Reference side, a level above
.claude. Two cells of89cb6289on a Windows VM have abaseRepoP with no junction and a junction<P>\J. TheworktreePathis<P>\J\sub\.claude\worktrees\w1in cell J-b-wt-pj and<P>\J\.claude\worktrees\w1in cell J-b-wt-pJ. The leaf did not exist, and no branch existed.89cb6289answers{"success":true}after 2 git calls. claustrum answersopenat J\\sub\\.claude\\worktrees: path escapes from parentandopenat J\\.claude\\worktrees: path escapes from parentin the frame, with no git call. Nothing changes on disk on either side. Not measured: a live worktree behind such a junction, and the branch step there. - Default. Always-on, Windows only. Activate: always-on. There is no flag and no key.
- Why always-on. Rule 3 clause (b), by the maintainer's decision of 2026-09-27.
The
git.worktree_createof both daemons refuses a junctioned.claudeor.claude\worktreeswithmkdir_failedand creates nothing (reference measured on a Windows VM, rows JCR1 and JCR2). So neither daemon creates a worktree there. Only a worktree made outside the daemon sits there. For it, claustrum keeps a branch that the reference deletes, which on89cb6289is a branch that another ref reaches. The maintainer widened the entry on 2026-10-07 to a junction at any directory betweenbaseRepoand the leaf. The create of both daemons refuses there too. Both answerfailed to create parent directory: <P>\J is not a directorywithmkdir_failedin cells J-b-wt-pj and J-b-wt-pJ, with equal frames. - Cost. A Windows client that diffs frames against the reference sees
success:falsewhere the reference sendssuccess:true. A branch that the reference deletes stays with claustrum. A client that relies on the remove to delete the branch there must delete it itself. - Reopen trigger. The reference refusing the junction with the same text (then
this becomes parity), or the reference deleting the tree through the junction. Or a Windows
client that depends on the success reply for a junction between
baseRepoand the leaf. - Pointers. PROTOCOL.md →
git.worktree_remove. Alsoworktreeremove.go(openRemoveParent). Evidence inscratch/i429/remove-val-windows-6bed4ee.md,scratch/i429/remove-val-windows-8755717.md,scratch/i429/remove-val-windows-8755717-jcr.mdandscratch/i429/s33-windows-junction/raw/table.md.
D20 · Wait for a settle before the group SIGKILL of a leader that reads as gone (Linux and macOS)¶
- Behavior. At a
-servestart the daemon ends the child groups that a dead predecessor left:SIGTERMto the group, then a wait of 2 s. If the leader of a group reads as gone in that wait and the group still answers, the daemon sendsSIGKILLto the group. claustrum first waits 50 ms and reads the leader again. A leader that is alive again takes the record test. A leader that fails that test gets no more signals. This is off-wire. No frame changes. - Reference side, measured.
89cb6289sends thatSIGKILLwith no wait. - Row RP05a (the leader exits on
SIGTERM, two members ignore it), Linux, the 20 runs of the last three run sets: theSIGKILLcomes 0.3 to 4.9 ms after theSIGTERM. - Row PG05a (a child that replaces its program on
SIGTERM), Linux, six run sets of 10, 10, 10, 30, 10 and 10 runs:89cb6289ended the child in 4, 2, 3, 5, 1 and 5 runs. That is 20 of 80 runs. In the other 60 runs the child stayed alive. - Row PG05a, macOS, three run sets of 21, 20 and 10 runs:
89cb6289ended the child in no run. - Rows where the child ends on
SIGTERM, Linux:89cb6289also sends a groupSIGKILLright after theSIGTERM, in 15 of 42 runs of one run set. claustrum sends none there and reads the group again after the wait. - claustrum side, measured. With the wait, claustrum ended the child of row PG05a in none of 50 Linux runs (the last three run sets) and in none of 51 macOS runs. In the Linux set of the build right before the wait, it ended the child in 2 of 10 runs.
- Default. Always-on, Linux and macOS. Activate: always-on. There is no flag and no key. The 50 ms is claustrum's own value.
- Why always-on (rule 3 clause (a)). The harm it refuses is a
SIGKILLto a live process that is no longer the recorded child. That loss cannot be taken back. It is the same shape as D15, which refuses to signal an identity that the daemon cannot verify. On the honest path the outcome stays inside what the rows of89cb6289show: in row PG05a the child stays alive, as in 60 of its 80 Linux runs, and in row RP05a the group still gets itsSIGKILL. The only difference on the honest path is the time of one signal at a daemon start, off the wire. - Cost. In row RP05a the group
SIGKILLcomes about 50 ms later. Measured on Linux in the same 20 runs: 51 to 55 ms after theSIGTERMin 19 runs, and 152 ms in one. Measured on macOS in 13 runs of row RP05q (three run sets): the members end 51 to 80 ms after theirSIGTERMin 12 runs and 100 ms after it in one, against 25 ms at most on89cb6289. A reap whose groups all end on theSIGTERMreturns one wait later. - The wait is a threshold. A program replacement that takes longer than 50 ms
still gets the
SIGKILL. No measured run shows that. - Reopen trigger. A measurement that shows the reference waiting before that
SIGKILL(then this becomes parity). Or a report of a child that outlived a daemon restart because it replaced its program. - Pointers. PROTOCOL.md → process.spawn. Also
childreap.go(settleGone,reapGoneLeader). Evidence inscratch/89cb6289/e-linux/REPORT.mdandREPORT-val1.mdtoREPORT-val5.mdthere, and inscratch/89cb6289/e-macos/REPORT-val1.mdtoREPORT-val3.md.
D21 · A second daemon on a live socket appends to remote-server.log (Windows)¶
- Behavior. On Windows a second
-servecan start on a live socket, and the first daemon stays alive. The first daemon holdsremote-server.logopen, so the launcher of the second one cannot rotate it. claustrum then opens that file for append. If both daemons are this build, each one writes to the end of the file, and no line of either one is lost. A first daemon of an earlier build writes at its own offset. That follows from the code and is not measured. This is off-wire. No frame changes. - Reference side, measured. On a Windows VM (rows WN04 and WJ04),
89cb6289writes to the same file and truncates it at the second start. - The 16 lines that its first daemon wrote before that start were lost.
- The later lines of the first daemon were kept, behind a block of NUL bytes.
- After both daemons ended, the file held 12 lines.
- claustrum side, measured. In the same rows the file kept the 16 lines of the first daemon. The later lines of both daemons followed in time order. After both daemons ended, the file held 28 lines.
- Equal in those rows. The launcher of the second daemon returned at once with
empty stdout and stderr. Both daemons and the three children stayed alive.
-stopended the second daemon only. - Default. Always-on, Windows only. Activate: always-on. There is no flag
and no key. On Linux a second daemon evicts the first, and the log rotates to
.oldon both sides (rows LOCK-0, LOCK-2, ST03). - Why always-on. The maintainer's decision of 2026-10-02: claustrum keeps the
lines. No frame, no reply and no exit status differs from
89cb6289in the measured rows. The difference is in the log file only. No clause of rule 3 covers this entry. It stands on that decision, and the reopen trigger below takes it back. - Cost. A reader of the log file sees the difference: it gets the kept lines
from claustrum. The file holds the lines of two daemons with no mark of which
daemon wrote a line. It does not start with the start lines of the second
daemon, as the file of
89cb6289does. - Not measured. A first daemon of another build beside a second daemon of this one. An earlier claustrum build does not write in append mode.
- Reopen trigger. A measurement that shows the reference keeping the earlier lines of the first daemon (then this becomes parity). Or a reader of the log that needs the file to start with the lines of the second daemon.
- Pointers. PROTOCOL.md → Daemon log. Also
server.go(openDaemonLog) anddetach_windows.go(openHeldDaemonLog). Evidence inscratch/89cb6289/e-windows/REPORT-val4.md, section 3.3.
D22 · Refuse a worktree locked in the .git folder of baseRepo on remove (always-on)¶
- Behavior.
git.worktree_removerefuses a worktree that is locked in the.gitfolder ofbaseRepo, and deletes nothing. Since7d193f89the reference refuses it too, with the same text. In four states89cb6289answers success for a worktree S that is locked in T, the.gitfolder ofbaseRepo. claustrum refuses in each of them. On Linux and macOS a gone worktree whose locked entry sits behind a linked<git dir>/worktreesgets success, and its branch goes. That follows5fd08069(cell L1 of roundtier0-26, Linux VM), by the maintainer's decision of 2026-10-14. - Reference side, measured. 3 of 3 runs on each system named:
- Row p6 (Linux, macOS, Windows). The daemon has
GIT_DIRandGIT_COMMON_DIRof another repository X.89cb6289answers{"success":true}and deletes the folder of S. The entry of T with itslockedfile stays. - Row p6e (Linux, macOS, Windows). The daemon has
GIT_DIRof X alone.89cb6289answers{"success":true}and deletes the folder of S. - Row p6d (Linux, macOS).
baseRepois<T>/missing/..with no foldermissing, with git onPATH.89cb6289answers{"success":true,"branchKept":true}and deletes the folder of S. - Row p6f (Linux, macOS, Windows). The environment of row p6, and the folder of
S is gone.
89cb6289answers{"success":true}and deletes nothing. - claustrum side, measured. In rows p6, p6e and p6d claustrum answers the
locked refusal of PROTOCOL.md and deletes nothing. In row p6f it
answers the "is gone but its registration is locked" refusal. Those are the
rows of build
87f2326on the same VMs. claustrum at2dc9d85refused in rows p6, p6d and p6f too, and deleted the folder in row p6e. - With
worktreeRoot, measured. On a Linux VM, 3 runs each, a worktree made by the daemon beneath a root and locked in T. Row q2 has a daemonGIT_DIRof X alone, and row q3 hasGIT_DIRandGIT_COMMON_DIRof X. In both89cb6289answers the transientcould not verifytext of PROTOCOL.md and deletes nothing. Row q4 is row q2 with the folder gone:89cb6289answers{"success":true}and deletes nothing. claustrum buildafd4ff0answers the locked refusals in rows q2, q3 and q4 and deletes nothing. The reference deletes nothing there either. The end state is the same on both sides, and the frame and the git calls differ. - Equal in the rows beside them. Row p6b puts the lock on an entry of X whose
record names S: both sides refuse. Rows p6c (Linux, macOS and Windows VMs) and
p6g (Linux VM) have no daemon environment: both sides refuse. Rows q1, q5 and q5b
(Linux VM) have no daemon environment either. Row q1 is equal: a lock with a
worktreeRoot. Rows q5 and q5b are equal in frame and end state: aT/.git/worktreesfolder of mode 000, with and withoutworktreeRoot. - How. If the removal reads its entries from another git directory,
claustrum also reads the entries under
<baseRepo>/.git/worktrees, with no git call, with and withoutworktreeRoot. If that folder exists and cannot be read, claustrum answers the lock-check refusal and deletes nothing. A folder that does not exist counts as not locked. - Default. Always-on, on every system. Activate: always-on. There is no flag and no key.
- Why always-on. The maintainer's decision of 2026-10-03: claustrum refuses a
worktree that is locked in the
.gitfolder ofbaseRepo. A lock is the user's own instruction not to delete, and a deleted folder cannot be taken back. No clause of rule 3 covers this entry. Rule 4 says that a change to a frame on an honest path is opt-in or does not ship. The maintainer decided on 2026-10-03 to accept this frame difference. Each of the four states needs a state that the request does not create: aGIT_DIRin the daemon's environment, abaseRepospelled through a missing name, or a folder removed by hand. No measurement shows a client that reaches one of them. The entry stands on that decision, and the reopen trigger below takes it back. - Cost. In the four states, and in rows q2 to q4, the frame differs from
89cb6289: claustrum sendssuccess:falsewith a locked text. In rows p6, p6e and p6d the folder stays. A caller there must unlock the worktree first. - Not measured. The reference on Windows in row p6d: Windows refuses that
baseRepoon both sides. The reference withGIT_DIRof X,GIT_COMMON_DIRof a third repository Y and a lock in T. The reference with a daemon environment and a<baseRepo>/.git/worktreesfolder that cannot be read: claustrum answers the lock-check refusal there. - Not covered. A
baseRepowith no.gitfolder of its own: a subfolder of T, a linked worktree or a submodule. With a daemonGIT_DIRof another repository, claustrum does not read the lock in the real repository there. It deletes the locked worktree, as the reference does in row p6. The maintainer decided on 2026-10-03 to keep that, and the reference is not measured there. - Reopen trigger. A caller that needs the removal of a locked worktree in these states. Or a measurement that shows the reference refusing there (then this becomes parity).
- Pointers. PROTOCOL.md →
git.worktree_remove. Alsoworktreeremove.go(lockedInBaseRepo) andmethods_git.go. Evidence inscratch/i429/s2-linux/REPORT-rev1.md, and inREPORT-val3.mdunderscratch/i429/s2-linux,s2-macosands2-windows, and inscratch/i429/s2-linux/REPORT-val4.md.
D23 · The environment block of a child is in name order (Windows)¶
On claustrum the Windows environment block of a child is in name order. The reference does not sort it. Go sorts the block since 1.26. The reference uses go1.25.14.
Windows VM, 2026-10-13: a claustrum build with go1.25.14 matched 5fd08069 in
the order. Counts: 18 of 18 process.spawn blocks with no env, 487 of 487
git children in the order of names, 18 of 18 at -install. The Go 1.26.9
build sorted every cell.
claustrum stays on Go 1.26 (maintainer's decision, 2026-10-13). Go 1.26.9 fixed standard library advisories of go1.25.14, one in the folder handles of the Windows file guards (GO-2026-6604).
Reopen trigger: the reference moves to Go 1.26 or newer.
D24 · Keep a folder that replaced the new registration in the create rollback (Linux and macOS)¶
- Behavior. On Linux and macOS
git.worktree_createtests the registration of the new worktree right after the add. When the tests accept it, claustrum takes the identity of the registration folder. A rollback deletes the folder at that path only if it still has that identity. After a failedrev-parse --show-toplevelthe rollback deletes the one new entry of the listing after the add, with its identity from that listing. In cell P2 of roundtier0-13(Linux VM)5fd08069deletes a sibling registration that was moved to that name during the call, and claustrum keeps it. The test runs on the registrations directory that the delete opened, so the test and the delete use one registrations directory. claustrum holds the accepted folder open until the create answers. While that handle is open, a new folder does not get that identity. If the open fails, the identity comes from a stat of the path (not measured). The rollback deletes nothing if the folder at the path is not the accepted one at that test. It adds no text to the frame, and the branch step runs. The placement of the index has the same test. claustrum places nothing if the folder at the path is not the accepted one at that test. It then answers a failed checkout with its own text,the registration <entry> is not the folder that was tested after the add. - Reference side, measured. A Linux VM with git 2.43 ran
89cb6289on 2026-10-07, 2 runs per cell. A macOS VM with git 2.50 ran it too, 2 runs per cell, with the same disk. A git wrapper changes the registrations folder ofbaseRepoand then fails the read-tree. - Cell B6. The wrapper renames the new registration
w1tow1xand makes a new empty folderw1.89cb6289removes the emptyw1and keepsw1x. - Cell B6b. The wrapper renames
w1tow1x, and the registrationw9of a live sibling worktree tow1.89cb6289removes thatw1with the files of the sibling, and keepsw1x. - claustrum side, measured. claustrum removes no folder of the
registrations directory in both cells (Linux and macOS VMs, 2 runs per cell).
A Linux VM round also ran main
8af9c03, an earlier build without this guard. It kept both folders in both cells too, because its rollback then verified the registration through the.gitfile of the leaf. - Equal in those cells (Linux and macOS VMs). The frame is the plain failed checkout on both
sides. The leaf and branch
w1are gone on both sides. In cell B6b the folder of the sibling worktree and its branch stay on both sides. - Default. Always-on, Linux and macOS. Activate: always-on. There is no flag and no key. On Windows no registration test runs after the add, so this guard does not run there. Windows is not measured.
- Why always-on. The maintainer's decision of 2026-10-07: the guard stays. The harm that it refuses is the loss of the registration of another worktree, as in cell B6b. No clause of rule 3 covers this entry. It stands on that decision, and the reopen trigger below takes it back.
- Cost. In the measured cells a folder stays that
89cb6289removes: the emptyw1of cell B6, and the files of the sibling under the namew1in cell B6b. No frame differs there. A create reaches the guard only if the registrations folder changes between the add and the rollback. - Not measured. The guard at the placement of the index: no cell has a
replaced folder with a read-tree that exits 0. A replaced folder with a
daemon
GIT_DIRof another repository is not measured either. Cells B9 and B9b have that environment with no replaced folder: the registration that git made in that repository is gone after the rollback on89cb6289(Linux and macOS VMs). If git gave no answer torev-parse --absolute-git-dir, the guard does not run. - Reopen trigger. A measurement that shows the reference keeping such a folder. Or a decision to match the disk of cells B6 and B6b.
- Pointers. Evidence in
scratch/i429/s37-linux-rollback-probe/REPORT.mdand inraw/cells/B6.mdandraw/cells/B6b.mdbeside it. The macOS round is inscratch/i429/s40-macos-rollback-val/REPORT.md. The rounds of the final build are inscratch/i429/s44-linux-rollback-last/REPORT.mdandscratch/i429/s45-macos-rollback-last/REPORT.md.
D25 · Answer "unavailable" for the peer check and serve every caller (Linux)¶
- Behavior. claustrum has no peer check. A daemon that starts with
CLAUDE_SSH_PEER_CHECK=1in its environment answers"peerCheck":"unavailable"inserver.capabilities, with nopeerCheckBymember. It writes one line before the listening line, at WARN level, and it serves every caller. That holds on Linux, macOS and Windows. The divergence is on Linux only. - Reference side, measured. A Linux VM ran
5fd08069withCLAUDE_SSH_PEER_CHECK=1in the daemon environment.server.capabilitiesanswers"peerCheck":"on","peerCheckBy":["network","pidfd"]. Before the listening line the log holds one more line:[Server] serving only callers in "user:[4026531837] net:1 pidfd"(the number is the one of that VM). A client of the same user on that VM got its answers. - claustrum side, measured. On the Linux VM the frame ends with
"peerCheck":"unavailable", and the log holds the line[Server] CLAUDE_SSH_PEER_CHECK is set: this build has no peer check on Linux, serving all callersafter the level tag. - Equal. With no variable and with the value
0,5fd08069answers"peerCheck":"off"(Linux, macOS and Windows VMs). claustrum answers the same on those VMs. With the value1,5fd08069answers"peerCheck":"unavailable"on the macOS VM and on the Windows VM, and claustrum answers the same there. The text of its log line after the level tag equals the line of5fd08069there.89cb6289has nopeerCheckmember. The other measured values are in PROTOCOL.md → server.*. - Default. Always-on, Linux only, and only with
CLAUDE_SSH_PEER_CHECK=1in the daemon environment. Activate: always-on. There is no flag and no key. - Why always-on. The maintainer's decision of 2026-10-10: the difference stays. No clause of rule 3 covers this entry. Rule 4 says that a change to a frame on an honest path is opt-in or does not ship. The maintainer decided on 2026-10-10 to accept this frame difference. The entry stands on that decision, and the reopen trigger below takes it back.
- Cost. A client that reads
"unavailable"on Linux learns that no check runs. An operator who sets the variable on Linux gets a daemon that serves every caller, where5fd08069answers"on". - Not measured. Whether
5fd08069serves each caller on Linux with the value1. Each measured caller ran as the same user on the same VM. Which client sets the variable is not measured either. - Reopen trigger. A client that needs the
"on"answer on Linux. Or a decision to answer as5fd08069does there. - Pointers. PROTOCOL.md → server.* and Daemon log.
CT-1 · Opt-in wantPid (pid + startTime) on spawn/reattach¶
process.spawn/process.reattachaccept an optional"wantPid":true. The reply then gainspid(the child's OS pid) andstartTime. This is the first wire-surface extension.- The default path is byte-identical. When
wantPidis absent or false,omitemptyomits both fields, and the frame is exactly the old{"success":true}/{found,running,firstSeq,lastSeq,stdinApplied}. The fields live on a dedicatedspawnResultstruct, so they can never leak into thesuccessResultthatprocess.stdin/process.killshare. startTimeis an opaque daemon token. It is the daemon's epoch-seconds wall clock captured at spawn, and the daemon returns it identically on spawn and reattach for the same id. Use it to detect PID reuse and orphans. It is not OS-comparable: do not equality-check it against psutilcreate_time.- The extension is tolerant in both directions: an older daemon ignores the param,
and an older client never sees the fields. A client can therefore send
wantPidunconditionally. The sibling clauster client pins the contract. - Pointers. PROTOCOL.md (
process.spawn), the page process.reattach, andresults.go.
CT-2 · Opt-in -keep-children serve flag¶
- A
-serveflag, off the wire: it changes no method, no frame and no capability. At the default (off), graceful shutdown kills the children: the whole tree on Linux and macOS, the direct child on Windows. When set, it leaves spawned children running so they survive a daemon restart/upgrade, and it logs one line with the surviving count. The new daemon does not re-adopt them. An out-of-band consumer reconciles them through the CT-1pid/startTime. - A child whose start returns after the shutdown began is ended also with the flag
(from the code,
TestLateStartEndedWithKeepChildren). Its client got no reply, so no client has itspid. - Caveat: survivors lose their stdio. The daemon-side pipe ends die with the daemon: the child sees EOF on stdin, and a later write gets SIGPIPE/EPIPE. Therefore only children that tolerate dead stdio genuinely survive. Not measured on Windows.
- The records of the kept children. On Linux and macOS a child has a record in
<runDir>/children. The reap of the next daemon start ends each recorded child of a daemon that is gone. With the flag, the graceful shutdown removes the records of the children that it leaves alive. The next daemon then finds no record of them and sends them no signal. This rule is claustrum's own, and the reference has no such flag. A Linux VM ran the flag on claustrum (row P14, two children, one of them ignoresSIGTERM).-stopsent no kill and removed both records. Both children were alive after it, with parent pid 1. A new daemon then sent no signal and logged no reap line, with the flag and without it. - Two limits of that rule. It covers the processes of the process table only. A
process whose
ida later spawn took keeps its record, so the next start ends it. The host cleaner does not read the records. By its own rule it ends an orphaned Claude Code group under the install root, a kept one too. That is not measured with the flag. - Windows. The flag works there too. A child is in no Job Object, so the exit
of the daemon does not end it. On a Windows VM
(rows WN03, WJ02, WJ05, WJ06), the children of
f6010b97and89cb6289survive a killed daemon. With the flag,-stopandserver.shutdownleave the direct child alive too. On that VM the whole tree of claustrum was alive at 1 s, 5 s and 15 s after both. The reference has no such flag:89cb6289exits with code 2 for it. - Activate:
-keep-childrenor thekeep-childrenkey. - Pointers. PROTOCOL.md (
-serveflags).
CT-3 · Opt-in claustrum.conf config file¶
- A single place to turn deviations on. Absent ⇒ stock. This is an optional
key = valuefile, read from the directory that holds the binary. If the file is missing, unreadable, non-regular, or malformed, claustrum behaves as a stock replica. Every key gates an already-opt-in divergence. The file adds zero new dependency (stdlibbufio+strings.Cut,#comments). claustrum ignores unknown keys and invalid values, which keeps the format forward-compatible and fail-safe. Precedence: explicit CLI flag > config > default. - Keys mirror the flags:
version-override,keep-children,metrics-addr,wire-log,wire-log-max-string,listen-pipe,max-extract-bytes(D3),max-cli-bytes(D10),cli-download-timeout(D12),git-timeout(D5). Three more keys are deprecated and set nothing:files-read-regular-only(retired D4),cli-probe-timeout(retired D11) andlibc-probe-timeout(retired D14). Durations usetime.ParseDuration, which rejects a bare number, except zero. Zero parses in unboundedly many spellings and always means disabled. No accepted oddity can switch a divergence on. version-overridemakes claustrum a permanent drop-in. The desktop client decides whether to re-upload the daemon: it runs<bin> --versionon the cached~/.claude/remote/srv/<pinned-sha>/serverand matches the output against the SHA it pins. Stock claustrum prints its own version, so the client re-SFTPs the reference every session. Set the key to that bare commit SHA. A 40-hex git SHA-1 is accepted, and a 64-hex value is accepted too. Anything else is a no-op. claustrum then printsclaude-ssh <sha> (via Claustrum …), the client hits the cache, and it stops overwriting.- Off the wire, off by default.
-versionis CLI stdout, not a JSON-RPC frame.server.capabilitiesstill reports claustrum's own version (server.versionwas removed in7d193f89). - Fail-safe and hardened: regular-file-only via
Lstat,io.LimitReader≤ 64 KiB, per-key validation (version-overridegated to^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$and lower-cased). claustrum uses every value as data, never as a format string. Any doubt → stock. Startup never fails. - Pointers. PROTOCOL.md (
-version), andconfig.go.
CT-4 · Opt-in hardened token persistence — deferred idea¶
- Context. The daemon persists its token to
daemon.token(0600) beside the socket, so a client can reconnect. That is parity, and it is on by default (tokenpersist.go). There are two accepted parity caveats. The file survives an unclean kill or crash, because cleanup runs only on graceful shutdown. And on Windows,0600is not an owner-only DACL. - Idea (not built). A
claustrum.confkey, for examplepersist-token = false, or a Windows owner-only DACL on the file, or both. These are for operators who prefer a smaller on-disk token window over drop-in reconnect. Must stay absent ⇒ stock. - Why deferred. There is no demand. The default matches the reference, and the socket directory is already owner-scoped in the real deployment. We record the idea so the security trade-off is not lost.
- Pointers. PROTOCOL.md → Token persistence, and
tokenpersist.go.
CT-5 · Opt-in -listen-pipe Windows named-pipe transport¶
- Shipped.
-listen-pipemakes-serveadditionally serve the exact same NDJSON JSON-RPC dispatch over a Windows named pipe, concurrently with theAF_UNIXsocket. Off ⇒ stock. The wire contract, the field ordering and the framing stay the same whether a request arrives over the socket or over the pipe. - Why. Without the pipe, a Windows client that cannot consume an
AF_UNIXsocket cannot attach. Pythonasynciois the notable example: its Windows Proactor loop natively supports named pipes. This was clauster's ask. - Discovery + auth. claustrum picks the pipe name
(
\\.\pipe\claustrum-<instance-id>, client-opaque) and publishes it torpc.pipebeside the socket (atomic write before accepting, and claustrum removes it on graceful shutdown). Same in-band"auth"+daemon.tokenhandshake. Owner-only DACL (SDDLD:P(A;;GA;;;<current-user-SID>)), local-only, no new authenticated surface (SECURITY.md). - Windows-only: elsewhere claustrum ignores the flag and prints a warning
(
honorListenPipe). A setup failure is non-fatal: the socket still serves. - Activate:
-listen-pipeor thelisten-pipekey. - Pointers. PROTOCOL.md (
-serveflags). Alsopipetransport.goandpipetransport_windows.go.
Retired entries¶
A retired entry is a divergence that a later reference build, or a later measurement of the reference, made moot. Its number is not reused, and its old link points here.
D1 · Verify the -cli-zst blob against a supplied checksum (retired)¶
- Since
7d193f89the reference checks a supplied-cli-checksumagainst the-cli-zstblob.5db5e4aignored it. The compare is case-sensitive, so the right digest in upper case fails. - On a mismatch the reference answers
checksum mismatchand keeps the blob. It creates the cli-dir before it opens the blob. So a failed attempt past the version check leaves the cli-dir, empty if it was new. The cli-dir half is measured on a Linux VM from5db5e4a, the mismatch from7d193f89. claustrum now does all of this, unit-tested. - The
-cli-urlpath follows the same rules onf6010b97. The compare is case-sensitive, and the cli-dir comes before any network access. - One delta stays, and D13 owns it: claustrum hashes before it decompresses.
- Desktop's one captured
-cli-zstcall (2026-08-10) supplied no-cli-checksum, so no check ran on that path. It is one instance of one failure shape.
D4 · files.read refuses a file that is not regular (retired)¶
- D4 made the refusal of a path that is not a regular file opt-in and off by
default. Its premise was that the reference does not refuse such a path.
89cb6289does not refuse one. 5fd08069refuses such a path with-32602 files.read: not a regular file. Its answers put the open before the kind test: a directory that it cannot open gets theopentext, and a writer that waits in its open of a FIFO returns at the request. On Linux and macOS it reads the null device itself as empty content. Measured on Linux, macOS and Windows VMs.- claustrum now does the same, with no switch. The FIFO rows and the null
device rows are tested on Linux and macOS.
NULand a named pipe are tested on Windows. - The two costs of the old default are gone for the measured kinds. A FIFO with
no writer gets the refusal at once.
/dev/zerogets the refusal, so the daemon does not grow. - The
-files-read-regular-onlyflag and itsclaustrum.confkey are deprecated. Both are still accepted, both set nothing, and either one logs one warning on-serve. - No knob stays to read such a path. A read of one is a new divergence, and rule 2 puts the burden of proof on it.
D7 · -cli-version must not collide with the install temp sweep (retired)¶
- D7 refused a
-cli-versionsuch as.fetch-xor1.0.zst. Its premise was that the sweep deletes such a version in the run that installed it. That held up to7d193f89. - Since
4534d86the sweep removes an entry only when its mtime is about 10 minutes old or more. The reference installs such a version and keeps it. A later install removes it once it is that old. A cache hit does not. - claustrum now does the same. Measured on a Linux VM against
4534d86throughf6010b97. D6 still applies to every version.
D11 · Deadline on the <cli> --version run of -install (retired)¶
- D11 made the deadline on the direct
<cli> --versionrun opt-in and off by default. Its premise was that the reference has no deadline at or below 90 s. That premise is false. - The reference stops a present CLI that has not answered after 30 s. It stops
a CLI that it installed in the same run after 120 s. Measured on
f6010b97and89cb6289, on Linux, macOS and Windows. A present CLI that answers at 28 s passes, and a new CLI that answers at 118 s passes. On Linux and Windows a present CLI that answers at 31 s is stopped, and a new CLI that answers at 121 s is stopped. Each stop came at 30.0 s or at 120.0 s. - The two older numbers of this entry fit those bounds. A CLI that answers at 20 s installs, and a CLI that answers at 90 s installs after 91 s. Both are under 120 s, the bound of a new CLI.
- The outcome at the bound is not one of the three that this entry named. The
reference answers a
cli unresponsive: …cliErrorwith"cliUnresponsive":true. On a cache hit it installs nothing. After an install it keeps the new CLI. PROTOCOL.md →-installholds the rows. - claustrum now does the same, always. The bounds are unit-tested with shrunk values.
- The
-cli-probe-timeoutflag and itsclaustrum.confkey are deprecated. Both are still accepted, both set nothing, and either one logs one warning on-install. - Not measured: which reference build first had the two bounds.
f6010b97has them. - No knob stays to change the bounds. A different bound is a new divergence, and rule 2 puts the burden of proof on it.
D14 · Deadline on the ldd --version libc probe (retired)¶
- Since
19f30c46the reference bounds thelddprocess at 5 s. Iflddstill runs then, it kills the whole process group oflddand drops any output. The loader glob then decides. No log line is written. The dropped-output row is measured onf6010b97and90fca6e6. - If
lddexits in time, the reference waits up to about 2 s for its output pipe, then uses the output. Measured onf6010b97for exits up to 4.5 s. - claustrum now does the same, always. The late-exit case is unit-tested.
- The
-libc-probe-timeoutflag and itsclaustrum.confkey are deprecated. Both are still accepted, both set nothing, and either one logs one warning on-install. - No knob stays to change the bound. A different bound is a new divergence, and rule 2 puts the burden of proof on it. No measurement gives that proof today.
Candidates considered but not taken¶
In each of these claustrum can be friendlier than the reference, and matching the reference costs something real. We record them so the code can point somewhere durable. No decision is implied, and nothing here is shipped or scheduled.
- Conditional
-stopsocket unlink. After a failed connect,-stopremoves the socket path, unless it printssurvivor. That matches the reference (measured againstf6010b97and90fca6e6on a Linux VM).os.Removedoes not check the shape of the path. A regular file or an empty directory at the-socketpath is removed too. Astat-first variant that removes only a socket keeps those two shapes, and it is a divergence. It does not change the socket case. - Fail fast on a missing
-servetoken source. The check runs in the detached child, so the launcher reports its accept timeout, and the real reason reaches only the child's log. That is reference parity (measured 10.02 s vs 10.07 s on5db5e4a). With a zero-byte-token-file, the launcher of89cb6289exited after 12.06 s (Linux row P7, also measured on macOS and Windows), and the bound of claustrum is 12 s now. A parent-side check answered in 0.03 s and named the actual problem: a better operator experience, and a divergence.
Explicitly out of scope (would break compatibility)¶
- Changing method names, params, result field order, error codes, or the stream-frame shape.
- Replacing the in-band
"auth"scheme. - Adding required new params to existing methods. The sanctioned exception is an optional, gracefully-ignored param whose result fields vanish by default. That is the CT-1 pattern. It leaves the default frame byte-identical, and it degrades both ways.
Any of these needs a deliberate, documented protocol version bump.