verification: guard the accumulator's two lifts (port lift-guard, assert on the payload)

The four forks ship lift-guard.sh and call it from eight files each. This
repository had no lift-guard.sh at all and called it zero times, while lifting
Phase 0c (selftest-harness.sh) and Phase 3d (selftest_statements.sh). Its lifts
got a line-count sanity check and nothing else — no analysis of which variables
the payload READS against which the driver DEFINES, and after yesterday's fix
no errexit check either. It was the one place that class could still recur.

A SECOND defect surfaced while porting, and it is the more interesting one.
Both lifts assembled the driver inline, with no payload file anywhere:

    { echo 'set -euo pipefail'; echo "HERE=..."; sed -n '...' check.sh } > DRIVER
    if [ "$(wc -l < "$DRIVER")" -lt 40 ]; then ... refuse ...

so the size check measured the CONCATENATED DRIVER — bindings included. A lift
that recovered almost nothing still cleared the threshold on the strength of
the lines the test itself prepended. That is exactly the defect round-7
lift-guard-payload-assert reported and that the forks closed with the words
"all six lift sites now assert on payload.sh". Six counted the forks. These two
were never in the count, so a finding recorded as closed was measuring the
wrong population — the same error this estate keeps finding in new costumes.
Both sites now write payload.sh / payload3d.sh and assert on those.

lift-guard.sh is byte-identical to the forks' (4f61c113). It is executable, so
this repository's own self-deriving Phase 0c rule required a pin before it
would run at all: required set == pinned set, verified.

WIRING PROVEN RATHER THAN ASSUMED. With lift-guard.sh stubbed to exit 1, each
self-test halts AT the guard, naming its phase, before a single attack case
runs. Guard restored, pins re-verified.

Certified: button + all three self-tests GREEN (check.sh 86s, selftest_audit
258s, selftest-harness 1s, selftest_statements 34s). The other five
repositories are unchanged from the tree round-15 certified 50/50 and were not
re-run; this commit does not claim they were.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mrwulf 2026-08-04 13:39:19 +02:00
parent a0d11b44d9
commit be4f742b29
4 changed files with 248 additions and 7 deletions

View file

@ -14,5 +14,6 @@ eda93f520546a692926b2a46bcb79332e1795879e5083327a8bf2404aca5cf87 Proofs/AxiomCh
82d41c634a2e9adfc2cc74c202d025e57411687796f820765d45608f3fb4cb20 Proofs/Inventory.lean 82d41c634a2e9adfc2cc74c202d025e57411687796f820765d45608f3fb4cb20 Proofs/Inventory.lean
0b8a0fc6947af1d1e600a756eb2b07dc88d189b21df3220c4501a41be8b33f1e run_bare.sh 0b8a0fc6947af1d1e600a756eb2b07dc88d189b21df3220c4501a41be8b33f1e run_bare.sh
473e2463d9c26653c8435ad6758044742f200eb13ea0db4b8f076466c08bd87a selftest_audit.sh 473e2463d9c26653c8435ad6758044742f200eb13ea0db4b8f076466c08bd87a selftest_audit.sh
5d2792996d5164022591f116ce9cbd03cdee8c9b886eb4aac6fa49c9de2bac32 selftest-harness.sh 733eb2cf4eaf9101433d75dbedfc45681cff44e1dbfdc841e6b9dba350ef1570 selftest-harness.sh
b4a3ea633ac8a38c699e10b55b040718567cb60c5f7c7439c0c9720d4c9f8554 selftest_statements.sh c85d7a0fc39fbf06ec2c85d2f42a65813c475cae7cf60e6d140948215c62eabc selftest_statements.sh
4f61c1135f8afc615e686bd267a062229bf349416caebe070eafca510036d1a4 lift-guard.sh

221
verification/lift-guard.sh Executable file
View file

@ -0,0 +1,221 @@
#!/usr/bin/env bash
# lift-guard.sh <payload> <driver> [<phase-label>]
#
# Every VARIABLE the LIFTED PAYLOAD reads must be one the DRIVER defines.
#
# VARIABLES ONLY — and the emphasis is a round-8 correction (Claude, N1). A
# lifted payload also inherits FUNCTIONS, shell options, traps and a working
# directory from the script it was cut out of. This tool models none of those.
# A lifted phase calling a function defined in a neighbouring phase fails with
# `command not found`, loud under `set -e`, which is why it is not urgent; but
# the banner used to read as a completeness claim about lifting and it is a
# completeness claim about variables.
#
# Prints the offending names and exits 1 if any are missing.
#
# ───────────────────────────────────────────────────────────────────────────
# WHY THIS EXISTS — 2026-08-02
#
# Five of this repository's self-tests work by lifting one phase out of
# check.sh and running it standalone against a deliberately corrupted tree.
# That is the right design: the test then attacks the SHIPPING gate rather
# than a re-implementation of it. But a lifted phase is a fragment, and it
# reads variables its neighbours defined. Each self-test therefore carries a
# hand-written preamble supplying them.
#
# A hand-written preamble is a hand-kept list, and hand-kept lists drift. Twice
# in two days a phase grew a dependency and no preamble was told:
#
# · Phase 2c grew an accounting block reading $KERNLOG, a file Phase 2b
# creates. selftest-shapes.sh died on its first expansion under `set -u`.
# It could not pass on any fork from the moment that block was added.
#
# · Phase 2b changed from globbing Proofs/*.lean to reading the $PROOFS
# membership manifest — the spelling-versus-ownership fix ScalarPackSpec
# forced. selftest-axgate.sh's preamble was never told. Bash does NOT
# error on an unset array expansion under `set -u`; it expands to nothing,
# so `printf '"%s.olean", ' "${PROOFS[@]}"` silently produced
# expected := [".olean"]
# — one entry, empty name — and the gate's own fail-closed absence check
# rejected it. The baseline went red and both attack cases were then
# rejected for the WRONG REASON.
#
# Both failed loudly rather than passing vacuously, which is the only reason
# they were not false assurance. That is luck, not design: a missing variable
# that happens to make an ATTACK case die still looks like the attack being
# caught, and only the substring assertions in each `expect` helper stand
# between that and a green test measuring nothing.
#
# The fix for the CLASS is to stop maintaining the list by hand. This tool
# derives the requirement from the two artifacts themselves, so a phase that
# grows a new dependency fails AT LIFT TIME, naming it, instead of dying
# mid-run or — worse — passing for the wrong reason.
#
# WHAT IT IS NOT. This is a shell-text approximation, not a bash parser. It
# still cannot see a name built at runtime or passed through `eval`, and it
# models variables only — not functions, shell options, traps or the working
# directory a lifted phase also inherits. It is a tripwire on failure modes
# that actually occurred, not a proof of closure.
#
# Where it CANNOT bound the reads it refuses rather than staying silent:
# indirect expansion (`${!name}`) is detected and fails the lift. That is the
# round-8 correction — a guard whose contract is "does not miss a dependency"
# must say so when it cannot honour it, instead of shrugging.
# ───────────────────────────────────────────────────────────────────────────
set -euo pipefail
PAYLOAD="${1:?usage: lift-guard.sh <payload> <driver> [phase-label]}"
DRIVER="${2:?usage: lift-guard.sh <payload> <driver> [phase-label]}"
LABEL="${3:-the lifted phase}"
for f in "$PAYLOAD" "$DRIVER"; do
[ -s "$f" ] || { echo "FATAL: lift-guard: '$f' is missing or empty."; exit 1; }
done
# ── The driver must run the phase under the SAME shell options as the button ──
# A lift is only evidence about the shipping gate if it executes the way the
# shipping gate executes. Every button in this estate runs `set -euo pipefail`.
# Eighteen lift sites prefixed their driver with `set -uo pipefail` and no -e
# (four per fork, two in the accumulator) while sixteen others got it right, so
# the estate did it both ways and the self-tests silently ran a more permissive
# shell than the phase they claim to test: without -e a failing command does not
# abort, execution continues, and the driver returns the LAST command's status.
# A lifted phase can therefore reach a verdict the shipping phase would never
# reach, while the self-test reports the gate "works".
#
# This lives here rather than in each self-test because the same defect appeared
# in eighteen places and would return the nineteenth time someone writes a lift.
# Checked on the DRIVER, which is what bash actually executes; the payload is
# lifted verbatim and carries no `set` line of its own.
if ! grep -qE '^[[:space:]]*set[[:space:]]+-[a-z]*e' "$DRIVER"; then
echo "FATAL: lift-guard: the driver for $LABEL does not enable errexit."
echo " The button runs 'set -euo pipefail'; this driver does not set -e, so"
echo " the lifted phase would run past a failure the shipping phase aborts on"
echo " and the test would report a verdict the button cannot produce."
echo " Driver's shell options:"
grep -nE '^[[:space:]]*set[[:space:]]+-' "$DRIVER" | sed 's/^/ /' || echo " (none)"
exit 1
fi
UNBOUND=$(python3 - "$PAYLOAD" "$DRIVER" <<'PYGUARD'
import re, sys
payload = open(sys.argv[1]).read()
driver = open(sys.argv[2]).read()
# What the payload READS. Deliberately over-approximates: a name mentioned in a
# comment costs one lifted definition, a name missed costs a broken self-test.
reads = set(re.findall(r'\$\{?([A-Za-z_][A-Za-z0-9_]*)', payload))
# ARITHMETIC CONTEXTS READ NAMES WITHOUT A `$`. Round-8 review (Claude, N1):
# echo $((X + 1)) reads X
# (( Y > 0 )) && ... reads Y
# and the pattern above cannot see either, because the character after `$` is
# `(`. This is the guard's own failure mode — a phase growing a dependency the
# guard is blind to — and `if [ $((inm + ins)) -eq 0 ]` is already live in
# check.sh's Phase 1b. Not lifted today, which made it latent, not absent.
for expr in (re.findall(r'\$\(\((.*?)\)\)', payload, re.S)
+ re.findall(r'(?<!\$)\(\((.*?)\)\)', payload, re.S)):
# NOT PRECEDED BY A DIGIT OR WORD CHARACTER. Round-9 review (Claude, N1):
# `$((0x1F))` was read as a variable `x1F`, and `$((1e3))` as `e3`, because
# the pattern happily starts matching at the letter-bearing tail of a
# numeric literal. Two false alarms introduced by the round-8 fix for a
# false NEGATIVE — the guard was made to see more and started seeing things
# that are not there, which is the failure mode that gets a guard deleted.
for tok in re.findall(r'(?<![0-9A-Za-z_])[A-Za-z_][A-Za-z0-9_]*', expr):
reads.add(tok)
# What the DRIVER defines, in every form these scripts actually use.
# `TIMEOUT=$T; CORES="$C"` is one line with two assignments, and a
# start-anchored pattern sees only the first.
# An assignment may open a line or follow `;`, `&&`, `||`, `then`, `do`, `{`,
# and — round-8 review (Claude, N1) — `else`, a `case` branch's `)`, and `!`.
# Six false-positive classes were demonstrated. A guard that cries wolf gets
# edited away, so over-strictness here is not the safe direction.
assigns = set(re.findall(
r'(?:^|;|&&|\|\||\)|!|\bthen\b|\bdo\b|\belse\b|\{)\s*'
r'([A-Za-z_][A-Za-z0-9_]*)=', driver, re.M))
# `mapfile`/`readarray` and `printf -v` bind a name without an `=` at all.
assigns |= set(re.findall(
r'\b(?:mapfile|readarray)\b(?:\s+-[A-Za-z]\s*\S*)*\s+([A-Za-z_][A-Za-z0-9_]*)',
driver))
assigns |= set(re.findall(r'\bprintf\b[^\n]*?\s-v\s+([A-Za-z_][A-Za-z0-9_]*)', driver))
# SEVEN MORE BINDING FORMS — round-9 review (Claude, N1). Each was a false
# alarm: the driver binds the name and the guard demanded it anyway. Listed in
# the order reported, so the next reader can check the list against that report.
assigns |= set(re.findall(r'\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*=', driver))
assigns |= set(re.findall(r'\bselect\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\b', driver))
assigns |= set(re.findall(r'\bgetopts\b\s+\S+\s+([A-Za-z_][A-Za-z0-9_]*)', driver))
assigns |= set(re.findall(r'(?:^|;|&&|\|\||\)|!|\bthen\b|\bdo\b|\belse\b|\{)\s*'
r'([A-Za-z_][A-Za-z0-9_]*)\+=', driver, re.M)) # BAR+=b
assigns |= set(re.findall(r'(?:^|;|&&|\|\||\)|!|\bthen\b|\bdo\b|\belse\b|\{)\s*'
r'([A-Za-z_][A-Za-z0-9_]*)\[[^]]*\]=', driver, re.M)) # FOO[0]=x
# Arithmetic CONTEXTS BIND TOO — `(( FOO = 1 ))`, `(( i++ ))`, and the C-style
# `for (( i=0; i<3; i++ ))`. The reads-extraction above adds every identifier it
# finds inside `(( ))`, so without this the guard demands the very names those
# expressions assign.
for expr in (re.findall(r'\$\(\((.*?)\)\)', driver, re.S)
+ re.findall(r'(?<!\$)\(\((.*?)\)\)', driver, re.S)):
assigns |= set(re.findall(r'(?<![0-9A-Za-z_])([A-Za-z_][A-Za-z0-9_]*)\s*(?:=[^=]|\+\+|--)', expr))
assigns |= set(re.findall(r'\b(?:export|declare|local|readonly)\s+(?:-\w+\s+)*'
r'([A-Za-z_][A-Za-z0-9_]*)', driver))
assigns |= set(re.findall(r'\bfor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\b', driver))
# `read` binds names too, and it is almost never at end of line: the shape that
# matters here is `while read -r n; do`. An end-anchored pattern misses it and
# the guard then demands a definition for a loop variable the payload binds
# itself — a false alarm, which is the one failure a guard cannot afford.
for m in re.finditer(r'\bread\b((?:\s+-\w+)*(?:\s+[A-Za-z_][A-Za-z0-9_]*)+)', driver):
assigns |= set(re.findall(r'[A-Za-z_][A-Za-z0-9_]*', m.group(1)))
# Names the driver INHERITS rather than defines: the shell's own, and the ones
# `source ~/aeneas-toolchain/env.sh` puts in the environment. Keep this list
# short and justified — every entry is a hole in the guard.
ENV = {'PWD', 'HOME', 'PATH', 'IFS', 'PIPESTATUS', 'BASH_SOURCE', 'FUNCNAME',
'LINENO', 'RANDOM', 'SECONDS', 'OSTYPE', 'HOSTNAME', 'USER', 'SHELL',
'TMPDIR', 'LC_ALL', 'LANG', 'BASH_REMATCH', 'REPLY', 'PS4',
'AENEAS_HOME', 'LEAN_PATH', 'LEAN_MEM_MB', 'LEAN_TIMEOUT',
'LEAN_MAX_CORES'}
# INDIRECT EXPANSION DEFEATS TEXT ANALYSIS, so say so instead of staying
# silent. `n=Q; echo "${!n}"` reads Q, and no amount of pattern-matching
# recovers that from the source. The guard's contract is that it does not miss
# a dependency; where it cannot honour that it must refuse, not shrug.
# `${!...}` HAS THREE MEANINGS IN BASH and only one of them is indirection:
# ${!name} indirect expansion — genuinely unanalysable
# ${!arr[@]} ${!arr[*]} array KEY expansion — ordinary, and LIVE at
# ltl-accumulator check.sh:274, `for cert in
# "${!CONES[@]}"`
# ${!prefix*} ${!prefix@} variable-NAME listing — ordinary
# Round-9 review (Claude, N1). The round-8 refusal tested for `${!` and could
# not tell them apart, so a legitimate construct would have hard-failed a lift
# with a diagnostic naming a feature it does not use. A refusal is the most
# expensive verdict this tool has; it must be reserved for the case it is
# actually about.
INDIRECT = re.compile(r'\$\{!\s*[A-Za-z_][A-Za-z0-9_]*\s*\}')
if INDIRECT.search(payload):
print('INDIRECT-EXPANSION')
else:
print(' '.join(sorted(n for n in reads - assigns - ENV if not n.isdigit())))
PYGUARD
)
if [ "$UNBOUND" = "INDIRECT-EXPANSION" ]; then
cat <<EOF
FATAL: $LABEL uses indirect expansion (\${!name}).
The set of variables it reads cannot be derived from its text, so this
guard cannot certify that the lift carries them. Rewrite the phase
without indirection, or lift it with a driver that is known-complete by
other means and say so in the self-test.
EOF
exit 1
fi
if [ -n "$UNBOUND" ]; then
cat <<EOF
FATAL: $LABEL reads variables this lift does not define: $UNBOUND
Either lift their definitions too — VERBATIM from check.sh, with a
sed range, so this test cannot drift away from the button's idea of
them — or end the lift range before the block that uses them.
Do NOT stub them: a stub makes the test measure something the button
never runs, which is how a self-test becomes decoration.
EOF
exit 1
fi

View file

@ -38,18 +38,29 @@ cp "$HERE/HARNESS.sha256" "$STASH/HARNESS.sha256"
# Lift Phase 0c. The two repo families end the phase differently, so accept # Lift Phase 0c. The two repo families end the phase differently, so accept
# either terminator rather than hardcoding one and silently lifting nothing. # either terminator rather than hardcoding one and silently lifting nothing.
# The payload is written to its OWN file before the driver is assembled, for two
# reasons that both bit this repository. The size sanity check below has to
# measure what was LIFTED; measured on the concatenated driver it counts the
# prepended bindings too, so a lift that recovered almost nothing still clears
# the threshold (round-7 lift-guard-payload-assert, closed in the four forks
# with "all six lift sites" — six counted the forks, and these two accumulator
# lifts were never in that count). And lift-guard needs payload and driver
# separately to tell what the phase READS from what the driver DEFINES.
DRIVER="$STASH/phase0c.sh" DRIVER="$STASH/phase0c.sh"
PAYLOAD="$STASH/payload.sh"
awk '/^# ── Phase 0c/{f=1} f{print} /^# ── Phase 1|^echo "=== Phase 1/{if(f && !/Phase 0c/) exit}' "$HERE/check.sh" \
| sed '/^# ── Phase 1/d; /^echo "=== Phase 1/d' > "$PAYLOAD"
{ {
echo 'set -euo pipefail' # -e matches the button; see lift-drivers-drop-errexit echo 'set -euo pipefail' # -e matches the button; see lift-drivers-drop-errexit
echo "HERE=\"$HERE\"" echo "HERE=\"$HERE\""
awk '/^# ── Phase 0c/{f=1} f{print} /^# ── Phase 1|^echo "=== Phase 1/{if(f && !/Phase 0c/) exit}' "$HERE/check.sh" \ cat "$PAYLOAD"
| sed '/^# ── Phase 1/d; /^echo "=== Phase 1/d'
} > "$DRIVER" } > "$DRIVER"
if [ "$(grep -c . "$DRIVER")" -lt 20 ]; then if [ "$(grep -c . "$PAYLOAD")" -lt 20 ]; then
echo "FATAL: could not lift Phase 0c out of check.sh — the phase markers moved." echo "FATAL: could not lift Phase 0c out of check.sh — the phase markers moved."
echo "This self-test must attack the shipping gate; refusing to run against nothing." echo "This self-test must attack the shipping gate; refusing to run against nothing."
exit 1 exit 1
fi fi
"$HERE/lift-guard.sh" "$PAYLOAD" "$DRIVER" "check.sh Phase 0c" || exit 1
expect() { # expect <label> <want-rc> <want-substring> expect() { # expect <label> <want-rc> <want-substring>
local label="$1" want_rc="$2" want_txt="$3" out rc local label="$1" want_rc="$2" want_txt="$3" out rc

View file

@ -46,18 +46,26 @@ cp "$T/AUDIT-MANIFEST.txt" "$T/MANIFEST.pristine"
# Phase 3d, lifted verbatim from the shipping button. HERE and INVLOG are the # Phase 3d, lifted verbatim from the shipping button. HERE and INVLOG are the
# two variables it reads from its surroundings. # two variables it reads from its surroundings.
# Payload to its own file before the driver is assembled: the size check must
# measure what was LIFTED, not the lift plus its bindings, and lift-guard needs
# the two apart to separate what the phase READS from what the driver DEFINES.
DRIVER="$T/phase3d.sh" DRIVER="$T/phase3d.sh"
PAYLOAD="$T/payload3d.sh"
sed -n '/^# -- Phase 3d/,/^# -- Phase 4/p' "$SRC/check.sh" | sed '$d' > "$PAYLOAD"
{ {
echo 'set -euo pipefail' # -e matches the button; see lift-drivers-drop-errexit echo 'set -euo pipefail' # -e matches the button; see lift-drivers-drop-errexit
echo "HERE=\"$T\"" echo "HERE=\"$T\""
echo 'INVLOG="$1"' echo 'INVLOG="$1"'
sed -n '/^# -- Phase 3d/,/^# -- Phase 4/p' "$SRC/check.sh" | sed '$d' cat "$PAYLOAD"
} > "$DRIVER" } > "$DRIVER"
if [ "$(wc -l < "$DRIVER")" -lt 40 ]; then if [ "$(wc -l < "$PAYLOAD")" -lt 40 ]; then
echo "FATAL: could not lift Phase 3d out of check.sh — the phase markers moved." echo "FATAL: could not lift Phase 3d out of check.sh — the phase markers moved."
echo "This self-test must attack the shipping gate; refusing to run against nothing." echo "This self-test must attack the shipping gate; refusing to run against nothing."
exit 1 exit 1
fi fi
# Guarded by the SHIPPING guard in $SRC, not the copy inside the scratch tree:
# a test that vets itself with its own copy of the instrument proves less.
"$SRC/lift-guard.sh" "$PAYLOAD" "$DRIVER" "check.sh Phase 3d" || exit 1
# Recompile the edited leaf module + the inventory into $T/inv.out. # Recompile the edited leaf module + the inventory into $T/inv.out.
build_inventory() { build_inventory() {