diff --git a/README.md b/README.md index 941eee6..a7a21ec 100644 --- a/README.md +++ b/README.md @@ -381,13 +381,20 @@ key and asserts only what must be true whichever of them wins the rename. which places the interference inside the window rather than hoping it lands there. `prune-cache-selftest.sh` scenario 12 stubs `du`, so the pass's own measurement publishes a reader marker strictly between its check and its -unlink; `seed-target-dir-selftest.sh` scenarios 8a and 8b stub `cp`, so the -consumer's own clone is what rotates the snapshot underneath it, or what -loses a subtree of its own source, strictly inside the identity window. 8a -does start a second real process — the actual `publish-snapshot.sh` — but the -stub is what fixes where its swap lands; the concurrency is incidental to the -determinism. Every stub asserts that it fired, because a scenario whose -interference silently did not happen passes for the wrong reason. +unlink. `seed-target-dir-selftest.sh` uses the shape four times over, on two +different commands. Scenarios 8a, 8b and 8c stub `cp`, so the consumer's own +clone is what rotates the snapshot underneath it, loses a subtree of its own +source, or reports a failure over a tree that is in fact whole — each strictly +inside the window that clone's checks cover; scenario 10 stubs `cp` one level +down instead, refusing the per-file copies that unshare the mutable paths. +Scenario 8d stubs `stat`, because the check it pins fires on an identity that +could not be READ rather than on one that changed, and the only way to make +that the sole witness is to fail the identity reads while the copy between +them succeeds. 8a also starts a second real process — the actual +`publish-snapshot.sh` — but the stub is what fixes where its swap lands; the +concurrency is incidental to the determinism. Every stub asserts that it +fired, because a scenario whose interference silently did not happen passes +for the wrong reason. **A synthetic stand-in for the other side, where that artefact *is* the contract.** `publish-snapshot-selftest.sh` scenarios 6 to 8 hold a @@ -404,9 +411,19 @@ does it any more. Where more than one guard could catch a fault, a scenario should assert *which* one did — otherwise deleting the guard under test leaves the suite -green because a sibling fires in its place. Scenarios 8a and 8b of the seed -suite do; scenario 9 of the same suite does not yet, which is why a mutation -survives it (issue #5). +green because a sibling fires in its place. Scenarios 8a to 8d and 10 of the +seed suite do, and each is reddened by exactly one mutation of the clone's +checks. Scenario 9 does not, and a mutation still survives it: with its source +unreadable, `cp -al` leaves the staging directory at mode `000`, so the +unshare pass aborts the clone before the copy's own exit status is ever +consulted, and the assertion is satisfied down a path it was not written for. + +That is the standing hazard here, and it is not hypothetical. Two guards that +can each catch the same fault mask each other, so **neither** is individually +necessary and no fixture built around that fault can pin either one — which is +how both `[ "$cp_rc" -eq 0 ]` and `[ "$i_before" != missing ]` sat unpinned +(issue #5) while looking well covered. Isolating a check means constructing +the state only it can see, not the state that trips several at once. The action YAML holds no logic beyond wiring; everything testable lives in `scripts/`. A composite action needs `shell: bash` on every `run:` step, and diff --git a/scripts/seed-target-dir-selftest.sh b/scripts/seed-target-dir-selftest.sh index a7f026e..dbf85f5 100755 --- a/scripts/seed-target-dir-selftest.sh +++ b/scripts/seed-target-dir-selftest.sh @@ -52,7 +52,9 @@ # identity read is reported as the string `missing`, so two of them # compare equal to each other; without the term that rejects the # sentinel, a clone whose source could not be identified at either end -# is published on the strength of two errors. +# is published on the strength of two errors. A regression guard on a +# defensive term rather than a reproduction of a reachable state — see +# the scenario's own comment. # All four scenarios force their interleaving rather than racing for it, # and each asserts WHICH check caught the tear, so none stays green if # the check it exercises is removed. @@ -486,6 +488,15 @@ echo "=== 8d: an identity that could not be read at either end ===" # would differ from `missing` and the identity COMPARISON would become the # witness instead — 8a's property, not this one. The assertion that the stub # fired is therefore a count rather than a flag. +# +# So be clear about what this scenario is. It is NOT a reproduction of a state +# a CI job reaches: every route to it is closed off — a rotation hands the +# witness to 8a, and a source that is genuinely gone fails `cp -al` and hands +# it to 8c. It is a REGRESSION GUARD ON A DEFENSIVE TERM, and the thing it +# defends against is a sentinel comparing equal to itself, which is a property +# of the code rather than of the filesystem. That is worth pinning precisely +# because nothing else can reach it: a term no fixture exercises is the one a +# refactor drops without argument. IDENT=$(cache_key feat/identity-unreadable) IDENT_BASE=$(cache_key release/3) make_wide_tree "$root/snapshot-$IDENT_BASE" identgen 4