AtlatestRepositoryfolio
1
#!/usr/bin/env bash2
#3
# F1a sabotage -- proof that the gate can go RED, one named signature per leg.4
#5
# A gate reported only green has not been shown to work; it has been shown to be6
# quiet. Each leg below plants a REAL DEFECT IN THE PRODUCT (not in an7
# assertion, except the one leg that says so) and requires a SPECIFIC failure8
# signature, not merely a non-zero exit. Exit codes lie: 126 is a script that is9
# not executable, 127 is a binary that is not there, and both look like a10
# convincing red while running no assertions at all.11
#12
# RESTORE IS UNCONDITIONAL, NOT REACHED. Originals are copied aside up front and13
# put back from an `EXIT INT TERM` trap, then verified BY SHA256 rather than by14
# the exit status of `cp`. A driver whose undo is a step that must be reached15
# leaves the tree corrupted the moment `set -e` fires between damage and repair.16
# `git checkout -- <path>` is deliberately not used: it restores from the last17
# COMMIT, so on an uncommitted tree it silently discards real work along with18
# the sabotage, and exits 0 either way.19
set -uo pipefail21
repo_dir="$(cd "$(dirname "$0")/.." && pwd)"22
cd "$repo_dir"24
sigil="${SIGIL_F1A_COMPILER:-$repo_dir/../task-sigil-language-readers/build/dev/bin/sigil}"25
redirects="${SIGIL_F1A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"26
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$(mktemp -d)}"27
# A DIRECT script run has no `--redirects` flag and reads the environment.28
# Without this, S5's probe dies with "Missing dependencies" -- which IS a29
# non-zero exit, and a driver that accepted "exited non-zero" as proof of a red30
# would have scored that leg as a PASS while the probe never ran a single check.31
# That is precisely why every leg below demands a NAMED SIGNATURE as well.32
export SIGIL_REDIRECTS="$redirects"34
targets=(35
src/folio/doc/model.sgl36
src/folio/doc/publication.sgl37
src/folio/doc/traversal.sgl38
src/folio/doc/render/plain.sgl39
src/folio/doc/render/debug.sgl40
test/test-folio-doc-model.sgl41
)42
backup_dir=$(mktemp -d)43
declare -A original_hash44
for t in "${targets[@]}"; do45
mkdir -p "$backup_dir/$(dirname "$t")"46
cp "$t" "$backup_dir/$t"47
original_hash["$t"]=$(sha256sum "$t" | cut -d' ' -f1)48
done50
restore_all() {51
local bad=052
for t in "${targets[@]}"; do53
cp "$backup_dir/$t" "$t"54
local now55
now=$(sha256sum "$t" | cut -d' ' -f1)56
if [ "$now" != "${original_hash[$t]}" ]; then57
echo "RESTORE FAILED for $t (${original_hash[$t]} != $now)" >&258
bad=159
fi60
done61
[ "$bad" = "0" ] || { echo "TREE MAY BE DIRTY -- CHECK BEFORE COMMITTING" >&2; exit 9; }62
}63
trap restore_all EXIT INT TERM65
# changed <target> -- a leg must PROVE it changed the file it claims to have66
# sabotaged. A `sed` that matches nothing exits 0, and a leg that quietly67
# edited nothing is indistinguishable from a gate that failed to notice a real68
# defect. Legs that PLANT a new string still grep for it; this is for the ones69
# that REMOVE one, where there is no new text to look for.70
changed() {71
local t="$1" now72
now=$(sha256sum "$t" | cut -d' ' -f1)73
test "$now" != "${original_hash[$t]}" || {74
echo "SABOTAGE DID NOT CHANGE $t" >&2; exit 1; }75
}77
legs_run=078
legs_red=080
# $1 human name, $2 signature that must appear, $3 command81
expect_red() {82
local name="$1" signature="$2" cmd="$3"83
legs_run=$((legs_run + 1))84
local log; log=$(mktemp)85
# The surrounding script runs WITHOUT errexit on purpose (a leg going red must86
# not end the run). An earlier version restored it with a bare `set -e` here,87
# which silently switched errexit ON for everything after the first leg.88
eval "$cmd" > "$log" 2>&189
local status=$?90
if [ "$status" = "0" ]; then91
echo " [$name] DID NOT GO RED - the gate does not catch this" >&292
tail -15 "$log" >&293
return 194
fi95
# The runner colours its output, so strip escapes before matching a signature96
# -- the gate already learned this the hard way on its summary line.97
local clean="$log.clean"98
sed 's/\x1b\[[0-9;]*m//g' "$log" > "$clean"99
if ! signature_in_failure "$clean" "$signature"; then100
echo " [$name] went red, but NOT for the expected reason." >&2101
echo " expected signature: $signature" >&2102
grep -nE '^[[:space:]]*✗ ' "$clean" >&2 || tail -20 "$clean" >&2103
return 1104
fi105
legs_red=$((legs_red + 1))106
echo " [$name] RED as required, signature matched: $signature"107
return 0108
}111
# signature_in_failure <ansi-stripped-log> <signature>112
#113
# THE ATTRIBUTION CHECK ABOVE COULD NOT FIRE FOR ANY SUITE LEG UNTIL 2026-08-22.114
# `sigil test` ECHOES EVERY TEST'S NAME, passing ones included, so matching an115
# arm's name anywhere in the log succeeds on the line reporting that arm PASSING.116
# Measured against a 36/36 GREEN suite log: all eight suite-arm signatures this117
# driver uses appear in it, each on a `✓` line. So "went red, but NOT for the118
# expected reason" was unreachable -- a red from ANY arm scored as a red at the119
# expected one, and every leg's attribution was decorative. This is the same120
# defect `test/sabotage-sd-p3.sh` in sigil-doc names in its own header.121
#122
# The discriminator is deliberately generous: anything that looks like a runner123
# log at all gets the strict `✗` anchor. Being wrong in that direction costs a124
# loud failure; being wrong the other way restores the vacuous check.125
signature_in_failure() {126
local clean="$1" signature="$2"127
if grep -qE '^[[:space:]]*[✓✗] ' "$clean" \128
|| grep -qE 'Tests:[[:space:]]+[0-9]+ passed' "$clean"; then129
grep -E '^[[:space:]]*✗ ' "$clean" | grep -qF -- "$signature"130
else131
# Not a runner log: the publication probe or the gate itself, whose132
# signatures are strings ONLY the failing path prints -- ` FAIL: <label>`133
# (the probe emits nothing at all on the passing branch) and134
# `F1A GATE RED: <reason>`. Verified: a green probe log contains zero135
# occurrences of S5's signature.136
grep -qF -- "$signature" "$clean"137
fi138
}140
# CONTROL FOR THE ANCHOR. It replaced a check that could not fire, so it does not141
# get to be trusted on its shape. Synthetic logs, no compiler, three directions.142
_anchor_ctl=$(mktemp -d)143
printf ' ✓ an arm named exactly like the signature <1ms\nTests: 1 passed (1 total)\n' \144
>"$_anchor_ctl/green"145
printf ' ✗ an arm named exactly like the signature\nTests: 0 passed, 1 failed (1 total)\n' \146
>"$_anchor_ctl/red"147
printf ' FAIL: a probe label\n' >"$_anchor_ctl/probe"148
if signature_in_failure "$_anchor_ctl/green" "an arm named exactly like the signature"; then149
echo "ANCHOR CONTROL FAILED: a signature on a PASSING runner line was accepted" >&2; exit 1; fi150
if ! signature_in_failure "$_anchor_ctl/red" "an arm named exactly like the signature"; then151
echo "ANCHOR CONTROL FAILED: a signature on a FAILING runner line was rejected" >&2; exit 1; fi152
if ! signature_in_failure "$_anchor_ctl/probe" "FAIL: a probe label"; then153
echo "ANCHOR CONTROL FAILED: a probe's failure-only string was rejected" >&2; exit 1; fi154
rm -rf "$_anchor_ctl"155
echo "attribution anchor armed: refused on a ✓ line, accepted on a ✗ line, probe strings still matched"157
run_suite() { "$sigil" -L build/dev/lib test --redirects "$redirects" test/test-folio-doc-model.sgl; }158
run_probe() { "$sigil" -L build/dev/lib -L src -L test test/f1a-publication-probe.sgl; }160
echo "=== F1a sabotage: $(git rev-parse HEAD) ==="162
# --- S1 PRODUCT: defensive ownership removed from one accessor --------------163
echo "S1: note-children stops copying its list spine"164
sed -i 's/(define (note-children x) (copy-list (note-record-children x)))/(define (note-children x) (note-record-children x))/' \165
src/folio/doc/model.sgl166
grep -q '(define (note-children x) (note-record-children x))' src/folio/doc/model.sgl \167
|| { echo "S1 patch did not apply" >&2; exit 1; }168
expect_red "S1 ownership" "aggregate ownership is mutation-proof" "run_suite" || true169
restore_all171
# --- S2 PRODUCT: `next` re-enters the state vocabulary ----------------------172
echo "S2: 'next' is admitted as a task status"173
sed -i "s/(define task-statuses '(inbox ready active blocked done cancelled))/(define task-statuses '(inbox ready next active blocked done cancelled))/" \174
src/folio/doc/model.sgl175
grep -q "inbox ready next active" src/folio/doc/model.sgl || { echo "S2 patch did not apply" >&2; exit 1; }176
expect_red "S2 D6 ready-not-next" "ready is a status, never a priority" "run_suite" || true177
restore_all179
# --- S3 PRODUCT: the flat node-id grammar is loosened to admit `/` ----------180
# This is the exact thing D12 says never to do.181
echo "S3: valid-node-id? starts accepting a slash"182
sed -i 's|(or (and (char>=? c #\\a) (char<=? c #\\z))|(or (char=? c #\\/) (and (char>=? c #\\a) (char<=? c #\\z))|' \183
src/folio/doc/model.sgl184
grep -q 'char=? c #\\/' src/folio/doc/model.sgl || { echo "S3 patch did not apply" >&2; exit 1; }185
expect_red "S3 D12 two grammars" "node ids stay flat and never admit a slash" "run_suite" || true186
restore_all188
# --- S4 PRODUCT: phase order uniqueness stops being enforced ----------------189
echo "S4: phase order no longer has to increase"190
sed -i 's/(if (and previous (<= order previous))/(if (and previous #f (<= order previous))/' \191
src/folio/doc/model.sgl192
grep -q 'and previous #f' src/folio/doc/model.sgl || { echo "S4 patch did not apply" >&2; exit 1; }193
expect_red "S4 ordered container" "phase order must be positive, unique and strictly increasing" "run_suite" || true194
restore_all196
# --- S5 PRODUCT: the unresolved query marker silently disappears ------------197
# The whole failure this requirement exists to prevent: rendering still works,198
# still validates, and simply says less than the source did.199
echo "S5: query lowering emits ordinary text instead of an unresolved marker"200
sed -i 's|((query? n) (para (unresolved-query-text n) (query-location n)))|((query? n) (para "results" (query-location n)))|' \201
src/folio/doc/publication.sgl202
grep -q '(para "results"' src/folio/doc/publication.sgl || { echo "S5 patch did not apply" >&2; exit 1; }203
expect_red "S5 publication fallback" "FAIL: plain marks the unresolved query" "run_probe" || true204
restore_all206
# --- S6 PRODUCT: backend vocabulary in the semantic core --------------------207
# S6 RUNS THE REAL GATE. It previously re-implemented arm 5's grep inline and208
# printed its own "signature matched" string, which meant deleting arm 5 from209
# gate-f1a.sh entirely would still have shown this leg RED. It proved that grep210
# can find a word that was just appended to a file -- a check validating itself.211
# Running the gate is slower; a leg that cannot fail is worth nothing at any212
# speed. The signature is now arm 5's own refusal message, which only213
# gate-f1a.sh can emit.214
echo "S6: SQLite vocabulary planted in the semantic core"215
echo ';; index-backed by sqlite for speed' >> src/folio/doc/model.sgl216
grep -q 'sqlite for speed' src/folio/doc/model.sgl || { echo "S6 patch did not apply" >&2; exit 1; }217
expect_red "S6 leakage" "backend/runtime vocabulary leaked into the semantic core" \218
"bash test/gate-f1a.sh" || true219
restore_all221
# --- S7 ASSERTION LAYER: the canonical datum literal is wrong ---------------222
# Deliberately the ONLY assertion-layer leg. Assertion sabotage proves the223
# harness notices when the harness changes; it says nothing about whether the224
# suite is executing the real product. The NINE product-layer legs in this driver225
# are what establish that, which is why they outnumber this one nine to one.226
echo "S7: the expected canonical datum is altered (assertion layer)"227
sed -i 's/(status ready) (priority low) (tags) (due #f) (children) source)/(status ready) (priority high) (tags) (due #f) (children) source)/' \228
test/test-folio-doc-model.sgl229
grep -q '(priority high) (tags) (due #f)' test/test-folio-doc-model.sgl \230
|| { echo "S7 patch did not apply" >&2; exit 1; }231
expect_red "S7 canonical datum" "canonical datum of the note root is exact" "run_suite" || true232
restore_all234
# --- S8 PRODUCT: `update-id` becomes a constant #f in the TRAVERSAL ----------235
# THE LEG THIS TICKET EXISTS FOR. Until 2026-08-22 `traversal.sgl` had no236
# sabotage leg in ANY driver in this family, while `gate-f1a.sh` arm 8 READS it237
# -- slicing each record's datum branch out of it to check field totality. An238
# input the gate consumes and cannot notice is wrong is the worst combination239
# there is, and it is not hypothetical: review planted exactly this defect and240
# got 35/35 GREEN from a broken product. The corpus's only update carried #f241
# anyway, and no assertion pinned the id BY VALUE in the rendered views.242
#243
# The suite was fixed at the time (see the "a declared update id reaches the244
# datum, the plain text and the lowering" arm, and the `u-f0review` id the245
# pressure manuscript now carries). NOTHING KEPT IT FIXED. This leg does.246
#247
# Both call sites go, not one: `folio-events` and `folio->datum` each read the248
# id, and replacing only one leaves the other instrument still telling the249
# truth -- which is how a two-instrument design hides a single-instrument break.250
echo "S8: update-id is replaced by a constant #f throughout the traversal"251
sed -i 's/(update-id n)/#f/g' src/folio/doc/traversal.sgl252
test "$(grep -c '(update-id n)' src/folio/doc/traversal.sgl || true)" = "0" \253
|| { echo "S8 patch did not apply" >&2; exit 1; }254
changed src/folio/doc/traversal.sgl255
expect_red "S8 traversal update identity" \256
"a declared update id reaches the datum, the plain text and the lowering" "run_suite" || true257
restore_all259
# --- S9 PRODUCT: statuses stop being visible words in the plain rendering ----260
# `render/plain.sgl`'s own header said, in the tree, that no sabotage leg261
# targeted it and that the gap was filed rather than papered over. This is that262
# leg. The rule the renderer is written to is SEMANTIC FACTS BECOME VISIBLE263
# WORDS, and the failure it guards is silent: a rendering that dropped statuses264
# is still valid output that simply says less than the source did.265
#266
# The BINDING stays and only the BEHAVIOUR goes, so this is a behaviour test267
# rather than a compile error -- a leg that fails to compile proves nothing268
# about what the suite can see.269
echo "S9: render/plain's status-of stops producing a status word"270
sed -i 's/^\( *\)(define (status-of symbol).*$/\1(define (status-of symbol) (begin symbol ""))/' \271
src/folio/doc/render/plain.sgl272
grep -q '(define (status-of symbol) (begin symbol ""))' src/folio/doc/render/plain.sgl \273
|| { echo "S9 patch did not apply" >&2; exit 1; }274
changed src/folio/doc/render/plain.sgl275
expect_red "S9 plain statuses" \276
"every work item status survives folio rendering and the lowering" "run_suite" || true277
restore_all279
# --- S10 PRODUCT: the debug view becomes a second serialization -------------280
# `render/debug.sgl` is eleven lines and is deliberately an ALIAS for281
# `folio->string`. That makes it look like a file too small to be worth a leg,282
# which is precisely the argument that leaves a file uncovered: the defect it283
# guards against is somebody "improving" the debug view into a second284
# formatter, and a second serialization nobody gates is the output people read285
# while reasoning about the canonical one.286
echo "S10: folio->debug stops being folio->string"287
sed -i 's/(folio->string root)/(begin root "")/' src/folio/doc/render/debug.sgl288
grep -q '(begin root "")' src/folio/doc/render/debug.sgl \289
|| { echo "S10 patch did not apply" >&2; exit 1; }290
changed src/folio/doc/render/debug.sgl291
expect_red "S10 debug divergence" \292
"debug rendering is the canonical string, not a second serialization" "run_suite" || true293
restore_all296
echo297
echo "F1A_SABOTAGE legs=$legs_run red=$legs_red"298
if [ "$legs_run" != "$legs_red" ]; then299
echo "SABOTAGE INCOMPLETE: $((legs_run - legs_red)) leg(s) failed to go red" >&2300
exit 1301
fi302
echo "F1A_SABOTAGE_COMPLETE all legs red"