Stop --help from running verbs, report what excludes drop, and add a real restore proof - #22
Stop --help from running verbs, report what excludes drop, and add a real restore proof#22andrei-hasna wants to merge 13 commits into
Conversation
Adversarial review of PR #22 — VERDICT: REJECT (do not merge as-is)Reviewed at The PR's core thesis reproduces and holds. The headline defect is real, the fixes bind, and Merge order — already settled, not by mePR #21 merged at 2026-07-27 22:43:50 UTC (2026-07-28 01:43:50 EEST) as BLOCKER 1 — "textual conflicts, no semantic overlap" is refuted; a naive resolution silently drops #21's guardThis is exactly the failure the merge-order warning anticipated, and it is now measured, not
The single conflict in The rebase is worse than reported. Commit 2 (
I am not authoring that resolution and merging it: it sits in the exact code path that lost 68 BLOCKER 2 — a repeated flag is silently collapsed, and it archives a secret in plaintextSame defect class the PR closes ("an argument list this function cannot fully account for does not
DEFECT 3 —
|
402a3fb to
0266051
Compare
`backup run --help` performed a REAL backup: --help was parsed into an unused flag and the `run` case ran anyway. It happened three times on 2026-07-27 (bkp_20260727104035_3xihad, bkp_20260727104414_l8kxir, bkp_20260727104643_06r19b), and because retention keeps one backup per calendar day per source, each accidental run evicted the earlier same-day archives from the daily slot — asking for help could destroy a restore-proven backup. The same root cause made every unknown flag a silent no-op. `run --dryrun`, one character from the safety flag, performed a live backup with dryRun: false. `sources add <path> --excludes node_modules` (plural) was accepted and stored the default exclude list including `.git`. And a flag whose value went missing read as absent, so `run --home --dry-run` resolved $HOME/.hasna/backup — the live installation, the same shape as the incident that destroyed 139 artifacts. Help is now resolved before dispatch and never executes. Argument validation runs before dispatch too and fails closed: unknown flags, missing or empty flag values, unexpected positionals, and single-dash arguments all refuse with exit 1 and name the accepted flag. Every verb and subcommand has help, and `doctor` derives its command list from the same spec instead of a hand-maintained copy that had already drifted (it omitted status, manifest, and holds). The surface is declared as data in src/cli/spec.ts so help text and validation cannot disagree with each other. Refs todos cc893efa (defects 1 and 4).
`sources add` attached twelve default excludes without saying what they cost. On 2026-07-27 that turned a 155-file capture of a repository into 87 restorable files — 68 missing, all under a staged `dist/`, 0 extra and 0 corrupt — while `backup verify` reported ok, because verify only compares the archive against its own recorded checksum and never opens it. The workaround at the time was renaming the staged directory, which is not a fix. For repository preservation, `.git` and `dist` are exactly the content you must keep. Three changes, none of which relies on remembering anything: - `sources add` measures the tree and reports each pattern with the file and byte count it drops, plus examples, on stderr and in the result, together with a `source-excludes-drop-content` finding. The counts carry `estimate: true`: they use the inventory's matcher rather than GNU tar's globber, so the authoritative measurement stays `verify --deep`. - `--profile preservation` attaches NO excludes. Combining it with `--exclude` is refused rather than resolved. It also drops the secrets patterns, and these archives are unencrypted, so the CLI says that out loud when it is used. - `plan` and `run` report a `preflight`: files and bytes to archive, files and bytes excluded, which patterns drop them, and per-source detail. `plan` is documented as the pre-flight and previously reported the excluded files nowhere at all. An inventory-only source reports no drop — it writes no archive for anything to be dropped from. Five assertions in tests/backup.test.ts read the full `addSource` warning list exactly; they now filter the new code so each stays about the git classification it was written to pin. Refs todos cc893efa (defect 2).
`backup verify` compares each archive at the destination against the sha256 recorded when it was written, and never opens it. That catches media rot and a truncated upload. It cannot catch content that was never archived — and on 2026-07-27 it returned ok for a backup of a repository missing 68 files, because the archive was a byte-perfect copy of an incomplete capture. This is vacuous verification in its purest form: a check that passes while measuring nothing it is read as measuring, whose output wording invites the wrong conclusion. Two halves. Say what it measured. VerifyResult now carries a REQUIRED `proof` field (`checksum-only`), plus `proves` and `doesNotProve` lists that ship in the payload rather than living in a doc nobody reads at the moment of the decision. The per-check message changed from "checksum matched" to "recorded checksum matched (archive contents NOT inspected)". The CLI prints the boundary on stderr. A checksum-only proof-bundle contract carries the limitation as a residualRisks entry EVEN WHEN IT PASSES — the contract is literally called a proof bundle and a green one was being filed as evidence of restorability. Then measure it. `verify --deep` does what every real proof in that wave did by hand: materialize each archive, re-check its checksum, extract it to a scratch tree, build a sha256 manifest over every restored file, diff it against the live source tree, and where the restored tree carries a git object database clone from it — the step that catches a shallow or truncated repository, which `git bundle verify` does not — comparing ref SHAs and tree hashes against `git ls-remote`. Non-zero exit on any missing or corrupt file. And stay honest about the gap. When no source tree is available — the normal state of a pre-deletion archive, and exactly when the proof matters most — it reports `completeness: "not-verified"` and names the archives in `doesNotProve` rather than returning a green result that reads as a completeness proof. `--require-source-diff` makes that a failure for callers needing the stronger claim. A caller-supplied `--target` is never removed; only a scratch directory the verifier created under the backup home's tmp. Deep verification lives in a new src/verification.ts, and the claim strings in src/lib/proof-claims.ts so both paths can state their own boundary without an import cycle. New MCP tool backup_verify_deep, grouped with inspection. Refs todos cc893efa (defect 3).
…ext drift The spec introduced here is a CLOSED ALLOWLIST that fails closed, so a flag a handler reads but the spec does not declare exits 1. PR #21 landed `sources add --include-git-history` and `policy set --preserve-markers` on main while this branch was open and the spec declared neither, so rebasing without this commit would have made both exit 1 — silently removing the entire configuration surface of #21's prune preservation guard. The `printHelp()` conflict is the same hazard in the other direction: #21 extended that blob with both flags and this branch deletes it wholesale in favour of spec-derived help, so taking this branch's side is what drops them. Their help prose moves into the spec's notes rather than being lost. - Declare `--include-git-history` on `sources add` and `--preserve-markers` on `policy set`, carrying #21's explanatory text into the spec notes. - `--preserve-markers=` (inline, empty) is #21's documented way to clear the per-installation markers, which this branch's empty-value refusal broke. Add `FlagSpec.emptyValueMeansNone` so the exception is declared on the flag rather than tested by name in the validator. It permits the exactly-empty string only: a bare `--preserve-markers` is still "missing value" and a whitespace-only value is still refused, so neither can read as an opt-in. - New tests/cli-spec-drift.test.ts generalises the reviewer's by-hand verb x flag sentinel probe into a static scan: every flag any handler reads must be declared, or the test names it. Includes an anti-vacuity test, since a scanner matching nothing would be green for the wrong reason. Mutation-proven: planting 402a3fb's spec state (both declarations deleted, needle counts asserted 1 -> 0 for each) turns the new suite red at 1 pass / 3 fail; the anti-vacuity test correctly stays green because it does not depend on the spec. `--profile default-with-git-history` is refused with a message naming the spelling that works, and the profile-refusal test now iterates the SELECTABLE names rather than every key of SOURCE_EXCLUDE_PROFILES, which now also holds the derived label.
`parseArgs` wrote flags into a flat Record, so a repeated flag silently
collapsed to its last occurrence. Measured before this change:
sources add <src> --exclude '.env' --exclude '*.pem' rc=0
stderr: excludes: profile=explicit patterns=*.pem
stored: excludes = ['*.pem'] <- '.env' discarded, no warning
run rc=0
tar tzf <archive>: src/ src/.env src/app.js <- the .env is archived
That is the defect class this branch exists to close — an argument list the CLI
cannot fully account for must not execute — left open, with a credentials
consequence, since these archives are not encrypted.
`ParsedArgs.flags` now records EVERY occurrence, and the spec decides what a
repeat means:
- `FlagSpec.repeatable` accumulates. On for the four comma-separated list flags
only: `sources add --exclude`, `policy set --preserve-markers`,
`verify --compare-source`, `aws --regions`. `--exclude a --exclude b` and
`--exclude a,b` now resolve identically.
- Every other flag REFUSES a repeat, naming each value. There is no honest
resolution for `--home /a --home /b`, and quietly taking the last one is the
"the home was not the one you named" shape that destroyed 139 live artifacts.
`listFlag` also throws on a valueless occurrence rather than letting it filter
down to `[]`, which would be indistinguishable from the deliberate
`--preserve-markers=` clear. `validateArguments` already refuses that, so this is
defence in depth against a mistake becoming an instruction.
Audited by measurement, not by reading: every declared flag on every verb and
subcommand was probed with a repeat. All refuse except the four accumulators.
The one exception is the `help` VERB, which resolves before validation by design
because it executes nothing.
tests/cli-repeated-flags.test.ts asserts the archive LISTING, not just the stored
config — the bug was that the tool reported confidently while content went
elsewhere, so checking the report would be weaker than the bug. The exhaustive
audit is driven off the spec, so a flag added later is covered without editing
the test, and the repeatable set is pinned exhaustively so marking a non-list
flag repeatable is a reviewed change.
Mutation-proven: planting the flat-Record behaviour (accumulate needle 1 -> 0,
repeat refusal guarded off) turns the suite red at 1 pass / 6 fail. The one green
is the pure spec assertion, which does not depend on the parser.
Suite: bun test --timeout 60000 rc=0, 152 pass / 0 fail / 13 files / 1304
expect(). typecheck rc=0.
`hashManifest` walked with `withFileTypes` and did `if (!entry.isFile()) continue`,
so symlinks and empty directories were hashed on NEITHER side and could not
appear as missing, extra, or corrupt. Measured on a tree of one regular file plus
two symlinks, with one symlink excluded from the archive:
sources add ... --exclude 'danger-link' stderr: "DROPPING 1 file(s), 13 bytes"
verify latest --deep rc=0 ok=true
completeness=verified-against-source missing=0 extra=0 corrupt=0
The pre-flight estimate and the deep measurement contradicted each other on one
tree, and the half this branch designates AUTHORITATIVE over the estimate was the
wrong one. That is the most damaging form the gap could take, because being the
authority is the entire point of `--deep`.
After: rc=1, ok=false, missing=1, `diff.missing = ["danger-link"]`. Same for an
excluded empty directory: `diff.missing = ["dropme"]`.
- A symlink is fingerprinted by its TARGET STRING, not the content it resolves
to. Following it would make a symlink and a regular file of identical content
compare equal — a real difference a restore must reproduce — and would let the
walk escape the tree or loop. Checked before `isDirectory()`, so a symlink to a
directory is recorded rather than descended into.
- Only EMPTY directories are recorded. A non-empty directory is already implied by
the entries beneath it; an empty one is implied by nothing. The root itself is
never recorded, since `relative(root, root)` is not a path either side can miss.
- Sockets, FIFOs, and device nodes are still skipped: tar does not carry their
content, so recording them would report a difference no restore could resolve.
- `totalBytes` skips symlinks and empty dirs. `statSync` FOLLOWS a link, so
counting one would double-count its target's bytes or invent bytes for a
dangling link.
- MANIFEST_COMMAND now covers `-type l` and `-type d -empty`. It ships in the
result so a human can redo the proof by hand; publishing a command narrower
than the measurement would restate the fixed defect as documentation.
Mutation-proven: planting the `isFile()`-only walk (needles `entry.isSymbolicLink()`
1 -> 0 and the empty-dir set 1 -> 0) turns the new suite red at 3 pass / 7 fail.
The three greens are the root-never-recorded, MANIFEST_COMMAND, and restoredBytes
assertions, none of which depends on the two recording paths.
The suite also pins the no-false-alarm direction: a faithful `--profile
preservation` archive of a tree containing a symlink and an empty directory still
passes with restoredFiles=3. A check that fails on a good archive gets ignored as
fast as one that always passes.
Suite: bun test --timeout 60000 rc=0, 162 pass / 0 fail / 14 files / 1329 expect().
typecheck rc=0.
`proveGit` compared refnames literally. A working clone files an upstream branch
under `refs/remotes/origin/<X>` while `git ls-remote` reports `refs/heads/<X>`, so
the same branch was counted twice in opposite directions. Measured on a clone of
this repository before the fix:
verify latest --deep --git-remote hasna/backup rc=1 ok=false
1/32 matched, 31 missing from the restored copy,
9 present locally but not on the remote
refs/heads/fix/cc893efa-... reported MISSING
refs/remotes/origin/fix/cc893efa-... present at the IDENTICAL SHA
`archiveOk` fails an archive on `git.ok === false`, so a faithful, hash-identical
archive of a working clone reported FAILED. A `--mirror` capture was unaffected,
so the documented remedy was right and only the comparison was wrong.
`logicalRefName` normalizes `refs/remotes/<remote>/<X>` to `heads/<X>`, discarding
the remote NAME: the restored clone's remote could be called anything, and its
recorded URL describes the machine that made the clone, not the objects.
`refs/remotes/<remote>/HEAD` is skipped as a symbolic alias.
Each logical name maps to a SET of local SHAs, not one, so a working clone whose
`refs/heads/main` is ahead of `refs/remotes/origin/main` still satisfies the
remote's SHA — the remote's commit is in the restored object database, which is
what a restore proof asks.
TWO THINGS THIS SURFACED THAT ARE WORTH READING CAREFULLY:
1. My first version of `logicalRefName` returned undefined for unrecognized
namespaces, and that INTRODUCED the same false negative it was fixing: GitHub
advertises `refs/pull/<n>/head`, a `--mirror` holds all 26 of them verbatim on
this repository, and dropping them made a faithful mirror report 9/35 with 26
"missing". Caught by measuring against the real remote rather than only the
fixture. Unrecognized namespaces now fall back to the LITERAL refname, so the
mirror path stays at full match. Pinned by its own test.
2. A working clone genuinely lacks those PR refs — `git clone` never fetches them
— so reporting them as missing is true but says nothing about the archive.
They are now split into `refsMissingLocallyAdvisory`: reported, counted in the
message, and NOT failed on. Only `refs/heads/*` and `refs/tags/*` are treated
as history a restore must reproduce.
Measured against the real GitHub API after the fix, rc=0:
working clone ok=true 9/35 matched, 0 history refs missing, 26 advisory
--mirror ok=true 35/35 matched, 0 advisory
Mutation-proven: planting the literal comparison (`logicalRefName` short-circuited
to identity, needle asserted present) turns the new suite red at 3 pass / 7 fail.
The suite also pins both no-false-pass directions — a branch pushed after the
capture is still reported missing and fails, and a diverged branch is still
reported mismatched.
Suite: bun test --timeout 60000 rc=0, 172 pass / 0 fail / 15 files / 1356 expect().
typecheck rc=0.
…behind `--deep` writes a REAL restored copy of the data to the scratch tree, and a caller-named `--target` is never removed — correctly, since deleting a path the caller named would make a verification command destructive. But it was silent, and it did not protect the path: `mkdirSync`'s `mode` is a no-op on an existing directory. Measured on a `--profile preservation` archive, which excludes nothing: mkdir -p <t>; chmod 0777 <t> verify latest --deep --target <t> rc=0, no warning about the target after: <t> still 0777; restored files 0664, including a credentials fixture Per the review's ruling — warn AND refuse, not automatic cleanup: - A group- or world-accessible target is REFUSED before anything is written. The message names the path, the mode, the `chmod 700` that fixes it, the `--allow-insecure-target` override, and why it matters. - Refusing rather than `chmod`-ing the path is deliberate. Silently changing the permissions of a directory the caller named is the same class of surprise as silently deleting it, and the operator may have chosen that mode for other things living there. - `--allow-insecure-target` proceeds and prints the exposure. The override accepts the risk; it does not alter the path. - A target the command CREATES is chmod-ed to 0700 explicitly, because `mkdirSync`'s mode argument is masked by the umask. Verified under `umask 000`. - Every named target gets a `TARGET:` notice on stderr and in `targetNotices`, even a secure one: the path is not removed and may hold plaintext credentials. Silence about that was the defect. The default scratch tree is unchanged in contract — 0700 under the home's tmp, removed after the run, no notices since no caller named it — and that is pinned so the guard cannot have made the ephemeral path destructive or leaky. Mutation-proven: planting the original behaviour (mode never inspected, no not-removed notice; needles asserted 1 -> 0 and 0) turns the suite red at 3 pass / 5 fail. Also fixed a type hole in my own test: a `.catch(e => e as Error)` union let the refusal assertions read a resolved result. It now asserts `toBeInstanceOf(Error)` first, so a future change that stops throwing fails by name instead of silently. Docs: docs/VERIFICATION.md gains the target rules and the logical-ref-comparison section; docs/COMMANDS.md gains the repeated-flag rows and the full profile x --include-git-history composition matrix. CHANGELOG covers all five. Suite: bun test --timeout 60000 rc=0, 180 pass / 0 fail / 16 files / 1380 expect(). typecheck rc=0, contracts:conformance rc=0, build rc=0.
…-target guard
Adversarial review round 2 returned APPROVE-WITH-CONDITIONS from two reviewers.
This closes every blocking condition, plus the new `prune --help` defect
(todos 9a2b3a22) which the pre-dispatch help gate already covered.
REFUSE A DUPLICATE --compare-source KEY (reviewer 1, worst finding). Marking the
flag repeatable fixed the argv layer, but a duplicate KEY still let the last
mapping win: `--compare-source t=<decoy> --compare-source t=<real>` returned rc=0
ok=true missing=0, while the reverse order returned rc=1 — the same input, order
dependent, one of them a GREEN verification, and nothing said about the discarded
mapping. Which tree an archive is diffed against decides what the proof means.
SPEC-DRIFT SCANNER NOW COVERS listFlag. The alternation omitted it, leaving the
readers for ALL FOUR repeatable flags unscanned — the highest-risk class, since an
undeclared flag fails closed at runtime. Planting `listFlag(parsed,
"sentinel-list")` left the suite green at 180/180 while the flag exited 1. The
reader list is now asserted to match the helpers the CLI actually defines, so a
fifth reader cannot silently narrow the scan again.
assertKeepsSecrets IS NOW ACTUALLY PINNED, and its docblock no longer overstates.
It was live (broadening the subtraction made it refuse) but untested, while the
comment claimed "a test pins it" and that the SECRETS_EXCLUDES-subset property
"is itself asserted". Neither was true. Added: the subset property, that the
modifier removes exactly `.git` while retaining every secrets pattern, that the
guard throws for each secrets pattern, and that it does NOT throw for the real
subtraction. Exported so it can be asserted directly.
Deleting the guard's CALL still left the suite green, because the guard only fires
when the invariant is already violated — so the call site is asserted at the
SOURCE level, and the comment now says that is what it is rather than implying a
behavioural test.
INLINE `=` NO LONGER TRUNCATES. `split("=", 2)` discarded everything past the
second `=`: `--name=a=b=c` stored "a", `--exclude='p=1,q=2'` stored ["p"]. Two
patterns silently reduced to one at rc=0 — the same class as the repeated-flag
collapse. Now splits on the first `=` and keeps the remainder. `--home=`,
`--preserve-markers=` and inline `--home=<path>` all still behave.
ONE SHARED RESTORE-TARGET GUARD, src/lib/restore-target.ts, used by BOTH
`verify --deep` and `restore apply`, which had drifted exactly as a duplicated
rule does: `--deep` refused a 0777 target while `restore apply --target <the same
path> --yes` returned rc=0 and left `creds.env` at mode 664.
- CONTAINMENT, new and the more serious half: a target inside the backup home (or
containing it) is refused on both commands, with no override. Measured before:
`verify --deep --target <home>/manifests` rc=0, leaving a restored directory
among the manifest files — content mixed into the catalog `verify` and `restore`
both read. Symlink-resolved, because a symlink into the home reads as outside it
lexically.
- EXPOSURE differs by command, deliberately and recorded rather than hidden.
`--deep` refuses (throwaway scratch tree the tool owns; 0700 is natural).
`restore apply` WARNS (the caller's destination for their own data; an ordinary
`mkdir` is 0755). Refusing in both places was my first attempt and it broke
tests/backup.test.ts:201, whose target is a plainly-created directory — a guard
that fails the common case only trains everyone to pass the override.
`verify` stays `mutating: false` because plain verify only reads, but its notes now
say `--deep` WRITES a restored copy, and the read-only-commands sentence in
UnnamedBackupHomeError is qualified the same way.
The `plan` note claiming the pre-flight "names every pattern that drops content"
now says it names the patterns and is an ESTIMATE using the inventory's matcher
rather than GNU tar's globber — the CHANGELOG already carried that caveat and the
spec contradicted it.
Mutation-proven, each with needle counts asserted changed: duplicate-key refusal +
`=` truncation planted together -> 9 pass / 2 fail; containment disabled -> 12 pass
/ 3 fail; the reviewer's `listFlag` plant now fails the drift test naming
"sentinel-list" (it previously left 180/180 green); deleting the
assertKeepsSecrets call now fails the wiring assertion (previously green);
broadening the subtraction to drop `.env` -> 14 pass / 2 fail.
Rebased onto b2c4809 (PR #23 merged) with ZERO conflicts.
Gates: bun test --timeout 60000 rc=0, 206 pass / 0 fail / 17 files / 1531 expect().
typecheck rc=0, contracts:conformance rc=0, build rc=0.
0266051 to
2350364
Compare
Round 2 — review conditions closed, and two corrections to my own PR bodyHead is now Two adversarial reviewers returned APPROVE-WITH-CONDITIONS. Every blocking condition is closed Corrections to the PR body — both reviewers were right, I was wrong
Conditions closed
Also folded in, from the coordinator: The one condition I did NOT implement as stated, and whyR2-4 asked to extend the target guard to
Findings acknowledged and NOT fixed here — filed instead
VerificationNew mutation plants, needle counts asserted changed each time: duplicate-key + Reviewer 2 independently reproduced the target-guard plant at exactly 3 pass / 5 fail, and reviewer Live catalog — a correction to the control itselfThe The control that held, and the one to use: no entry disappeared. One thing worth reclaiming: the brief's fingerprint I reproduced it three times. The absolute-path variants the coordinator issued No NOT MERGED. Ready for a third pass, focused on the exposure-asymmetry decision above. |
Round 3 — the five unverified plants reproduce; two defects block the mergeThird adversarial pass, on head Verdict: APPROVE-WITH-FIXES. Not merged. The engineering is real and the mutation evidence holds Baseline
1. The plants — seven reproduced independently, five of them for the first timeMeasured by building each ref, targeted file runs,
The D1 replant is not an import error. Every failure is a real assertion ( The secrets guard fails closed through the real CLI, not just in tests. With That is the strongest safety claim in the PR and it holds under mutation. 2. Non-widening — measured independently, and end-to-endAll cells re-measured through the real CLI,
All four refusal cells reproduce at rc=1, including End-to-end, which is what actually matters — The composition captures the 3.
|
… help true
`restore apply --help` shipped "--allow-insecure-target Write into a
group/world-readable --target anyway. Refused without this." and "--target is
refused if it is group- or world-readable", while `applyRestore` passed
`exposure: "warn"`. Measured with no override:
chmod 0777 <t>
restore apply latest --target <t> --yes rc=0
<t>/s/src/creds.env mode 664, stderr EMPTY
Two defects in one: the help states a refusal the code does not perform on a
credential path, and the warning that replaces the refusal reached no human
channel — `verify --deep`, `sources` and `run` all print theirs.
`warn` is kept: the 0755 argument stands, since refusing the primary restore
path would train everyone to pass the override. The warning is fixed instead.
- The exposure policy per verb is one constant, `TARGET_EXPOSURE`, which the
guard call sites pass AND the help text is generated from, so prose in a
`notes` array can no longer drift from an argument at a call site.
- `case "restore"` prints every warning the result carries on stderr:
`restore:` lines, plus `TARGET:` for the target notices, the same prefix
`verify --deep` uses. Notices are returned separately from the payload so
`--contract` cannot silence them.
- `RestoreApplyResult.targetNotices` mirrors `DeepVerifyResult`; empty on a run
that wrote nothing, which has not measured the target's mode.
- `--allow-insecure-target` was inert on this verb — the warn path never read
it — and now records the acceptance in the notice.
tests/restore-target-help.test.ts reads the help text and the measured
behaviour in ONE test for both verbs, because the defect was a
documentation/code divergence; the asymmetry itself is pinned separately as a
decision. Suite: 206 -> 211 pass, 0 fail, 18 files.
… just preservation
The notice's condition was `excludeProfile === "preservation"`, so the claim
that preservation is the only way to archive secrets — and the only way that
says so out loud — was false. Measured on a tree holding a `.env`:
sources add <path> --exclude '.gi?' rc=0 notices: []
run; tar tzf <archive> src/ src/app.js src/.env
Registration is the higher-leverage place for this than the restore/verify
target notices already fixed: a source registered this way runs on a schedule
forever and nothing asks again.
The condition is now the EFFECTIVE exclude list. Coverage is measured with the
same matcher the exclude report uses, against one representative filename per
secrets pattern, because a pattern is not a filename — `*.pem` never matches
the literal string `*.pem`. Using the matcher rather than `includes()` also
keeps the notice honest in the other direction: `--exclude '*'` drops
everything and is correctly not warned about, and a warning layer that fires
on lists that are fine is one operators learn to skip.
`SECRETS_PATTERN_EXAMPLES` is keyed by the patterns themselves, so adding one
to `SECRETS_EXCLUDES` without a representative is a compile error rather than
a pattern that silently stops being checked. `PRESERVATION_SECRETS_WARNING`
(introduced by this PR, absent from `main`) is replaced by the generated
notice; `runtime.ts` re-exports the generator.
Suite: 211 -> 218 pass, 0 fail, 19 files.
…t nothing was created The audit built to catch "the CLI used a home you did not name" probed `--home` with the RELATIVE values "one" and "two". Those resolve against the test process's cwd, so the single regression this test exists to detect writes `two/config.json`, `two/manifests`, `two/restore-plans` and `two/tmp` into the repository root — measured under a mutation plant. Probe values are now absolute paths under the test's own temp root, and the walk asserts neither was created. The exit-code assertion alone cannot see a refusal that resolved the home first, which is the shape that matters here. Verified by planting the collapse (disabling the repeat refusal): the audit fails with 93 collapsed invocations named, and nothing lands outside the temp root.
#24 (`bad7d52`) landed on main first, so this branch integrates it rather than preceding it. Merge, not rebase: the review record cites this branch's head `2350364` and the two commits before it, and rewriting those SHAs would leave three rounds of adversarial evidence pointing at commits the PR no longer lists. The interaction predicted in the merge-order analysis is real, and it was machine-caught rather than reasoned about: - `reconcile` and its flags are now declared in `src/cli/spec.ts`. Without the declaration the closed allowlist exits 1 on `--adopt`, silently removing #24's recovery path for orphaned runs. #24 also added a fourth flag the merge-order note did not list, `--active-within-seconds`, and the drift test named it. - `tests/cli-spec-drift.test.ts` failed on the merge because #24 added a fifth flag-reader helper, `numberFlagStrict`, that the scanner did not scan for — the guard-on-the-guard doing its job. Added, longest-first so `numberFlag` cannot match inside it, plus a pin that reconcile's four flags are declared on the verb that reads them. Conflict resolutions, all in favour of the spec-driven surface: - `main()` keeps the pre-dispatch help/validation gate; `--home` is resolved with `stringFlagStrict` INSIDE the try, as #24 does, so the refusal is reported as JSON rather than thrown at the shell. - `doctor`'s `commands` stays `commandNames()`. #24's hand-maintained array (which had gained `reconcile`) is exactly the duplicate that had already drifted; the derived list now includes `reconcile` because the spec declares it. - `printHelp()`'s hand-written blob is gone, as on this branch; #24's `reconcile` help prose is ported into the spec's notes, so `backup reconcile --help` prints it and MUTATING is marked. Also corrected here, since both are claims in this PR's own diff: the CHANGELOG and docs/COMMANDS.md said `--profile preservation` was the only way to archive secrets. An explicit `--exclude` list that omits the secrets patterns does it too — measured — which is why the notice is keyed on the resolved list. Verified through the real CLI, not only the suite: `reconcile --help` prints without executing; `reconcile`, `--adopt --no-hold --destination local --active-within-seconds 0` all accepted; `--activewithinseconds` refused with `Did you mean --active-within-seconds?`; `doctor` lists reconcile among 22 commands. bun test 242 pass / 0 fail / 20 files; typecheck, contracts:conformance, build all rc=0.
Corrections to claims in this PR's body and release notesThree statements this PR made are wrong. Recorded here rather than by editing the body or amending a 1. "None of these findings is introduced by this PR" is wrong for
|
Round 3's two blocking defects are fixed, plus the registration notice and the
|
| plant | result |
|---|---|
revert the case "restore" stderr emission |
4 fail / 1 pass, every failure a real assertion (Received: ""), no import errors |
hand-write the old apply help back in, behaviour untouched |
1 fail / 4 pass, on the help/behaviour agreement test |
Non-blocking findings, also in this push
N1 — the registration-time notice now fires on the effective exclude list. sources add measures
which secrets patterns the resolved list fails to drop, using the matcher the exclude report uses
against one representative filename per pattern, and names them. --exclude '.gi?' on a tree with a
.env went notices: 0 -> notices: 1. It correctly does not fire for --exclude '*', which
covers credentials another way — an over-firing warning is the failure mode that gets warnings ignored.
Adding a pattern to SECRETS_EXCLUDES without a representative is now a compile error.
tests/source-secrets-notice.test.ts (7 tests); planting the old profile-name condition fails it on the
.gi? case specifically.
The relative---home audit is fixed. tests/cli-repeated-flags.test.ts probed --home with the
relative values one/two, so the one regression that audit exists to detect writes
two/config.json, two/manifests, two/restore-plans and two/tmp into the repository root. Probe
values are absolute paths under the test's own temp root, and the walk now asserts neither was created —
the exit-code assertion cannot see a refusal that resolved the home first. Verified by planting the
collapse: 93 collapsed invocations named, nothing written outside the temp root.
Left alone, deliberately
--deep is still wired into nothing, and this push does not wire it in. The subtractive-modifier
composition is untouched. The .git half of the glob bypass is untouched — see the corrections comment.
#24 is integrated, and its interaction was machine-caught
bad7d52 landed first, so this branch merges main rather than rebasing onto it: the review record
cites 2350364 and the two commits before it, and rewriting those SHAs would leave three rounds of
evidence pointing at commits the PR no longer lists.
reconcileand its flags are declared insrc/cli/spec.ts. Without that the closed allowlist exits 1
on--adopt. Make an archive the catalog cannot see an error, and recover the ones it already lost #24 also carries a fourth flag the merge-order note did not list,
--active-within-seconds.tests/cli-spec-drift.test.tsfailed on the merge because Make an archive the catalog cannot see an error, and recover the ones it already lost #24 added a fifth flag-reader helper,
numberFlagStrict, that the scanner did not scan for. That is the guard-on-the-guard firing, not a
surprise. Added longest-first sonumberFlagcannot match inside it, plus a pin that reconcile's four
flags are declared on the verb that reads them.doctor'scommandsstays derived (commandNames()); Make an archive the catalog cannot see an error, and recover the ones it already lost #24's hand-maintained array is the duplicate
that had already drifted.printHelp()'s blob is gone; Make an archive the catalog cannot see an error, and recover the ones it already lost #24's reconcile prose is in the spec notes.
Checked through the real CLI, not only the suite: reconcile --help prints without executing;
reconcile, --adopt --no-hold --destination local --active-within-seconds 0 all accepted;
--activewithinseconds refused with "Did you mean --active-within-seconds?"; doctor lists
reconcile among 22 commands.
Live catalog
Control is "no entry disappeared", never a count, and one path form throughout
(cd $HOME/.hasna/backup && find manifests -type f):
manifests 73 entries
config.json sha256 60ffe3b3a191e47da58db9c58489cebc40cb7498ebafb725b87d04eb24eaf325
mtime 2026-07-28 03:21:34 +0300 (identical to round 3's record)
runs.jsonl mtime 2026-07-27 17:14:40 +0300 (unchanged)
Every command I ran carried an explicit --home <fixture> on the same command line. No backup run
against real sources, no prune --apply, nothing deleted, expired, moved or renamed.
Not merged. Ready for a final adversarial pass.
Closes the four CLI/verification defects in todos task
cc893efa. All four were found by using thetool, and all four were reproduced against
main@ 8ce32b1 before anything was changed.No
backup run,prune --apply,sources add, orrestore applytouched the live registry. Everycommand below ran with an explicit
--home <fixture>on the same command line — no env var, no$(...)subshell — and the redirection was confirmed by grepping the
initoutput for the fixture path beforeany mutating call.
$HOME/.hasna/backupstill holds its 62 manifests, and itsconfig.jsonmtime(2026-07-27 16:54:35) is unchanged.
The defects, as reproduced
Exit codes measured unpiped (
cmd; rc=$?).run --helpmanifests=0 destfiles=0run --dryrun(typo)"dryRun": falseUnknown flag --dryrun. Did you mean --dry-run?run --home --dry-run--homeread as absent → resolved the LIVE homeMissing value for --home <path>.run -hsources add <p> --excludes x.gitDid you mean --exclude?<verb> --helpfor 5 verbssources add <repo>DROPPING 27 file(s), 26890 bytes … .git → 26 files, dist → 1 fileplanpreflight: 1 file(s) to archive, 27 file(s) EXCLUDEDverify lateston an incomplete backupok: true"checksum matched"ok: trueproof: checksum-only+doesNotProve[]verify --deep30 source files / 1 restored / 29 missingrun --home --dry-runsilently targeting the live home is a new find, not in the task text: it isthe same "the home was not the one you named" shape as the incident that destroyed 139 live artifacts.
It was read off the code path (
src/cli/index.tsparseArgs →stringFlag→backupHome(undefined)→defaultBackupHomeRoot()), never executed against the live home.What changed
1 — Help and argument discipline (
c6f3b71, defects 1 + 4). Newsrc/cli/spec.tsdeclares the wholecommand surface as data — verbs, subcommands, flags with kinds, positionals, help text, and a
mutatingclassification.resolveInvocation()answers help and validates arguments before thedispatch switch, so no path can reach a verb with an argument list the spec does not accept. Refusal is
the failure mode for unknown flags, missing/empty flag values, unexpected positionals, and single-dash
args — on every verb, not just the mutating ones, because the failure mode is "the CLI ignored part
of what you typed".
doctor's hardcoded command array is now derived from the spec; the array itreplaces had already drifted, omitting
status,manifest, andholds.2 — Excludes are measured and reported, and there is a profile with none (
e022ebb, defect 2).sources addwalks the tree and reports each pattern with the file and byte count it drops, plusexamples, on stderr and in the result, with a
source-excludes-drop-contentfinding.--profile preservationattaches no excludes; combining it with--excludeis refused rather thanresolved.
plan/runcarry apreflightblock with kept-vs-excluded totals. The counts are labelledestimate: true— they use the inventory's matcher, not GNU tar's globber — because the authoritativemeasurement is
verify --deep.preservationalso drops the secrets patterns and these archives areunencrypted, so the CLI says that out loud when it is used.
3 —
verifystops reading as a proof, andverify --deepis one (402a3fb, defect 3).VerifyResultcarries a requiredprooffield plusproves/doesNotProvein the payload; theper-check message is now
recorded checksum matched (archive contents NOT inspected). A checksum-onlyproof-bundle contract carries the limitation as a
residualRisksentry even when it passes — thecontract is called a proof bundle and a green one was being filed as evidence of restorability.
verify --deepmaterializes each archive, re-checks its checksum, extracts to scratch, builds a sha256manifest over every restored file, diffs it against the live source tree, and clones any recovered git
object database to compare ref SHAs and tree hashes against
git ls-remote. Cloning is the load-bearinggit step: a shallow bundle passes
git bundle verifyand then fails to clone.And it stays honest about the gap: when no source tree is available — the normal state of a pre-deletion
archive, and exactly when the proof matters most — it reports
completeness: "not-verified"and namesthe archives in
doesNotProveinstead of returning a green result that reads as a completeness proof.--require-source-diffturns that into a failure.Evidence
Fail markers cross-checked per the colorized-output caveat:
grep -c '✗'= 0 andgrep -c '(fail)'= 0, and the summary line reads
120 pass / 0 fail.Mutation-proven, not assertion-counted. Each regression suite was run against planted old behaviour
and had to go red:
main'ssrc/cli/index.tsrestored (help executes, flags ignored)--profilesilently ignored → falls back toDEFAULT_EXCLUDESexcludeReportearly-returns)--deepdegenerated to checksum-only (no diff, no git proof)verifyreverted to"checksum matched"with no claimsTwo separate plants for defect 2 because neither alone covers both halves — one leaves the reporting
intact, the other leaves the profile intact.
The defect-2 acceptance test is the check that caught the original bug: stage a tree containing a
real
.gitanddist, register it via the documented preservation path, run, restore, and assert azero-difference sha256 manifest on both sides (
{staged, restored, missing: [], extra: [], corrupt: []}as one object, so the counts appear in the failure diff). It then recovers the history —restored
git rev-parse HEADequals the live HEAD andgit fsckexits 0 — because a hash-identical.gitis not by itself proof the history is usable.The git-remote comparison was verified against the real GitHub API, not only against a fixture:
verify latest --deep --git-remote hasna/backupexpanded tohttps://github.com/hasna/backup.git,fetched 30 refs via
git ls-remote, and correctly reported0/30 matched, 29 missing locally, 1 mismatchedfor an unrelated fixture repository (rc=1). The suite substitutes a local bare repo forthe endpoint, since
git ls-remotetakes a path and a URL through the same code path.Existing tests touched, and why
Nothing was weakened to a substring match.
tests/backup.test.tsread the fulladdSourcewarning list withtoEqual([...]).The new exclude finding is appended last so positional
warnings[0]readers still see the coveragefinding, and a
gitFindingCodes()helper filters the new code at the five exact-list sites. One siteadditionally asserts the new code is present, so the interaction stays pinned in that file.
tests/backup.test.ts:1111asserted"checksum matched". That string was the defect; theassertion now pins the new wording and
proof: "checksum-only".tests/live-home-guard.test.ts:253asserted that--confirm-home " "produces the deepLiveBackupHomeRefusedErrornaming the live home. Argument validation now rejects a whitespace-onlypath value one layer earlier. Still refused, nothing deleted, and the deep guard still refuses blank
independently (
confirmsLiveHometrims); the two path-shaped confirmations still assert thehome-naming message. Split into its own expectation rather than deleted.
tests/mcp.test.tstool-name lists gainedbackup_verify_deep.tests/contracts.test.tsfixture gained the now-requiredprooffield.Bugs found while doing this
fired for
inventory-onlysources, which claims files are missing from an archive that is neverwritten.
excludeReport()now returns an empty report unlessmode === "archive"; pinned by its own test.process.exitCode = undefinedis a no-op once the code has been set (verified:process.exitCode = 1; process.exitCode = undefinedstill reads1).tests/backup.test.ts:1112and:1320both reset withundefined, so after the first CLI refusal in that file every later exit-code read is stale. The newhelpers reset to
0. This produced 7 phantom failures in my first run before I found it.Overlap with #21
#21 (
1f202268, preservation prune guard +--include-git-history) is open and touches some of the samefiles. The scope split held: this PR adds
--profile/reporting only. It does not add, edit, orreference
--include-git-history, and it does not touch prune, preservation markers, orsrc/preservation.ts. Expect textual conflicts insrc/config.ts(addSource),src/cli/index.ts(
handleSources),src/types.ts,CHANGELOG.md, anddocs/COMMANDS.md— adjacent hunks, no semanticoverlap. Whoever merges second resolves; the two flags compose, since
preservationexcludes nothing andtherefore keeps git history by construction.
Not done
verify --deepis not wired into any scheduled loop ordoctor; it is opt-in per invocation.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Rebase onto
d32e5cb, and the review's blockers closedAppended after the adversarial review (verdict, REJECT). The
text above is the original submission and is left intact; the retractions below say plainly which of
its claims did not survive. Timestamps are UTC+0300 (EEST); this box is UTC+0300.
Rebased from base
8ce32b1ontod32e5cb(PR #21).git merge-base --is-ancestor d32e5cb HEAD→ rc=0. PR #22 is now MERGEABLE (was CONFLICTING).
Retractions from the original submission
review found. There were three genuine semantic collisions, not zero: the two flags below, plus
--preserve-markers=.src/config.ts,src/cli/index.ts,src/types.ts, CHANGELOG anddocs" undercounted. Commit
e022ebbconflicted in five files includingsrc/backup.tsandsrc/runtime.ts— the two the notes claimed were kept clear of Refuse to prune preservation-marked archives, and let a source capture git history #21.--git-remote… reported 0/30 matched … correct, since the fixture repo is not hasna/backup"was a misread of its own output. The review reproduced the same shape against a clone of the real
hasna/backup, so "different repo" never explained the numbers. A namespace mismatch did. Fixedbelow.
additions of
DEFAULT_EXCLUDESintosrc/runtime.tstwice (import block and re-export block) andproduced duplicate identifiers.
bun installfailed rc=1 until deduped. Those were not textualconflicts — git resolved them silently and wrongly.
BLOCKER 1 — #21's flags are declared, and the drift is now a test failure
mainreadsinclude-git-historyandpreserve-markers; the spec at402a3fbdeclared neither, andthis branch makes flags a closed allowlist that fails closed. The
printHelp()conflict is the samehazard mirrored: #21 extended that blob and this branch deletes it wholesale, so taking this branch's
side is what dropped them. Their prose moved into the spec's notes.
Measured through the CLI,
--home <fixture>on the same command line, redirection confirmed before anymutating call:
sources add <p> --include-git-historyexcludeProfile=default-with-git-history, 11 patterns, no.gitpolicy set --preserve-markers a,bpreserveMarkerssetpolicy set --preserve-markers=policy set --preserve-markers(bare)Missing value for --preserve-markersThe third row is the collision the review did not have to find because it fell out of the rebase: this
branch's empty-value refusal broke #21's documented clear form. Fixed with a declared
FlagSpec.emptyValueMeansNonethat permits the exactly empty string only — whitespace-only staysrefused, so nothing that merely looks empty can read as an opt-in.
tests/cli-spec-drift.test.tsgeneralises the review's by-hand verb×flag sentinel probe into a staticscan: every flag any handler reads must be declared, or the test names it.
The composition decision —
--profile×--include-git-historyThe review declined to author this and merge it, on the grounds that it is the code path that lost 68
files and 139 artifacts. I authored it; I am not merging it. Here it is, stated explicitly so it
can be attacked.
The model:
--profile/--excludechoose a BASE list.--include-git-historyis a SUBTRACTIVEMODIFIER on that base — it removes the patterns that drop
.git, and nothing else.Every cell falls out of that one rule. All eleven measured through the real CLI:
default, 12 patterns--include-git-historydefault-with-git-history, 11 (no.git)--profile defaultdefault, 12 — identical to (nothing)--profile default --include-git-historydefault-with-git-history, 11 — identical to the bare modifier. This was the undefined cell.--profile preservationpreservation, none--profile preservation --include-git-historypreservation, none — modifier is a no-op--exclude distexplicit,dist--exclude dist --include-git-historyexplicit,dist(no.gitpattern to subtract)--exclude .git --include-git-history--profile default --exclude dist--profile preservation --exclude d --include-git-historyTwo properties are load-bearing, each pinned by a test:
--profile defaultis a pure synonym for passing nothing, with or without the modifier. Ifnaming the default changed behaviour the profile name would be a lie, and
--include-git-historyalone already resolved to default-minus-
.gitin shippedmain. Refusing the explicit spellingwhile honouring the implicit one would be a regression dressed as strictness.
DEFAULT_EXCLUDES_WITH_GIT_HISTORYremoves exactly['.git'](12 → 11) and keeps.env,.env.*,.secrets,.connect,*.pem,*_TOKEN,*_KEY. A newSECRETS_EXCLUDES+assertKeepsSecrets()makes the modifier fail closed if a future change toexcludesGitDirever broadened the subtraction. So this does not widen what gets archived beyond the
.gitdirectory the caller asked for;
--profile preservationremains the only way to archive secrets,and it still says so out loud.
Why the asymmetry (the modifier silently trims a
defaultbase but refuses anexplicitone):with
defaultthe operator never named the patterns, so subtracting one is the tool honouring theflag. With
--excludethey did name them, so subtracting one would discard a written instruction —#21 already ruled that a refusal, and resolving it either way produces a source that lies.
The recorded label is
default-with-git-history, notdefault. Alternative rejected: record itas
default. The stored list is 11 patterns, not the 12 that label denotes, andsources addprintsthe profile name — so labelling it
defaultwould print a false statement in the very report thisbranch added to stop the tool making false statements about what it archived. It is not selectable via
--profile(one spelling per meaning); passing it is refused with a message naming--profile default --include-git-history.Second alternative rejected: make
--profile default --include-git-historya refusal, on thegrounds that a profile "sets the exclude list" so a modifier contradicts it. Rejected for reason (1)
above — it would make naming the default a breaking change and regress #21's shipped behaviour. It is
the defensible runner-up, and it is the one a reviewer should push on.
BLOCKER 2 — a repeated flag no longer collapses
The parser records every occurrence and the spec decides what a repeat means. The four comma-separated
list flags accumulate (
sources add --exclude,policy set --preserve-markers,verify --compare-source,aws --regions), so--exclude a --exclude b≡--exclude a,b. Everyother flag refuses, because there is no honest resolution for
--home /a --home /band quietlytaking the last one is the shape that destroyed 139 artifacts:
Per the review's instruction, every other repeatable flag was audited rather than just
--exclude:every declared flag on every verb and subcommand was probed with a repeat. All refuse except the four
accumulators. The single exception is the
helpverb, which resolves before validation by designbecause it executes nothing — measured:
help --home a --home bprints help at rc=0 and writes nothing.That audit is a test, driven off the spec, so a flag added later is covered without editing it.
The three further defects
--deepwas blind to symlinks and empty directories — the review's most damaging finding, sincethe half designated authoritative was the wrong one.
A symlink is fingerprinted by its target string, not followed — otherwise a symlink and a regular
file of identical content compare equal, and the walk could escape the tree or loop. Only empty
directories are recorded; a non-empty one is implied by its contents.
MANIFEST_COMMANDnow covers-type land-type d -empty, because it ships in the result for a human to redo by hand andpublishing a command narrower than the measurement would restate the defect as documentation.
--git-remotefalse-negatived non-mirror clones — a namespace mismatch, as the review establishedby refuting its own stronger hypothesis. Refs are now compared by logical name, with
refs/remotes/<remote>/<X>→heads/<X>; the remote name is discarded, since the restored clone'sremote could be called anything. Each logical name maps to a set of local SHAs, so a local branch
ahead of its upstream still satisfies the remote's SHA — that commit is in the restored object
database, which is what the proof asks.
Two things worth reading carefully here:
undefinedfor unrecognized namespaces dropped GitHub'srefs/pull/<n>/head, which a--mirrorholds verbatim — 26 of 35 refs on this repository — making a faithful mirror report 9/35 with 26
"missing". Caught only by measuring against the real remote rather than the fixture. Unrecognized
namespaces now fall back to the literal refname. Pinned by its own test.
git clonenever fetches them), so reporting themas missing is true but says nothing about the archive. They now go to
refsMissingLocallyAdvisory: reported and counted in the message, not failed on. Onlyrefs/heads/*andrefs/tags/*count as history a restore must reproduce.Measured against the real GitHub API, rc=0: working clone
ok=true, 9/35 matched, 0 history refs missing, 26 advisory;--mirrorok=true, 35/35, 0 advisory. Before:1/32, ok=false.--targetexposure — the review's ruling was warn and refuse, not cleanup, and that is whatthis does. A group- or world-accessible target is refused before anything is written, naming the
path, the mode, the
chmod 700that fixes it, and--allow-insecure-target.Refusing rather than
chmod-ing is deliberate: silently changing the permissions of a directory thecaller named is the same class of surprise as silently deleting it. A target the command creates is
chmod-ed to 0700 explicitly, sincemkdirSync's mode is masked by the umask (verified underumask 000). Every named target gets aTARGET:notice that it is not removed and may hold plaintextcredentials — silence about that was the defect.
Judgements the review settled, kept as ruled
The exclude estimate stays; fail-closed on read-only verbs stays (blast radius empirically
zero);
ok:truewithcompleteness: not-verifiedstays. Not relitigated.Verification
Marker caveat, since
FORCE_COLOR=3is set and this repo prints no per-test markers on a greenrun:
grep -c '(pass)'= 0 andgrep -c '✓'= 0 on a fully passing log, so the ANSI-strippedsummary line is authoritative and both fail-marker forms are checked —
grep -c '✗'= 0 andgrep -c '(fail)'= 0.Every new fix is mutation-proven red against its planted old behaviour, with needle counts asserted
changed:
Recordparser, repeat refusal offisFile()-only manifest walk8ce32b1:src/cli/index.ts) on the rebased treecomparePath=undefined+proveGitoff) on the rebased treeIn each case the tests that stayed green are the ones that do not depend on the planted code path —
the anti-vacuity scanner check, the pure spec assertions,
MANIFEST_COMMAND. One plant attemptfailed its own assertion and did not write the file, so that run's rc=0 was the unplanted tree;
recorded because it would otherwise read as evidence.
Live registry untouched
Fingerprint recipe reconciled with the brief first:
7e4d2912f84e55ee369ad7fd1dfcd5c2is md5 over thesorted
md5sumlines (hash + filename); md5 over the hashes alone givesd7119a19…. Identicalbefore and after all work:
swarm-final-archive-20260727still present inconfig.json. Nobackup runagainst real data, noprune --apply, no archive deleted, expired, moved or renamed. Every command carried an explicit--home <fixture>on the same command line, with redirection confirmed by grepping theinitoutput for the fixture path before any mutating call, in the same process.
One measurement I could not reproduce: the review's sha256 measure
698aaf2ee69dc21e…. My recipesgive
2f6951f9…(hash+name lines) and7ff933dc…(hashes only). Its recipe is undocumented, so I relyon the three measures that do reproduce rather than claim agreement I cannot show.
Overlap with PR #23
No conflict. PR #23 (
fix/9c93913b-prune-guard-wiring-tests, now at85c0155) touchessrc/backup.tsandtests/prune-guard-wiring.test.ts. Itssrc/backup.tshunks are all insidepruneBackups(lines 318–469); mine are in the import block,runBackup,verifyBackup, andhelpers — disjoint. A trial merge of #23 into this branch auto-merged at rc=0 ("Automatic merge
went well"), and the trial branch was discarded. Correction to the brief I was given: #23 touches
tests/prune-guard-wiring.test.ts, notsrc/preservation.ts.Not done
NOT MERGED — this goes back for a second adversarial review pass. No release or version bump.
verify --deepis still not wired into any loop or intodoctor. Thecompletenessmislabel on afailed extract (
a92b20ec) and theconfirmsLiveHomecoverage regression (03e39cbf) are still openas their own tasks, untouched here.