#!/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()