diff --git a/KOHEN.md b/KOHEN.md index ae40d91a..c331544c 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -566,6 +566,26 @@ is (Day 4). `scripts/upstream-key-set` stores a caller's model key for the gate scenario without echoing it (`~/.config/bikoret/upstream_keys`, 0600) — run it from a real terminal, not through a non-interactive runner. +**Before the image is built — the Doorpost read on the workbench.** +`VavPipeline.Doorpost` bakes a hash over the category-grade spec files +(`@spec_files` in `vav_pipeline/lib/vav_pipeline/doorpost.ex`); a pod +whose files do not match logs `DOORPOST_DRIFT_AT_STARTUP`, answers 503 +on readiness, and takes the gate dark (tablet 54: two spec edits shipped +without the pin). `scripts/build_and_push.sh` now runs +`scripts/doorpost_check.py` first and refuses to build on drift, naming +the files that changed since `origin/main`. Hand builds should do the +same: + +```bash +python3 -I scripts/doorpost_check.py # the working tree, as the image is built from +python3 -I scripts/doorpost_check.py --rev HEAD # a commit +python3 -I scripts/doorpost_check.py --print # the hash the pin should be +``` + +A spec change and its pin move belong in ONE commit, with +`mix test test/doorpost_test.exs` run on it. `DOORPOST_SKIP=1` exists +for a deliberate, written-down exception; it is never the way to ship. + ### Standing at the gate — *Bishe'arecha* A caller can put Bikoret between themselves and a model instead of diff --git a/scripts/build_and_push.sh b/scripts/build_and_push.sh index 0d4e65d8..5a7663ab 100755 --- a/scripts/build_and_push.sh +++ b/scripts/build_and_push.sh @@ -53,6 +53,16 @@ if [[ ! -d "deploy/docker" || ! -d "l1/torah" ]]; then fi # Sanity: docker buildx +# The Doorpost, read before the build: the spec files the image will carry +# must hash to the pin baked into VavPipeline.Doorpost, or the pod drifts +# at startup, readiness answers 503, and the gate goes dark (tablet 54, +# 2026-10-15). scripts/doorpost_check.py computes the same hash the same +# way and names the drifted files. DOORPOST_SKIP=1 only for a deliberate, +# documented exception — never to ship a drift. +if [[ "${DOORPOST_SKIP:-0}" != "1" ]]; then + python3 -I scripts/doorpost_check.py || exit 1 +fi + if ! docker buildx version >/dev/null 2>&1; then echo "ERROR: docker buildx not available — install Docker Desktop or buildx plugin" >&2 exit 1 diff --git a/scripts/doorpost_check.py b/scripts/doorpost_check.py new file mode 100755 index 00000000..a1604623 --- /dev/null +++ b/scripts/doorpost_check.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""doorpost_check.py — the Doorpost read BEFORE the image is built. + +VavPipeline.Doorpost bakes @expected_hash over the category-grade spec +files (@spec_files in vav_pipeline/lib/vav_pipeline/doorpost.ex). An +image whose spec files do not hash to its pin logs +DOORPOST_DRIFT_AT_STARTUP, answers 503 on readiness, and takes the gate +dark with it — tablet 54, 2026-10-15: two spec edits shipped without a +pin move and every Claude Code session behind the gate was cut off. + +This script computes the same hash the same way, with no Elixir: + for each path in @spec_files, in listed order: sha256(contents) + join "path\\thash" with "\\n"; sha256 of that. +and compares it with the pin. Exit 0 on a match, 1 on drift (the +message names each drifted file against a base revision), 2 on a +missing file or an unreadable module. + + scripts/doorpost_check.py # the working tree, as the image is built from + scripts/doorpost_check.py --rev # a git revision instead of the working tree + scripts/doorpost_check.py --base # on drift, diff the manifest against (default origin/main) + scripts/doorpost_check.py --print # print the hash only (what the pin should be) + +Devarim 4:2 — nothing added, nothing taken: the pin moves by a commit +that moves it, never by a build that ignores it. +""" +import argparse, hashlib, os, re, subprocess, sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MODULE = "vav_pipeline/lib/vav_pipeline/doorpost.ex" + + +def read(rev, path): + """Bytes of `path` at `rev`, or from the working tree when rev is None. None if missing.""" + if rev is None: + full = os.path.join(ROOT, path) + try: + with open(full, "rb") as f: + return f.read() + except FileNotFoundError: + return None + r = subprocess.run(["git", "-C", ROOT, "show", f"{rev}:{path}"], capture_output=True) + return r.stdout if r.returncode == 0 else None + + +def spec_files_and_pin(rev): + src = read(rev, MODULE) + if src is None: + sys.exit(f"doorpost_check: cannot read {MODULE}" + (f" at {rev}" if rev else "")) + src = src.decode() + block = src.split("@spec_files [", 1)[1].split("]", 1)[0] + files = re.findall(r'"([^"]+)"', block) + pin = re.search(r'@expected_hash\s+"([0-9a-f]{64})"', src) + if not files or not pin: + sys.exit(f"doorpost_check: could not parse @spec_files/@expected_hash from {MODULE}") + return files, pin.group(1) + + +def manifest(rev, files): + out = {} + for p in files: + data = read(rev, p) + out[p] = None if data is None else hashlib.sha256(data).hexdigest() + return out + + +def identity(man): + return hashlib.sha256("\n".join(f"{p}\t{h}" for p, h in man.items()).encode()).hexdigest() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--rev", help="git revision to check instead of the working tree") + ap.add_argument("--base", default="origin/main", help="revision to name drifted files against") + ap.add_argument("--print", action="store_true", help="print the computed hash only") + a = ap.parse_args() + + files, pin = spec_files_and_pin(a.rev) + man = manifest(a.rev, files) + missing = [p for p, h in man.items() if h is None] + if missing: + print("doorpost_check: MISSING spec file(s): " + ", ".join(missing), file=sys.stderr) + sys.exit(2) + computed = identity(man) + if a.print: + print(computed) + return + where = a.rev or "working tree" + if computed == pin: + print(f"doorpost: {where} hashes to its pin {pin[:8]}… ({len(files)} files) — the Doorpost will verify") + return + print(f"doorpost: DRIFT in {where} — computed {computed}, pinned {pin}", file=sys.stderr) + base_files, _ = spec_files_and_pin(a.base) + base_man = manifest(a.base, base_files) + for p in files: + if base_man.get(p) != man[p]: + print(f" changed since {a.base}: {p}", file=sys.stderr) + for p in files: + if p not in base_man: + print(f" added to @spec_files since {a.base}: {p}", file=sys.stderr) + print(f" Move @expected_hash in {MODULE} to {computed} in the same commit as the spec change,", file=sys.stderr) + print(f" then run: mix test test/doorpost_test.exs. Refusing to build an image that would drift at startup.", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()