repo-mailbox/scripts/session-gate-selftest.sh
Kjell Tore Guttormsen 02d4df2dfb
feat(gate): session-gate.sh - R3/R4/R6 checks before a session starts
Order 20260926T070037Z-680859139 (.claude). A read-only, zero-model-call
check any session starter can call first: exit 0 allows, exit 2 denies with
one stderr line naming the rule and what is missing.

- R4: PLAN.md holds exactly one gate line,
  <!-- gate: gjenfortelling=<time>; dekning=komplett; r1-proeve=<time|->;
  foerste-maaling=<time|->; retning=<name> -->
- R3: the Logg table's Bevegelse (ja|nei) and Kvote (N%) columns. Deny at
  >= 10% of the weekly quota without movement since the last row that moved.
- R6: cap 1 live session per repo (claude agents --json --cwd <repo>) until
  r1-proeve and foerste-maaling are both times, then 2. Night 23:00-07:00 or
  --night needs r1-proeve.

Fail-closed: unmeasured rows, a missing claude binary, a non-JSON listing,
duplicate or unknown keys and usage errors all deny.

Measured: claude agents --cwd matches the directory a session was STARTED
in, not its printed cwd, so --repo must be the main checkout root.

Chose NOT to call it from board.sh --dispatch, because a dispatch line is
generated before the start (a live count would be stale), and live-session
reading is the process axis board.sh refuses by design.

session-gate-selftest.sh: 0 -> 97 checks. It was red 91 of 95 before the
script existed, then 97/97 green. Ten mutants all die. It is wired into npm
test as the sixth suite, and the README badge goes from 1006 to 1103.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:24:12 +02:00

408 lines
18 KiB
Bash

#!/bin/bash
# session-gate-selftest.sh - prove session-gate.sh against fixture repos.
# ASCII only, bash 3.2 safe. Writes only under a mktemp directory.
#
# Every rule gets at least one repo that must be LET THROUGH beside the repos
# that must be REFUSED, because a gate proven only by refusals is a gate that
# refuses everything and proves nothing. The live-session count is fed from a
# fake `claude` on PATH, so the suite never depends on what happens to be
# running on the machine - and the fake records its argv, so the real call
# shape (`agents --json --cwd <physical path>`) is pinned, not assumed.
set -u
export LC_ALL=C
DIR="$(cd "$(dirname "$0")" && pwd)"
GATE="$DIR/session-gate.sh"
PASS=0; FAIL=0
check() { if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); echo " ok - $1"; else FAIL=$((FAIL+1)); echo " FAIL - $1"; fi; }
T="$(mktemp -d)"
trap 'chmod -R u+rwx "$T" 2>/dev/null; rm -rf "$T"' EXIT
T="$(cd -P "$T" && pwd -P)"
FAKEBIN="$T/fakebin"
mkdir -p "$FAKEBIN"
cat > "$FAKEBIN/claude" <<'FAKE'
#!/bin/bash
printf '%s\n' "$*" >> "$FAKE_LOG"
if [ -n "${FAKE_AGENTS_FILE:-}" ]; then cat "$FAKE_AGENTS_FILE"; fi
exit "${FAKE_AGENTS_EXIT:-0}"
FAKE
chmod +x "$FAKEBIN/claude"
FAKE_LOG="$T/fake.log"
printf '[]\n' > "$T/ag0"
cat > "$T/ag1" <<'J'
[
{
"pid": 4242,
"id": "aaaa1111",
"cwd": "/x/demo/.claude/worktrees/w",
"kind": "background",
"startedAt": 1790406122536,
"sessionId": "aaaa1111-0000-0000-0000-000000000000",
"name": "demo-w",
"status": "busy",
"state": "working"
}
]
J
printf '[{"id":"a","cwd":"/x/demo","kind":"background","sessionId":"a-1"},{"cwd":"/x/demo","kind":"interactive","sessionId":"b-2"}]\n' > "$T/ag2"
printf 'hello\n' > "$T/agjunk"
: > "$T/agempty"
# run_gate <args...>: env knobs NOW (clock, default 12:00; NOW_UNSET=1 leaves
# the variable out), AG (agents fixture, default ag0), AGX (fake exit),
# GPATH (PATH for the gate, default fakebin first).
run_gate() {
: > "$FAKE_LOG"
if [ "${NOW_UNSET:-0}" = 1 ]; then
OUT="$(env -u SESSION_GATE_NOW PATH="${GPATH:-$FAKEBIN:/usr/bin:/bin}" FAKE_LOG="$FAKE_LOG" \
FAKE_AGENTS_FILE="${AG:-$T/ag0}" FAKE_AGENTS_EXIT="${AGX:-0}" \
/bin/bash "$GATE" "$@" 2>"$T/err")"; RC=$?
else
OUT="$(env PATH="${GPATH:-$FAKEBIN:/usr/bin:/bin}" SESSION_GATE_NOW="${NOW-12:00}" FAKE_LOG="$FAKE_LOG" \
FAKE_AGENTS_FILE="${AG:-$T/ag0}" FAKE_AGENTS_EXIT="${AGX:-0}" \
/bin/bash "$GATE" "$@" 2>"$T/err")"; RC=$?
fi
ERR="$(cat "$T/err")"
}
# denied <rule>: exit 2, nothing on stdout, exactly ONE stderr line naming the rule.
denied() {
[ "$RC" -eq 2 ] && [ -z "$OUT" ] && [ "$(printf '%s\n' "$ERR" | grep -c .)" -eq 1 ] \
&& printf '%s' "$ERR" | grep -q "$1"
}
# allowed: exit 0, one ALLOW line on stdout, stderr empty.
allowed() {
[ "$RC" -eq 0 ] && [ -z "$ERR" ] && printf '%s' "$OUT" | grep -q '^session-gate: ALLOW'
}
GATE_OK='<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
GATE_R1='<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=2026-09-26T08:00; foerste-maaling=-; retning=demo -->'
GATE_R1M='<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=2026-09-26T08:00; foerste-maaling=2026-09-27T09:00+02:00; retning=demo -->'
LOG_OK='## Logg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|
| 26.09 | x | ja: first | 3% |
| 26.09 | y | nei: nothing moved | 4% |'
# mkplan <name> <gate-line> <logg-block>: a repo dir holding PLAN.md.
mkplan() {
mkdir -p "$T/$1"
printf '# PLAN - %s\n%s\n\n## Maal\nSomething.\n\n%s\n' "$1" "$2" "$3" > "$T/$1/PLAN.md"
}
echo "session-gate-selftest"
# --- 0. CLI, help and the known-positive baseline --------------------------
run_gate -h
[ "$RC" -eq 0 ]; check "-h exits 0" $?
printf '%s' "$OUT" | grep -q 'checks that the fields are filled in, not that they were thought through'
check "help states the caveat: filled in, not thought through" $?
printf '%s' "$OUT" | grep -q 'retelling is the only control with real content'
check "help states the caveat: the retelling is the only control with real content" $?
printf '%s' "$OUT" | grep -q '0 = allow' && printf '%s' "$OUT" | grep -q '2 = deny'
check "help documents exit 0 = allow and 2 = deny" $?
run_gate
denied usage; check "no arguments: deny (exit 2, one line)" $?
run_gate --repo
denied usage; check "--repo without a value: deny" $?
run_gate --repo "$T" --bogus
denied usage; check "unknown argument: deny" $?
mkplan base "$GATE_OK" "$LOG_OK"
run_gate --repo "$T/base" --kind other
denied usage; check "--kind outside build|read: deny" $?
run_gate --repo "$T/does-not-exist"
denied repo; check "--repo that does not exist: deny" $?
run_gate --repo "$T/base"
allowed; check "KNOWN-POSITIVE: complete PLAN.md, 0 live sessions, daytime -> allow" $?
run_gate --repo "$T/base" --kind build
allowed; check "explicit --kind build on the same repo -> allow" $?
LC_ALL=C grep -q '[^ -~ ]' "$GATE"; [ $? -eq 1 ]
check "session-gate.sh is ASCII-only" $?
[ -s "$GATE" ] && { grep -v '^[[:space:]]*#' "$GATE" | grep -Eq '(^|[^A-Za-z_-])(jq|python3?|node)([^A-Za-z_-]|$)'; [ $? -eq 1 ]; }
check "no jq/python/node in executable lines (zero dependencies)" $?
printf 'x=1\njq .\n' | grep -v '^[[:space:]]*#' | grep -Eq '(^|[^A-Za-z_-])(jq|python3?|node)([^A-Za-z_-]|$)'
check "KNOWN-POSITIVE: the dependency grep finds a planted jq call" $?
# --- 1. PLAN.md itself: fail-closed on absence and unreadability -----------
mkdir -p "$T/noplan"
run_gate --repo "$T/noplan"
denied PLAN.md; check "no PLAN.md: deny, naming PLAN.md" $?
mkdir -p "$T/lower"
printf '%s\n\n%s\n' "$GATE_OK" "$LOG_OK" > "$T/lower/plan.md"
[ -f "$T/lower/PLAN.md" ] && echo " note - case-insensitive volume: [ -f PLAN.md ] is TRUE for plan.md here"
run_gate --repo "$T/lower"
denied PLAN.md; check "only plan.md (wrong case): deny - the name is matched byte for byte" $?
mkplan unread "$GATE_OK" "$LOG_OK"
chmod 000 "$T/unread/PLAN.md"
run_gate --repo "$T/unread"
denied PLAN.md; check "unreadable PLAN.md: deny" $?
chmod 644 "$T/unread/PLAN.md"
mkdir -p "$T/plandir/PLAN.md"
run_gate --repo "$T/plandir"
denied PLAN.md; check "PLAN.md is a directory: deny" $?
mkdir -p "$T/emptyplan"; : > "$T/emptyplan/PLAN.md"
run_gate --repo "$T/emptyplan"
denied R4; check "empty PLAN.md: deny on R4 (no gate line)" $?
mkdir -p "$T/linked-target"
printf '%s\n\n%s\n' "$GATE_OK" "$LOG_OK" > "$T/linked-target/real-plan.md"
ln -s real-plan.md "$T/linked-target/PLAN.md"
run_gate --repo "$T/linked-target"
allowed; check "symlinked PLAN.md is read through the link -> allow" $?
# --- 2. R4: the retelling and the coverage table --------------------------
mkplan r4-none "" "$LOG_OK"
run_gate --repo "$T/r4-none"
denied R4; check "R4: no gate line: deny" $?
mkplan r4-two "$GATE_OK
$GATE_OK" "$LOG_OK"
run_gate --repo "$T/r4-two"
denied R4; check "R4: two gate lines (ambiguous): deny" $?
mkplan r4-prose "See the form \`$GATE_OK\` in the docs." "$LOG_OK"
run_gate --repo "$T/r4-prose"
denied R4; check "R4: a gate line quoted mid-sentence is prose, not a gate line: deny" $?
r4() { # r4 <name> <gate-line>: build and run
mkplan "$1" "$2" "$LOG_OK"; run_gate --repo "$T/$1"
}
r4 r4-nogj '<!-- gate: dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling missing: deny" $?
r4 r4-gjdash '<!-- gate: gjenfortelling=-; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling=- (no yes yet): deny" $?
r4 r4-gjdate '<!-- gate: gjenfortelling=2026-09-26; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling with a date but no time: deny" $?
r4 r4-gjjunk '<!-- gate: gjenfortelling=yes; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling=yes (not a time): deny" $?
r4 r4-gjmonth '<!-- gate: gjenfortelling=2026-13-01T10:00; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling in month 13: deny" $?
r4 r4-gjhour '<!-- gate: gjenfortelling=2026-09-26T24:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: gjenfortelling at hour 24: deny" $?
r4 r4-dekdel '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=delvis; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning=delvis: deny" $?
r4 r4-dekcase '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=Komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning=Komplett (exact token only): deny" $?
r4 r4-dekpre '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett2; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning=komplett2 (prefix is not the token): deny" $?
r4 r4-nodek '<!-- gate: gjenfortelling=2026-09-26T07:10; r1-proeve=-; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: dekning missing: deny" $?
r4 r4-noret '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning= -->'
denied R4; check "R4: retning empty: deny" $?
r4 r4-retdash '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=- -->'
denied R4; check "R4: retning=- : deny" $?
r4 r4-nor1 '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: r1-proeve key missing (must be written, - if not yet): deny" $?
r4 r4-r1junk '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=ja; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: r1-proeve=ja (not a time or -): deny" $?
r4 r4-nomaal '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; retning=demo -->'
denied R4; check "R4: foerste-maaling key missing: deny" $?
r4 r4-typo '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; r1-prove=2026-09-26T08:00; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: an unknown key (typo r1-prove beside r1-proeve) is refused, not ignored" $?
r4 r4-repeat '<!-- gate: gjenfortelling=2026-09-26T07:10; dekning=komplett; r1-proeve=-; r1-proeve=2026-09-26T08:00; foerste-maaling=-; retning=demo -->'
denied R4; check "R4: a repeated key (which one counts?) is refused" $?
r4 r4-okz '<!-- gate:gjenfortelling=2026-09-26T07:10:00Z; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=demo -->'
allowed; check "R4 control: seconds and Z suffix -> allow" $?
r4 r4-okoff '<!-- gate: gjenfortelling=2026-09-26T07:10+02:00; dekning=komplett; r1-proeve=-; foerste-maaling=-; retning=ny retning 2 -->'
allowed; check "R4 control: +02:00 offset and a retning with spaces -> allow" $?
# --- 3. R3: movement per quota share, over PLAN.md's Logg -----------------
r3() { # r3 <name> <logg-block>
mkplan "$1" "$GATE_OK" "$2"; run_gate --repo "$T/$1"
}
HDR='## Logg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|'
r3 r3-ten "$HDR
| 1 | a | ja | 5% |
| 2 | b | nei | 6% |
| 3 | c | nei | 4% |"
denied R3; check "R3: 10% without movement since the last movement: deny (threshold is >= 10)" $?
printf '%s' "$ERR" | grep -q '10'
check "R3: the denial states the sum" $?
r3 r3-nine5 "$HDR
| 1 | a | nei | 4,5% |
| 2 | b | nei | 5 % |"
allowed; check "R3 control: 9.5% (comma decimal, spaced percent) -> allow" $?
r3 r3-before "$HDR
| 1 | a | nei | 50% |
| 2 | b | ja: moved | 1% |
| 3 | c | nei | 3% |"
allowed; check "R3 control: rows BEFORE the last movement do not count -> allow" $?
r3 r3-lastja "$HDR
| 1 | a | nei | 40% |
| 2 | b | Ja - moved K1 | 2% |"
allowed; check "R3 control: last row moved (Ja, any case) -> sum 0 -> allow" $?
r3 r3-nomove-ever "$HDR
| 1 | a | nei | 6% |
| 2 | b | nei | 6% |"
denied R3; check "R3: no movement ever, 12%: deny (counted from the first row)" $?
r3 r3-unmeasured "$HDR
| 1 | a | ja | 1% |
| 2 | b | nei | ikke maalt |"
denied R3; check "R3: kvote 'ikke maalt' is unmeasured, not zero: deny" $?
r3 r3-kdash "$HDR
| 1 | a | nei | - |"
denied R3; check "R3: kvote '-': deny" $?
r3 r3-kneg "$HDR
| 1 | a | nei | -3% |"
denied R3; check "R3: negative kvote: deny" $?
r3 r3-kbare "$HDR
| 1 | a | nei | 3 |"
denied R3; check "R3: kvote without % (unit unknown): deny" $?
r3 r3-bingen "$HDR
| 1 | a | ingen mot kriteriene | 1% |"
denied R3; check "R3: bevegelse outside ja|nei ('ingen ...'): deny" $?
r3 r3-bempty "$HDR
| 1 | a | | 1% |"
denied R3; check "R3: bevegelse empty: deny" $?
r3 r3-bjanei "$HDR
| 1 | a | janei | 1% |"
denied R3; check "R3: bevegelse 'janei' (prefix is not the token): deny" $?
r3 r3-cells "$HDR
| 1 | a | nei |"
denied R3; check "R3: a row with fewer cells than the header: deny" $?
r3 r3-pipe "$HDR
| 1 | a \`x|y\` | nei | 1% |"
denied R3; check "R3: a row with more cells than the header: deny" $?
r3 r3-nolog ""
denied R3; check "R3: no Logg section at all: deny" $?
r3 r3-notable "## Logg
- 26.09 did things, no movement, 3%"
denied R3; check "R3: Logg section with no table: deny" $?
r3 r3-nokvote "## Logg
| Dato | Hva | Bevegelse |
|---|---|---|
| 1 | a | ja |"
denied R3; check "R3: Logg table without a Kvote column: deny" $?
r3 r3-nobev "## Logg
| Dato | Hva | Kvote |
|---|---|---|
| 1 | a | 1% |"
denied R3; check "R3: Logg table without a Bevegelse column: deny" $?
r3 r3-hdronly "$HDR"
allowed; check "R3 control: header only, no rows yet (first session) -> allow" $?
r3 r3-twolog "$HDR
| 1 | a | ja | 1% |
## Logg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|"
denied R3; check "R3: two Logg sections (ambiguous): deny" $?
r3 r3-bullet "$HDR
| 1 | a | ja | 1% |
- 2 | b | nei | 30% (a row written as a bullet)"
denied R3; check "R3: a list item inside Logg (a row the gate cannot read): deny" $?
r3 r3-after "$HDR
| 1 | a | ja | 1% |
## Next section
- a bullet that belongs to another section
| x | y |"
allowed; check "R3 control: the Logg section ends at the next heading -> allow" $?
SECT="$(printf '\302\247')"
r3 r3-para "## $SECT Logg
| dato | hva | bevegelse | kvoteandel |
|:---|---|---|---:|
| 1 | a | nei | 2% |"
allowed; check "R3 control: '## <section sign> Logg', lower-case headers, Kvoteandel, aligned separator -> allow" $?
r3 r3-endrlogg "## Endringslogg
| Dato | Hva | Bevegelse | Kvote |
|---|---|---|---|
| 1 | a | ja | 1% |"
denied R3; check "R3: '## Endringslogg' is not the Logg section: deny" $?
# --- 4. R6: concurrency cap and night starts -------------------------------
mkplan r6 "$GATE_OK" "$LOG_OK"
AG="$T/ag0" run_gate --repo "$T/r6"
allowed; check "R6 control: 0 live sessions, cap 1 -> allow" $?
grep -qx "agents --json --cwd $T/r6" "$FAKE_LOG"
check "R6: live sessions are read with 'claude agents --json --cwd <repo>'" $?
ln -s "$T/r6" "$T/r6-link"
AG="$T/ag0" run_gate --repo "$T/r6-link"
grep -qx "agents --json --cwd $T/r6" "$FAKE_LOG"
check "R6: a symlinked --repo is resolved to its physical path before the query" $?
AG="$T/ag1" run_gate --repo "$T/r6"
denied R6; check "R6: 1 live session, no R1 proof -> cap 1 -> deny" $?
printf '%s' "$ERR" | grep -q '1'
check "R6: the denial states the live count" $?
mkplan r6-r1 "$GATE_R1" "$LOG_OK"
AG="$T/ag1" run_gate --repo "$T/r6-r1"
denied R6; check "R6: R1 proof but no first measurement -> cap still 1 -> deny" $?
mkplan r6-r1m "$GATE_R1M" "$LOG_OK"
AG="$T/ag1" run_gate --repo "$T/r6-r1m"
allowed; check "R6 control: R1 proof AND first measurement, 1 live -> cap 2 -> allow" $?
AG="$T/ag2" run_gate --repo "$T/r6-r1m"
denied R6; check "R6: 2 live (one-line JSON, interactive counts too), cap 2 -> deny" $?
AG="$T/ag1" run_gate --repo "$T/r6" --kind read
allowed; check "R6 control: --kind read is not capped -> allow with 1 live" $?
[ ! -s "$FAKE_LOG" ]
check "R6: --kind read does not query live sessions at all" $?
AG="$T/agjunk" run_gate --repo "$T/r6"
denied R6; check "R6: claude agents prints non-JSON: deny (fail-closed)" $?
AG="$T/agempty" run_gate --repo "$T/r6"
denied R6; check "R6: claude agents prints nothing: deny (fail-closed)" $?
AG="$T/ag0" AGX=1 run_gate --repo "$T/r6"
denied R6; check "R6: claude agents exits 1: deny (fail-closed)" $?
NOCL="$T/noclaude"; mkdir -p "$NOCL"
env PATH="$NOCL:/usr/bin:/bin" /bin/bash -c 'command -v claude' >/dev/null 2>&1; [ $? -ne 0 ]
check "ground truth: claude is not on the reduced PATH" $?
GPATH="$NOCL:/usr/bin:/bin" run_gate --repo "$T/r6"
denied R6; check "R6: claude not on PATH: deny (fail-closed)" $?
GPATH="$NOCL:/usr/bin:/bin" run_gate --repo "$T/r6" --kind read
allowed; check "R6 control: --kind read needs no claude binary -> allow" $?
NOW=23:00 run_gate --repo "$T/r6"
denied R6; check "R6 night: 23:00 without R1 proof: deny" $?
printf '%s' "$ERR" | grep -q 'r1-proeve'
check "R6 night: the denial names r1-proeve" $?
NOW=06:59 run_gate --repo "$T/r6"
denied R6; check "R6 night: 06:59 without R1 proof: deny" $?
NOW=07:00 run_gate --repo "$T/r6"
allowed; check "R6 night control: 07:00 is day -> allow" $?
NOW=22:59 run_gate --repo "$T/r6"
allowed; check "R6 night control: 22:59 is day -> allow" $?
NOW=12:00 run_gate --repo "$T/r6" --night
denied R6; check "R6 night: --night at noon without R1 proof: deny" $?
NOW=02:00 run_gate --repo "$T/r6" --kind read
denied R6; check "R6 night: a read session at 02:00 without R1 proof: deny" $?
NOW=02:00 run_gate --repo "$T/r6-r1"
allowed; check "R6 night control: R1 proof present -> night start allowed" $?
NOW=12:00 run_gate --repo "$T/r6-r1" --night
allowed; check "R6 night control: --night with R1 proof -> allow" $?
NOW=noon run_gate --repo "$T/r6"
denied SESSION_GATE_NOW; check "clock override 'noon' is refused by name, never ignored" $?
NOW=25:00 run_gate --repo "$T/r6"
denied SESSION_GATE_NOW; check "clock override 25:00 is refused by name" $?
NOW= run_gate --repo "$T/r6"
denied SESSION_GATE_NOW; check "clock override set but empty is refused by name" $?
NOW_UNSET=1 run_gate --repo "$T/r6-r1m"
allowed; check "KNOWN-POSITIVE: real clock (override unset), R1 proof -> allow at any hour" $?
# --- 5. Rule order: the first failing rule is the one reported ------------
mkplan order "" "$HDR
| 1 | a | nei | 50% |"
AG="$T/ag2" NOW=03:00 run_gate --repo "$T/order"
denied R4; check "all three rules fail: R4 is reported first (one line)" $?
echo "session-gate-selftest: $PASS passed, $FAIL failed (of $((PASS+FAIL)) checks)"
[ "$FAIL" -eq 0 ] || exit 1
exit 0