AtlatestRepositorysigil-system
sigil-system / tree / scriptsgate-system-grant-migration
1
#!/bin/sh2
# gate-system-grant-migration - the gate for rebuilding3
# (sigil system grant) as a profile over (sigil grant).4
#5
# Outcomes, four distinct things:6
# MIGRATION COMPLETE PASS (0) every check ran and passed7
# MIGRATION COMPLETE FAIL (1) a check ran and failed8
# MIGRATION TIMED-OUT (2) a bounded step did not finish9
# MIGRATION SETUP-FAILED (3) the subject was never established10
#11
# A MIGRATION'S FAILURE MODE IS SILENT DIVERGENCE, so the load-bearing12
# arm is a DIFFERENTIAL: the migrated grant-check must agree with the13
# pre-migration one decision for decision. The agreement count is14
# printed and asserted non-zero, because "they agreed on nothing because15
# neither ran" and "they agreed on everything" are otherwise the same16
# colour.17
#18
# Scratch goes to /tmp. Nothing is written under the repo except build19
# output.21
set -u23
REPO="$(cd "$(dirname "$0")/.." && pwd)"24
SCRATCH="$(mktemp -d /tmp/gate-sysgrant.XXXXXX)"25
FIXTURE="$SCRATCH/fixture"26
TIMEOUT_S="${GATE_SYSGRANT_TIMEOUT:-900}"28
SIGIL="${GATE_SYSGRANT_SIGIL:-/home/daviwil/Projects/Code/sigil/task-sigil-grant-p1-algebra/build/dev/bin/sigil}"29
REDIRECTS="${GATE_SYSGRANT_REDIRECTS:-/tmp/grant-dev-redirects.sgl}"30
PROBE="${GATE_SYSGRANT_PROBE:-$REPO/scripts/grant-differential-probe.sgl}"31
MODULE="$REPO/src/sigil/system/grant.sgl"32
BASE_REF="${GATE_SYSGRANT_BASE:-944b328}"34
say() { echo "$@"; }35
rule() { say "------------------------------------------------------------"; }37
doubt() {38
say ""39
say "BEFORE READING THIS AS A PRODUCT DEFECT: suspect this gate."40
say " Check that the sigil it used ($SIGIL) is the one you think, that"41
say " the redirect actually applied (step 2 proves it BEHAVIOURALLY --"42
say " a clean build proves nothing here, Sigil is late-binding), and"43
say " that the decision counts below are non-zero."44
}46
dump_counts() {47
if [ -s "$SCRATCH/migrated.txt" ] || [ -s "$SCRATCH/baseline.txt" ]; then48
say ""49
say "--- instrument state at exit ---"50
say " baseline decision lines: $(grep -c '^CHK|' "$SCRATCH/baseline.txt" 2>/dev/null || true)"51
say " migrated decision lines: $(grep -c '^CHK|' "$SCRATCH/migrated.txt" 2>/dev/null || true)"52
say "--- end ---"53
fi54
}56
setup_failed() {57
rule; say "SETUP-FAILED: $*"58
say "Nothing was tested. This is NOT a pass and NOT a failure."59
dump_counts; doubt60
say "Logs kept at: $SCRATCH"61
say "MIGRATION SETUP-FAILED"; exit 362
}64
red() {65
rule; say "FAILED: $*"66
dump_counts; doubt67
say "Logs kept at: $SCRATCH"68
say "MIGRATION COMPLETE FAIL"; exit 169
}71
timed_out() {72
rule; say "TIMED-OUT after ${TIMEOUT_S}s: $*"73
say ""74
say "CHECKS THAT DID NOT RUN:"75
say " - the differential (baseline vs migrated decisions)"76
say " - the existing test-system suite"77
dump_counts; doubt78
say "Logs kept at: $SCRATCH"79
say "MIGRATION TIMED-OUT"; exit 280
}82
restore_module() {83
git -C "$REPO" checkout HEAD -- src/sigil/system/grant.sgl 2>/dev/null84
}85
trap 'restore_module' EXIT HUP INT TERM87
for t in awk grep sed timeout git sha256sum diff; do88
command -v "$t" >/dev/null 2>&1 || setup_failed "required tool '$t' is not on PATH"89
done90
[ -x "$SIGIL" ] || setup_failed "no sigil binary at $SIGIL"91
[ -f "$REDIRECTS" ] || setup_failed "no redirect file at $REDIRECTS -- (sigil grant) is unreleased, so the build needs one"92
[ -f "$PROBE" ] || setup_failed "no differential probe at $PROBE"93
[ -f "$MODULE" ] || setup_failed "module source missing: $MODULE"95
# The module must be COMMITTED before this gate runs: the differential96
# checks the base version out over it and restores with `git checkout97
# HEAD --`, which restores to the LAST COMMIT. An uncommitted module98
# would be destroyed by that restore, and its absence afterwards looks99
# exactly like a clean restore.100
[ -z "$(git -C "$REPO" status --porcelain src/sigil/system/grant.sgl)" ] || setup_failed \101
"src/sigil/system/grant.sgl has uncommitted changes. This gate swaps it against $BASE_REF and restores with 'git checkout HEAD --', which would revert your work rather than the swap. Commit first."103
rule104
say "GATE gate-system-grant-migration"105
say " repo: $REPO"106
say " sigil: $SIGIL"107
say " version: $("$SIGIL" --version 2>&1 | head -1)"108
say " redirects: $REDIRECTS"109
say " base ref: $BASE_REF ($(git -C "$REPO" rev-parse --short "$BASE_REF"))"110
rule112
say "STEP 1/5 fixture"113
mkdir -p "$FIXTURE/allow" "$FIXTURE/allowed" "$FIXTURE/a,b" "$SCRATCH/outside"114
ln -sfn "$SCRATCH/outside" "$FIXTURE/link-out"115
[ -d "$FIXTURE/a,b" ] || setup_failed "could not create the comma-bearing fixture directory"116
[ -L "$FIXTURE/link-out" ] || setup_failed "could not create the escaping symlink"117
say " root: $FIXTURE (allow/ allowed/ 'a,b'/ link-out -> outside/)"119
# PROVENANCE, behaviourally and from BOTH sides. The redirect is not120
# picked up automatically and has been seen not to apply, silently,121
# with the build still compiling clean -- Sigil is late-binding, so a122
# COMPILE line and a zero exit say nothing about which (sigil grant) is123
# in force. Two-sided so a merely permissive module cannot pass.124
say "STEP 2/5 provenance of (sigil grant)"125
P1="$("$SIGIL" eval '(begin (import (sigil grant)) (display (grant->specs (grant-from-specs (list "fs:rw:/x/a,b")))))' 2>&1)"126
case "$P1" in127
*"fs:rw:/x/a,b"*) say " accepts a comma-bearing fs path: ok" ;;128
*) setup_failed "the (sigil grant) in force REFUSES a comma-bearing fs path, so it predates the fix. Got: $P1" ;;129
esac130
P2="$("$SIGIL" eval '(begin (import (sigil grant)) (display (guard (e (#t "REFUSED")) (make-grant (list (list (quote exec) (quote allowlist) (list "a,b")))))))' 2>&1)"131
case "$P2" in132
*REFUSED*) say " still refuses a comma in an exec command: ok" ;;133
*) setup_failed "the (sigil grant) in force ACCEPTS a comma in an exec command; it is not the module under test. Got: $P2" ;;134
esac136
say "STEP 3/5 differential vs $BASE_REF"137
MINE_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"138
say " migrated module sha256: $MINE_HASH"140
# `sigil eval` has NO --redirects and resolves the RELEASED dependency,141
# so it can see neither the redirected (sigil grant) nor this package's142
# own source. `sigil test <file>` is the package-aware runner that can.143
run_probe() {144
GRANT_DIFF_ROOT="$FIXTURE" timeout "$TIMEOUT_S" \145
"$SIGIL" test --redirects "$REDIRECTS" "$PROBE" > "$1.raw" 2>&1146
rc=$?147
[ "$rc" -ne 124 ] || timed_out "the differential probe did not finish"148
# Keep ONLY the probe's own lines, and say why rather than filtering149
# silently: the runner's chrome carries per-run durations and temp150
# paths, which differ between two runs of the SAME code and would151
# manufacture a divergence. The probe's prefixes are exactly the152
# observable decisions. The surviving count is asserted non-zero153
# below, so this filter cannot quietly empty the comparison.154
sed 's/\x1b\[[0-9;]*m//g' "$1.raw" \155
| grep -E '^(ADD|CHK|LOG|DECISIONS|GRANTS-PRED|PRINCIPAL)' > "$1"156
return "$rc"157
}159
run_probe "$SCRATCH/migrated.txt" || {160
say "--- probe output tail ---"; tail -20 "$SCRATCH/migrated.txt"161
red "the differential probe failed against the MIGRATED module"162
}164
git -C "$REPO" checkout "$BASE_REF" -- src/sigil/system/grant.sgl || setup_failed "could not check out the base module"165
BASE_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"166
say " baseline module sha256: $BASE_HASH"167
[ "$BASE_HASH" != "$MINE_HASH" ] || setup_failed \168
"the baseline and migrated modules are the SAME FILE. The differential would compare a thing to itself and agree for the wrong reason."170
run_probe "$SCRATCH/baseline.txt" || {171
say "--- probe output tail ---"; tail -20 "$SCRATCH/baseline.txt"172
restore_module173
red "the differential probe failed against the BASELINE module"174
}176
restore_module177
NOW_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"178
[ "$NOW_HASH" = "$MINE_HASH" ] || red \179
"the migrated module was NOT restored after the differential (hash $NOW_HASH, expected $MINE_HASH)"180
say " module restored, verified against its pre-swap hash"182
MIG_N="$(grep -c '^CHK|' "$SCRATCH/migrated.txt")"183
BASE_N="$(grep -c '^CHK|' "$SCRATCH/baseline.txt")"184
say " decisions compared: migrated $MIG_N / baseline $BASE_N"185
[ "$MIG_N" -gt 0 ] || red "the migrated probe produced ZERO decisions; the differential compared nothing"186
[ "$BASE_N" -gt 0 ] || red "the baseline probe produced ZERO decisions; the differential compared nothing"187
[ "$MIG_N" -eq "$BASE_N" ] || red "the two probes produced different numbers of decisions ($MIG_N vs $BASE_N)"189
# A migration may INTEND to change a decision. Byte-identity would then190
# force either a failing gate or a corpus with the interesting case191
# quietly removed, and the second is how a differential comes to agree192
# for the wrong reason. So intended divergences are declared BY SHAPE,193
# checked FOR DIRECTION, and counted.194
#195
# The one declared here: the old engine granted the EMPTY command.196
# `exec:allowlist:git,` split to ("git" "") and the "" entry became a197
# permission. Nothing could ever match it (argv[0] is never ""), so it198
# was unreachable authority, and the migration drops it. Every such199
# difference must be a NARROWING, baseline #t becoming migrated #f.200
# A widening of the same shape is a defect and still reds.201
diff -u "$SCRATCH/baseline.txt" "$SCRATCH/migrated.txt" > "$SCRATCH/diff.txt" 2>&1202
grep -E '^[-+]CHK\|' "$SCRATCH/diff.txt" > "$SCRATCH/diffchk.txt" 2>/dev/null || true204
# Anything differing that is NOT the declared shape.205
UNEXPECTED_DIFF="$(grep -E '^[-+]CHK\|' "$SCRATCH/diff.txt" 2>/dev/null \206
| grep -vE '^-CHK\|[^|]*\|exec\|""\|#t$' \207
| grep -vE '^\+CHK\|[^|]*\|exec\|""\|#f$' || true)"208
if [ -n "$UNEXPECTED_DIFF" ]; then209
say "--- UNDECLARED divergence (baseline vs migrated) ---"210
echo "$UNEXPECTED_DIFF" | head -40211
say "--- full diff head ---"212
head -40 "$SCRATCH/diff.txt"213
red "the migrated engine DISAGREES with the pre-migration engine outside the declared narrowing"214
fi216
NARROWED="$(grep -cE '^\+CHK\|[^|]*\|exec\|""\|#f$' "$SCRATCH/diff.txt" 2>/dev/null || true)"217
NARROWED="${NARROWED:-0}"218
WIDENED="$(grep -cE '^\+CHK\|[^|]*\|exec\|""\|#t$' "$SCRATCH/diff.txt" 2>/dev/null || true)"219
WIDENED="${WIDENED:-0}"220
[ "$WIDENED" -eq 0 ] || red "a declared-shape divergence WIDENED authority ($WIDENED lines). The allowance covers narrowing only."221
AGREED=$((MIG_N - NARROWED))222
say " decisions agreeing: $AGREED of $MIG_N"223
say " declared narrowings (exec \"\" was granted, now denied): $NARROWED, widenings: 0"225
# A differential that agrees is meaningful only if it COULD disagree, and226
# the thing that decides now is the CLASSIFIER above, not `diff`. When a227
# check is repaired by adding a step, the new step is an instrument too228
# and arrives with the same defect rate and none of the scrutiny. So both229
# get a control.230
#231
# Control 1: `diff` itself can see a difference at all.232
CTL="$SCRATCH/control.txt"233
sed 's/^CHK|pty|/CHK|PLANTED|/' "$SCRATCH/migrated.txt" > "$CTL"234
if diff -q "$SCRATCH/baseline.txt" "$CTL" >/dev/null 2>&1; then235
red "the differential's comparison is BLIND: a deliberately altered transcript still compared equal"236
fi237
say " comparison positive control: an altered transcript IS detected"239
# Control 2, and this is the one that matters now: an UNDECLARED240
# divergence must be REJECTED by the classifier. Without this, a241
# classifier whose patterns were too broad would wave everything through242
# and the gate would report agreement over a transcript full of changes.243
CTL2="$SCRATCH/control-undeclared.txt"244
sed '0,/^CHK|[^|]*|fs-read|/s//CHK|PLANTED-UNDECLARED|fs-read|/' "$SCRATCH/migrated.txt" > "$CTL2"245
if [ "$(diff -u "$SCRATCH/baseline.txt" "$CTL2" 2>/dev/null | grep -cE '^[-+]CHK\|')" -eq 0 ]; then246
setup_failed "the undeclared-divergence control did not land: the planted line produced no diff"247
fi248
CTL2_LEAK="$(diff -u "$SCRATCH/baseline.txt" "$CTL2" 2>/dev/null | grep -E '^[-+]CHK\|' \249
| grep -vE '^-CHK\|[^|]*\|exec\|""\|#t$' \250
| grep -vE '^\+CHK\|[^|]*\|exec\|""\|#f$' || true)"251
[ -n "$CTL2_LEAK" ] || red \252
"the classifier is TOO BROAD: a deliberately planted UNDECLARED divergence was accepted as declared. Every difference would pass."253
say " classifier positive control: an undeclared divergence IS rejected"255
say "STEP 4/5 existing test-system suite"256
timeout "$TIMEOUT_S" "$SIGIL" test --redirects "$REDIRECTS" > "$SCRATCH/suite.log" 2>&1257
SRC=$?258
[ "$SRC" -ne 124 ] || timed_out "the test-system suite did not finish"259
SUM="$(sed 's/\x1b\[[0-9;]*m//g' "$SCRATCH/suite.log" | grep -E '^ Tests:' | tail -1)"260
[ -n "$SUM" ] || red "the suite printed no 'Tests:' summary line"261
PASSED="$(echo "$SUM" | sed -E 's/.*Tests:[[:space:]]*([0-9]+) passed.*/\1/')"262
TOTAL="$(echo "$SUM" | sed -E 's/.*\(([0-9]+) total\).*/\1/')"263
say " $PASSED passed of $TOTAL"264
[ "$TOTAL" -gt 0 ] || red "the suite reported 0 tests"266
# The known pre-existing failure is NAMED, not tolerated by count. It is267
# a missing runtime primitive unrelated to grants, and it was MEASURED268
# on the base module rather than assumed.269
KNOWN="process sessions optionally merge stderr into captured output"270
FAILED=$((TOTAL - PASSED))271
if [ "$FAILED" -gt 0 ]; then272
# Take the names from the runner's "Failed tests:" block ONLY.273
# An earlier version matched `^ <any char> ` and so swallowed every274
# PASSING line too (they are " <checkmark> name"), reporting 37275
# passes as unexpected failures. Anchoring on the block is what makes276
# this a list of failures rather than a list of lines.277
UNEXPECTED="$(sed 's/\x1b\[[0-9;]*m//g' "$SCRATCH/suite.log" \278
| awk '/^ Failed tests:/{f=1;next} f&&/^━|^$/{f=0} f{print}' \279
| sed 's/^[[:space:]]*•[[:space:]]*//' \280
| grep -v "$KNOWN")"281
[ -z "$UNEXPECTED" ] || { say "--- unexpected failures ---"; echo "$UNEXPECTED"; red "$FAILED failing, and not all are the known pre-existing one"; }282
say " $FAILED failing, all of them the known pre-existing:"283
say " '$KNOWN'"284
say " (measured on the BASE module too -- it fails identically there)"285
fi287
say "STEP 5/5 structural"288
CTLHITS="$(grep -c 'grant-check' "$MODULE")"289
[ "$CTLHITS" -gt 0 ] || setup_failed "structural grep is blind: 0 hits for a token that IS in $MODULE"290
say " grep positive control: $CTLHITS hits"292
UNDER="$(grep -c 'path-under?' "$MODULE")"293
[ "$UNDER" -eq 0 ] || red "path-under? is still present ($UNDER refs). It was to be DELETED, not adapted -- if the algebra does not cover it, say so rather than keeping a second implementation."294
say " path-under? deleted: ok"296
for keep in realpath canonical-target; do297
n="$(grep -c "$keep" "$MODULE")"298
[ "$n" -gt 0 ] || red "$keep is gone from the profile. Canonicalisation is this module's reason to exist; the algebra is pure and cannot do it."299
done300
say " realpath + canonical-target retained: ok"302
# Extract the export block into a list of bare symbols and compare by303
# EXACT STRING. An earlier version pattern-matched `^ <sym>$` and304
# reported `grant-decisions` missing when it is plainly there — the last305
# export carries the closing paren (`grant-decisions)`). A false red,306
# caused by matching layout instead of content; symbols containing `?`307
# and `!` also make a regex the wrong tool here.308
# Range runs from `(export` to `(begin`. NOT to the first blank line:309
# the export block is grouped with blank lines between sections, so a310
# blank-line terminator captured 4 of 8 symbols and reported the rest311
# missing — a second false red from the same habit of matching layout.312
awk '/^ \(export/{f=1} /^ \(begin/{f=0} f' "$MODULE" \313
| sed -e 's/;;.*//' -e 's/[()]//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \314
| grep -v '^$' | grep -v '^export$' > "$SCRATCH/exports.txt"315
NEXP="$(wc -l < "$SCRATCH/exports.txt")"316
[ "$NEXP" -gt 0 ] || setup_failed "could not extract any export names from $MODULE"317
say " exports extracted: $NEXP"318
for sym in make-grants grant-allow-all grants? grants-principal grant-add! grant-check grant-assert! grant-decisions; do319
grep -qxF "$sym" "$SCRATCH/exports.txt" || red "export '$sym' is missing from the module's export list"320
done321
say " all 8 public exports present: ok"322
# And nothing NEW is exported: the API is unchanged in both directions,323
# so a consumer cannot start depending on something this migration324
# happened to expose.325
[ "$NEXP" -eq 8 ] || red "the module exports $NEXP symbols, not 8. The public API was to be UNCHANGED; an addition is as much a change as a removal."326
say " exactly 8, nothing added: ok"328
# ------------------------------------------------------------329
# LANDING STATUS. Reported, not asserted: this gate answers "is the330
# migration CORRECT", and correctness is independent of whether the331
# dependency has been released. But the blocker must not be something a332
# reader has to remember, so the gate states it every run and derives it333
# rather than reciting it.334
# ------------------------------------------------------------335
rule336
say "LANDING STATUS"337
FLOOR="$(grep -E '^[[:space:]]*sigil:' "$REPO/package.sgl" | head -1 | sed -E 's/.*sigil:[[:space:]]*"([^"]*)".*/\1/')"338
say " package.sgl declares sigil: \"$FLOOR\""339
MONO="/home/daviwil/Projects/Code/sigil/sigil"340
if [ -d "$MONO/.git" ]; then341
GRANT_COMMIT="$(git -C "$MONO" log --format=%H -1 -- packages/sigil-stdlib/src/sigil/grant.sgl 2>/dev/null)"342
if [ -n "$GRANT_COMMIT" ]; then343
TAGS="$(git -C "$MONO" tag --contains "$GRANT_COMMIT" 2>/dev/null | head -3)"344
if [ -n "$TAGS" ]; then345
say " (sigil grant) is RELEASED in: $(echo "$TAGS" | tr '\n' ' ')"346
# Does the declared floor actually name one of those releases? The347
# question is not "has it shipped" but "does THIS manifest resolve348
# it", and those came apart for most of this branch's life.349
# THE `sigil:` FIELD IS A MINIMUM WITH NO UPPER BOUND, not an exact350
# pin: e530e14 established that a caret is a range while a bare351
# version here is a floor. An earlier version of this check352
# compared floor to tag by STRING EQUALITY and reported "0.21" as353
# not naming v0.21.0, which is false, since 0.21 means >= 0.21.0.354
# It failed CLOSED, so it never licensed a bad merge; it would355
# simply have sent someone to edit a manifest already correct.356
# AND THE COMPARISON RUNS THE OTHER WAY. `sigil:` is a MINIMUM, so357
# any runtime at or above it is permitted. To GUARANTEE the module358
# is present, the floor must be at least the release that359
# introduced it. Asking "does the floor permit some release that360
# has it" is the inverted question and answers YES for a floor of361
# 0.20, which permits 0.20.3, which has no grant.sgl. That is a362
# false green in the dangerous direction and a control caught it.363
#364
# Normalise the floor to three components first: "0.21" and365
# "0.21.0" are the same version, and a text sort disagrees.366
EARLIEST="$(echo "$TAGS" | sed 's/^v//' | sort -V | head -1)"367
NFLOOR="$FLOOR"368
case "$NFLOOR" in369
*.*.*) ;;370
*.*) NFLOOR="$NFLOOR.0" ;;371
*) NFLOOR="$NFLOOR.0.0" ;;372
esac373
SATISFIED=no374
[ "$(printf '%s\n%s\n' "$NFLOOR" "$EARLIEST" | sort -V | head -1)" = "$EARLIEST" ] && SATISFIED=yes375
say " earliest release with it: $EARLIEST; declared floor normalises to $NFLOOR"376
if [ "$SATISFIED" = yes ]; then377
say " => LANDABLE. The declared floor names a release containing (sigil grant)."378
else379
say " => NOT LANDABLE YET. The floor \"$FLOOR\" does not name a release that"380
say " contains (sigil grant). Set it to one of the releases above."381
fi382
else383
say " (sigil grant) landed in monorepo $(git -C "$MONO" rev-parse --short "$GRANT_COMMIT") and is in NO TAG."384
say " => NOT LANDABLE YET. Consumers resolve caret ranges against TAGS, so"385
say " no floor written today can resolve it. This branch is complete"386
say " EXCEPT the sigil: field, and that field is the only thing gating"387
say " the merge. Do not SHA-pin to route around it."388
fi389
fi390
fi392
rule393
say "Logs kept at: $SCRATCH"394
say "MIGRATION COMPLETE PASS"395
exit 0