Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c40200dfc | ||
|
|
04f61c1238 | ||
|
|
07463c9c37 | ||
|
|
4acd56c4b6 | ||
|
|
20cd75562c | ||
|
|
06bc9508aa |
No files matched your search
@@ -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`,
|
the gate scenario without echoing it (`~/.config/bikoret/upstream_keys`,
|
||||||
0600) — run it from a real terminal, not through a non-interactive runner.
|
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*
|
### Standing at the gate — *Bishe'arecha*
|
||||||
|
|
||||||
A caller can put Bikoret between themselves and a model instead of
|
A caller can put Bikoret between themselves and a model instead of
|
||||||
@@ -620,6 +640,56 @@ Each upstream call spends one `api_calls_external` of the day's manna
|
|||||||
the bedikah by hand still runs. Streaming is refused (422). A non-200
|
the bedikah by hand still runs. Streaming is refused (422). A non-200
|
||||||
from the model passes through as it came. Tamid §1.1 has the contract.
|
from the model passes through as it came. Tamid §1.1 has the contract.
|
||||||
|
|
||||||
|
**Fallback — when the gate is down, Claude Code here is down with it.**
|
||||||
|
Every request of a session behind the gate goes to bikoret.ch; if the pod
|
||||||
|
is not ready (503: stale manna at the day boundary, a Doorpost drift, a
|
||||||
|
hung rollout) the session cannot reach Anthropic at all. Note that an
|
||||||
|
`env` entry in `~/.claude/settings.json` OVERRIDES the shell environment
|
||||||
|
(verified 2026-10-15): `env -u ANTHROPIC_BASE_URL … claude` does nothing
|
||||||
|
while the gate is armed there. Three tools, all in `scripts/` (install to
|
||||||
|
`~/.local/bin`), none of which holds a key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude-direct # start Claude Code straight to Anthropic, whatever
|
||||||
|
# settings.json says (a --settings JSON on the command
|
||||||
|
# line outranks every settings file); OAuth as usual
|
||||||
|
claude-key # the same, on the Anthropic API KEY from
|
||||||
|
# ~/.config/shorashim/upstream_keys instead of the
|
||||||
|
# subscription's OAuth token: no browser, no /login.
|
||||||
|
# Billed to the API console account, not the Max plan.
|
||||||
|
claude-bikoret # start behind the gate; probes the gate first and, if
|
||||||
|
# it does not answer, says so and starts claude-direct.
|
||||||
|
# BIKORET_STRICT=1 claude-bikoret refuses instead.
|
||||||
|
bikoret-settings on|off|status
|
||||||
|
# arm / disarm the gate for EVERY launcher (desktop,
|
||||||
|
# background jobs, plain `claude`) via settings.json.
|
||||||
|
# `on` writes the courtyard key there — the kohen runs
|
||||||
|
# it, not the builder; `off` is the recovery.
|
||||||
|
```
|
||||||
|
|
||||||
|
Order of recovery: `claude-direct` (always works while the OAuth token
|
||||||
|
is good), then `bikoret-settings off` if background sessions must also
|
||||||
|
recover, then fix the pod (`curl -s https://bikoret.ch/kodesh/health`
|
||||||
|
names the failed check), then `bikoret-settings on` and `claude-bikoret`
|
||||||
|
again.
|
||||||
|
|
||||||
|
**When it is the login, not the gate.** Every session on the VM shares
|
||||||
|
one OAuth credential (`~/.claude/.credentials.json`); the access token
|
||||||
|
lives eight hours and the daemon refreshes it ahead of time. On
|
||||||
|
2026-10-15 21:08Z that refresh failed with no incident at Anthropic
|
||||||
|
(most likely two processes racing on the single-use refresh token), the
|
||||||
|
credential was dropped, and every session said "Login expired · Please
|
||||||
|
run /login" for 2 h 43 min. The browser flow from a remote shell is
|
||||||
|
fragile ("Login interrupted" twice before it took). Recovery:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude-key # work NOW on the API key; nothing to log in to
|
||||||
|
claude auth login # from the shell, not inside a session: prints the
|
||||||
|
# URL, paste the code it gives back; `--console`
|
||||||
|
# signs in to the API account instead of the plan
|
||||||
|
claude auth status --text # says which credential a launch would use
|
||||||
|
```
|
||||||
|
|
||||||
### The second scroll — *Mishneh haTorah*
|
### The second scroll — *Mishneh haTorah*
|
||||||
|
|
||||||
`/kodesh/health` carries `mesorah_hash` beside `l1_hash`: the mesorah
|
`/kodesh/health` carries `mesorah_hash` beside `l1_hash`: the mesorah
|
||||||
|
|||||||
@@ -60,3 +60,11 @@ data:
|
|||||||
|
|
||||||
# Pipeline latency budget (ms) — Construction spec §2.5
|
# Pipeline latency budget (ms) — Construction spec §2.5
|
||||||
PIPELINE_LATENCY_BUDGET_MS: "5000"
|
PIPELINE_LATENCY_BUDGET_MS: "5000"
|
||||||
|
|
||||||
|
# Ecto pool size for vav-pipeline (runtime.exs default 20). The DBaaS
|
||||||
|
# is an Aiven hobbyist-2 shared with Gitea and Aiven's own monitor;
|
||||||
|
# a pool of 20 fills its connection ceiling by itself (2026-10-15:
|
||||||
|
# refusals "remaining connection slots are reserved" for 24 h, one
|
||||||
|
# pool worker looping, Aiven's monitor refused). Ten is ample for a
|
||||||
|
# single replica.
|
||||||
|
POOL_SIZE: "10"
|
||||||
@@ -145,13 +145,29 @@ handle_info(_Info, State) ->
|
|||||||
%% Internal — gate-keeper checks
|
%% Internal — gate-keeper checks
|
||||||
%%====================================================================
|
%%====================================================================
|
||||||
|
|
||||||
do_check_case(Case) ->
|
%% The context is a map (authority_source, action, ...). Anything else is
|
||||||
Context = Case#sanhedrin_case.context,
|
%% a malformed case, refused here — never reasoned about, never a crash.
|
||||||
|
%% Tablet 55 (2026-10-19): the Tamid's own example request carried a
|
||||||
|
%% LIST as context; maps:get raised, and this gen_server and escalation
|
||||||
|
%% died with the caller's input. A gate that can be felled by what it is
|
||||||
|
%% handed is not a gate. Fail closed, stay standing.
|
||||||
|
do_check_case(#sanhedrin_case{context = Context}) when is_map(Context) ->
|
||||||
with_chain([
|
with_chain([
|
||||||
fun() -> check_avodah_zarah(Context) end,
|
fun() -> check_avodah_zarah(Context) end,
|
||||||
fun() -> check_shfichut_damim(Context) end,
|
fun() -> check_shfichut_damim(Context) end,
|
||||||
fun() -> check_gilui_arayot(Context) end
|
fun() -> check_gilui_arayot(Context) end
|
||||||
]).
|
]);
|
||||||
|
do_check_case(#sanhedrin_case{context = Other}) ->
|
||||||
|
{halt, malformed_case,
|
||||||
|
iolist_to_binary([<<"case context must be a map, got ">>,
|
||||||
|
atom_to_binary(type_name(Other), utf8)])}.
|
||||||
|
|
||||||
|
type_name(T) when is_list(T) -> list;
|
||||||
|
type_name(T) when is_binary(T) -> binary;
|
||||||
|
type_name(T) when is_atom(T) -> atom;
|
||||||
|
type_name(T) when is_tuple(T) -> tuple;
|
||||||
|
type_name(T) when is_number(T) -> number;
|
||||||
|
type_name(_) -> other.
|
||||||
|
|
||||||
do_check_vote(_Vote) ->
|
do_check_vote(_Vote) ->
|
||||||
%% Vote-level halt detection is the responsibility of the upstream
|
%% Vote-level halt detection is the responsibility of the upstream
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ halt_clear_test() ->
|
|||||||
?assertEqual(clear, halt_monitor:check_case(Case)),
|
?assertEqual(clear, halt_monitor:check_case(Case)),
|
||||||
cleanup(ok).
|
cleanup(ok).
|
||||||
|
|
||||||
|
%% Tablet 55: a non-map context is refused, and the monitor survives it.
|
||||||
|
halt_malformed_context_test() ->
|
||||||
|
{ok, _} = application:ensure_all_started(sanhedrin),
|
||||||
|
Sides = [#{<<"position">> => <<"a">>, <<"evidence">> => [<<"x">>]}],
|
||||||
|
Case = make_case_with_context(simple, <<"question">>, Sides),
|
||||||
|
?assertMatch({halt, malformed_case, _}, halt_monitor:check_case(Case)),
|
||||||
|
%% still standing, and still judging
|
||||||
|
?assertEqual(clear, halt_monitor:check_case(make_case(simple, <<"q">>))),
|
||||||
|
ok.
|
||||||
|
|
||||||
halt_avodah_zarah_test() ->
|
halt_avodah_zarah_test() ->
|
||||||
setup(),
|
setup(),
|
||||||
Case = make_case_with_context(simple, <<"question">>,
|
Case = make_case_with_context(simple, <<"question">>,
|
||||||
|
|||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Arm or disarm the gate in Claude Code's settings.json (the `env` block is
|
||||||
|
# applied to EVERY launcher — desktop, background jobs, the plain `claude`).
|
||||||
|
#
|
||||||
|
# bikoret-settings on put ANTHROPIC_BASE_URL + ANTHROPIC_CUSTOM_HEADERS in
|
||||||
|
# bikoret-settings off remove them (the fallback when the gate is down)
|
||||||
|
# bikoret-settings status say which
|
||||||
|
#
|
||||||
|
# `on` writes the courtyard key into a config file, so the transmitter runs it:
|
||||||
|
# the builder is not allowed to write a credential there. `off` writes none.
|
||||||
|
import json, os, sys
|
||||||
|
p = os.path.expanduser("~/.claude/settings.json")
|
||||||
|
KEYS = ("ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS")
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||||
|
d = json.load(open(p))
|
||||||
|
env = d.get("env", {})
|
||||||
|
if mode == "on":
|
||||||
|
k = [l.split()[1] for l in open(os.path.expanduser("~/.config/shorashim/api_keys")) if l.startswith("courtyard")][0]
|
||||||
|
env.update({"ANTHROPIC_BASE_URL": "https://bikoret.ch/gate/anthropic",
|
||||||
|
"ANTHROPIC_CUSTOM_HEADERS": "X-Bikoret-Key: " + k})
|
||||||
|
d["env"] = env
|
||||||
|
elif mode == "off":
|
||||||
|
for key in KEYS:
|
||||||
|
env.pop(key, None)
|
||||||
|
if env: d["env"] = env
|
||||||
|
else: d.pop("env", None)
|
||||||
|
elif mode != "status":
|
||||||
|
sys.exit("usage: bikoret-settings on|off|status")
|
||||||
|
if mode in ("on", "off"):
|
||||||
|
json.dump(d, open(p, "w"), indent=2)
|
||||||
|
os.chmod(p, 0o600)
|
||||||
|
armed = all(k in d.get("env", {}) for k in KEYS)
|
||||||
|
print("gate in settings.json:", "ARMED (every launcher goes through bikoret.ch)" if armed else "off (direct to Anthropic)")
|
||||||
@@ -53,6 +53,16 @@ if [[ ! -d "deploy/docker" || ! -d "l1/torah" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Sanity: docker buildx
|
# 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
|
if ! docker buildx version >/dev/null 2>&1; then
|
||||||
echo "ERROR: docker buildx not available — install Docker Desktop or buildx plugin" >&2
|
echo "ERROR: docker buildx not available — install Docker Desktop or buildx plugin" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start Claude Code behind the gate (KOHEN "Claude Code behind the gate").
|
||||||
|
# The courtyard key is read from the keys file at launch and never written anywhere else.
|
||||||
|
#
|
||||||
|
# Fallback (2026-10-15): before launching, probe the gate. If bikoret.ch or
|
||||||
|
# the pod is down, say so and start DIRECT to Anthropic instead
|
||||||
|
# (claude-direct), unless BIKORET_STRICT=1 — then refuse, so a session that
|
||||||
|
# must be examined is never silently unexamined.
|
||||||
|
GATE="https://bikoret.ch/gate/anthropic"
|
||||||
|
code=$(curl -sS -m 6 -o /dev/null -w '%{http_code}' -X POST "$GATE/v1/messages" \
|
||||||
|
-H 'content-type: application/json' -d '{}' 2>/dev/null)
|
||||||
|
case "$code" in
|
||||||
|
401|400|422) ;; # the gate answered: it is up (it refuses an unkeyed/empty request)
|
||||||
|
*)
|
||||||
|
echo "bikoret gate not answering (HTTP ${code:-000}) — see: curl -s https://bikoret.ch/kodesh/health" >&2
|
||||||
|
if [ "${BIKORET_STRICT:-0}" = "1" ]; then
|
||||||
|
echo "BIKORET_STRICT=1: refusing to start unexamined" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "starting DIRECT to Anthropic (claude-direct); re-run claude-bikoret when the gate is back" >&2
|
||||||
|
exec "$HOME/.local/bin/claude-direct" "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
KEY=$(awk '$1=="courtyard"{print $2}' "$HOME/.config/shorashim/api_keys")
|
||||||
|
[ -n "$KEY" ] || { echo "no courtyard key in ~/.config/shorashim/api_keys" >&2; exit 1; }
|
||||||
|
export ANTHROPIC_BASE_URL="$GATE"
|
||||||
|
export ANTHROPIC_CUSTOM_HEADERS="X-Bikoret-Key: $KEY"
|
||||||
|
exec claude "$@"
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start Claude Code DIRECT to Anthropic, bypassing the Bikoret gate — the
|
||||||
|
# fallback for when bikoret.ch / the pod is down (KOHEN "Claude Code behind
|
||||||
|
# the gate — fallback").
|
||||||
|
#
|
||||||
|
# Why --settings and not `env -u`: an `env` entry in ~/.claude/settings.json
|
||||||
|
# overrides the shell environment (verified 2026-10-15), so unsetting the
|
||||||
|
# variables in the shell does nothing while the gate is armed there. A
|
||||||
|
# `--settings` JSON on the command line outranks every settings file, so it
|
||||||
|
# wins without touching settings.json. OAuth login is used as normal.
|
||||||
|
unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS
|
||||||
|
exec claude --settings '{"env":{"ANTHROPIC_BASE_URL":"https://api.anthropic.com","ANTHROPIC_CUSTOM_HEADERS":""}}' "$@"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start Claude Code on the Anthropic API KEY instead of the subscription's
|
||||||
|
# OAuth token — the second leg of the fallback (KOHEN "Claude Code behind
|
||||||
|
# the gate — fallback"). For when /login fails or the OAuth refresh died
|
||||||
|
# (2026-10-15 21:08Z: the daemon's proactive refresh failed, every session
|
||||||
|
# on the VM showed "Login expired", and the browser flow took three tries
|
||||||
|
# from a remote shell).
|
||||||
|
#
|
||||||
|
# Billing: the API console account (pay per token), not the Max plan.
|
||||||
|
# The key is read from the upstream keys file at launch and never written
|
||||||
|
# anywhere. Direct to Anthropic, whatever settings.json says.
|
||||||
|
KEY=$(awk '$1=="anthropic"{print $2}' "$HOME/.config/shorashim/upstream_keys")
|
||||||
|
[ -n "$KEY" ] || { echo "no 'anthropic <key>' line in ~/.config/shorashim/upstream_keys" >&2; exit 1; }
|
||||||
|
export ANTHROPIC_API_KEY="$KEY"
|
||||||
|
unset ANTHROPIC_BASE_URL ANTHROPIC_CUSTOM_HEADERS
|
||||||
|
exec claude --settings '{"env":{"ANTHROPIC_BASE_URL":"https://api.anthropic.com","ANTHROPIC_CUSTOM_HEADERS":""}}' "$@"
|
||||||
Executable
+106
@@ -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 <rev> # a git revision instead of the working tree
|
||||||
|
scripts/doorpost_check.py --base <rev> # on drift, diff the manifest against <rev> (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()
|
||||||
@@ -132,3 +132,49 @@ make. The fence held throughout — each forgery was stripped and named by
|
|||||||
a verdict the builder did not write — which is the design proving itself
|
a verdict the builder did not write — which is the design proving itself
|
||||||
on its builder: the check has to stand outside the model, because the
|
on its builder: the check has to stand outside the model, because the
|
||||||
model's own intention is subject to the same pull it is meant to catch.
|
model's own intention is subject to the same pull it is meant to catch.
|
||||||
|
|
||||||
|
## Tablet 55 — the row that was a label (2026-10-19)
|
||||||
|
|
||||||
|
Tamid promises `POST /chatzer/sanhedrin`: a case, optional sides, a
|
||||||
|
panel of three, a majority of two and a minority of one with their
|
||||||
|
sources. The row had stood since the endpoint was written. The build
|
||||||
|
state carried a line beside it — "never verified on prod" — for months.
|
||||||
|
On 2026-10-19 the builder sent the spec's own example request to the
|
||||||
|
live system, as the roadmap's rule asks: a spec row is a claim to test,
|
||||||
|
not a label.
|
||||||
|
|
||||||
|
The first request answered 500. The router had handed the `sides` list
|
||||||
|
to the Sanhedrin as the case's *context*, where the halt monitor reads a
|
||||||
|
map of `authority_source` and `action`; `maps:get` raised, and the halt
|
||||||
|
monitor and the escalation server died with the caller's input. The
|
||||||
|
supervisor restarted them; health stayed 8/8 the whole time. A gate that
|
||||||
|
can be felled by what it is handed is not a gate, and a health check
|
||||||
|
that cannot see the gate fall is not watching it. The router then
|
||||||
|
pattern-matched `{:ok, decision}` on an error and turned the refusal
|
||||||
|
into a 500 of its own.
|
||||||
|
|
||||||
|
The second request, without sides, answered 200 — and the answer was
|
||||||
|
hollow. A panel of one, not three. Field names the spec does not use.
|
||||||
|
An outcome of "accepted" on zero roots and zero references examined,
|
||||||
|
under the sentence "Torah references ground this interpretation". That
|
||||||
|
is the shape the bedikah names golden_calf when a model says it: a
|
||||||
|
confident claim standing on nothing. Here the system said it in its own
|
||||||
|
voice, through its own inner court. The complexity hint was ignored, no
|
||||||
|
panel manna was spent, and nothing was engraved in L3.
|
||||||
|
|
||||||
|
Fixed in this tablet: the halt monitor refuses a non-map context as
|
||||||
|
`malformed_case` and stays standing; the router carries the sides inside
|
||||||
|
the context map, checks their shape at the kiyor (422), and answers a
|
||||||
|
Sanhedrin refusal (422 SANHEDRIN_HALTED) or failure (503
|
||||||
|
SANHEDRIN_UNAVAILABLE) instead of crashing. The spec's own example now
|
||||||
|
deliberates, and the test asserts that the gate is still there
|
||||||
|
afterwards.
|
||||||
|
|
||||||
|
Not fixed, and recorded here so it is not mistaken for fixed: the
|
||||||
|
panel of one, the empty references under a grounded-sounding sentence,
|
||||||
|
the ignored hint, the unspent manna, the unrecorded case. The row in
|
||||||
|
Tamid is still larger than the thing behind it. The lesson is tablet
|
||||||
|
37's again — a spec row is a claim to test — with one thing added: the
|
||||||
|
system's own inner court is held to the same check as a model at the
|
||||||
|
gate. When the Sanhedrin accepts on nothing, it is the calf, whoever
|
||||||
|
cast it.
|
||||||
@@ -1084,32 +1084,83 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
resubmit: true
|
resubmit: true
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
case_ref = generate_id()
|
# Tablet 55: the sides are the caller's arguments, not the halt
|
||||||
|
# gate's context. The gate reads a MAP (authority_source, action);
|
||||||
|
# handing it the sides list crashed the halt monitor and escalation
|
||||||
|
# with the Tamid's own example request. The sides ride inside the
|
||||||
|
# map, their shape checked at the door; and a refusal from the
|
||||||
|
# Sanhedrin is answered, not matched into a 500.
|
||||||
|
with {:ok, sides} <- sanhedrin_sides(params["sides"]) do
|
||||||
|
case_ref = generate_id()
|
||||||
|
|
||||||
{:ok, decision} =
|
case VavPipeline.SanhedrinClient.submit_case(%{
|
||||||
VavPipeline.SanhedrinClient.submit_case(%{
|
case_ref: case_ref,
|
||||||
case_ref: case_ref,
|
complexity: :simple,
|
||||||
complexity: :simple,
|
question: case_text,
|
||||||
question: case_text,
|
context: %{sides: sides},
|
||||||
context: params["sides"] || %{},
|
requested_by: "chatzer"
|
||||||
requested_by: "chatzer"
|
}) do
|
||||||
})
|
{:ok, decision} ->
|
||||||
|
send_json(conn, 200, %{
|
||||||
|
case_ref: decision.case_ref,
|
||||||
|
deliberation: %{
|
||||||
|
panel_size: decision.panel_size,
|
||||||
|
majority: decision.majority,
|
||||||
|
minority: decision.minority,
|
||||||
|
outcome: decision.outcome,
|
||||||
|
processing_time_ms: decision.deliberation_time_ms
|
||||||
|
},
|
||||||
|
note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.",
|
||||||
|
disclosure: disclosure()
|
||||||
|
})
|
||||||
|
|
||||||
send_json(conn, 200, %{
|
{:error, "Halted" <> _ = reason} ->
|
||||||
case_ref: decision.case_ref,
|
send_error(conn, 422, "SANHEDRIN_HALTED", %{
|
||||||
deliberation: %{
|
issue: reason,
|
||||||
panel_size: decision.panel_size,
|
correction: "The case was refused at the gate before deliberation. Amend it and resubmit.",
|
||||||
majority: decision.majority,
|
resubmit: true
|
||||||
minority: decision.minority,
|
})
|
||||||
outcome: decision.outcome,
|
|
||||||
processing_time_ms: decision.deliberation_time_ms
|
{:error, reason} ->
|
||||||
},
|
send_error(conn, 503, "SANHEDRIN_UNAVAILABLE", %{
|
||||||
note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.",
|
issue: reason,
|
||||||
disclosure: disclosure()
|
correction: "The Sanhedrin did not deliberate. Retry; if it recurs, the kohanim will find it in the log.",
|
||||||
})
|
resubmit: true
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, issue} ->
|
||||||
|
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||||||
|
issue: issue,
|
||||||
|
correction: "'sides' is an optional list of {\"position\": string, \"evidence\": [string]}",
|
||||||
|
resubmit: true
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The optional sides: absent → []; a list of maps each with a string
|
||||||
|
# "position" and an optional list of string "evidence" → kept as given;
|
||||||
|
# anything else → refused at the kiyor.
|
||||||
|
defp sanhedrin_sides(nil), do: {:ok, []}
|
||||||
|
|
||||||
|
defp sanhedrin_sides(sides) when is_list(sides) do
|
||||||
|
if Enum.all?(sides, &side_ok?/1),
|
||||||
|
do: {:ok, sides},
|
||||||
|
else: {:error, "'sides' must be a list of {position, evidence[]} objects"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanhedrin_sides(_), do: {:error, "'sides' must be a list"}
|
||||||
|
|
||||||
|
defp side_ok?(%{"position" => p} = side) when is_binary(p) do
|
||||||
|
case Map.get(side, "evidence", []) do
|
||||||
|
ev when is_list(ev) -> Enum.all?(ev, &is_binary/1)
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp side_ok?(_), do: false
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Internal — Health and Status
|
# Internal — Health and Status
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ defmodule VavPipeline.Doorpost do
|
|||||||
# changes; the update is a deliberate operator commit. A bare
|
# changes; the update is a deliberate operator commit. A bare
|
||||||
# hash-update commit (without a corresponding spec change in the
|
# hash-update commit (without a corresponding spec change in the
|
||||||
# same commit) is itself a drift signal.
|
# same commit) is itself a drift signal.
|
||||||
@expected_hash "581133126c483eea33d3eb79379cf2f0c54e221d8ae705d9e67da987086ed52a"
|
@expected_hash "66aa7874fae4875793791ce6ca7bbe55302109d5e4444bcb36d578dfe6e98a0d"
|
||||||
|
|
||||||
@doc "Return the list of files that constitute the category-grade specification."
|
@doc "Return the list of files that constitute the category-grade specification."
|
||||||
@spec files() :: [String.t()]
|
@spec files() :: [String.t()]
|
||||||
|
|||||||
@@ -317,6 +317,43 @@ defmodule VavPipeline.BigdeiKodeshTest do
|
|||||||
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
|
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
|
||||||
assert body["note"] =~ "not a binding ruling"
|
assert body["note"] =~ "not a binding ruling"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Tablet 55: the Tamid's own example — a case WITH sides — returned
|
||||||
|
# 500 and took the halt monitor and escalation down with it.
|
||||||
|
test "POST /chatzer/sanhedrin with sides (the Tamid's example) deliberates" do
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
|
||||||
|
case: "A borrower repaid before one witness; the lender denies receipt. Who bears the burden?",
|
||||||
|
sides: [
|
||||||
|
%{position: "the borrower is believed", evidence: ["the money was handed over before a witness"]},
|
||||||
|
%{position: "the lender is believed", evidence: ["one witness does not establish payment"]}
|
||||||
|
],
|
||||||
|
complexity_hint: "monetary"
|
||||||
|
}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
body = Jason.decode!(conn.resp_body)
|
||||||
|
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
|
||||||
|
# and the gate is still standing afterwards
|
||||||
|
assert Process.whereis(:halt_monitor) != nil
|
||||||
|
assert Process.whereis(:escalation) != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST /chatzer/sanhedrin with malformed sides returns 422, not 500" do
|
||||||
|
for bad <- ["not a list", [%{"no_position" => 1}], [%{"position" => "p", "evidence" => "not a list"}]] do
|
||||||
|
conn =
|
||||||
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", sides: bad}))
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put_req_header("authorization", "Bearer chatzer-test-token")
|
||||||
|
|> Router.call(@opts)
|
||||||
|
|
||||||
|
assert conn.status == 422, "expected 422 for sides=#{inspect(bad)}, got #{conn.status}"
|
||||||
|
assert Jason.decode!(conn.resp_body)["error"]["code"] == "KIYOR_VALIDATION_FAILED"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user