Compare commits

..
Author SHA1 Message Date
Scott KarpandClaude Fable 5.1 ad27d3c037 tablet 58: the tool's answer is given — tool results are sources; markers on word boundaries
The sugya reply came back lo_tov — golden_calf on two sentences the
builder had just read from tool output (the Mishnah's reasons, from the
second scroll; the commit and pod, from its own tools). The gate's
sources were only the user turns' text and document blocks; a
tool_result block and a tool turn — what the model was GIVEN (tablet
49) — were not counted. And "shorashim-675b…" was vital because the
insight markers matched "Rashi" inside it.

- Shape.sources: Anthropic tool_result blocks (string or blocks) inside
  a user turn, and OpenAI role "tool" turns, are sources; the query is
  still the last user turn. gate_test: both shapes.
- zerikah._INSIGHT_MARKERS: \b…\b. test_zerikah: the pod name is not
  Rashi; "Rashi reads" and "The Mishnah records" are; "Rambanovich" and
  "talmudic_engine" are not.
- Tablet 58 in the Broken Tablets.

Elixir 643/0, korban 294/0. Doorpost pin unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 20:56:25 +00:00
Scott KarpandClaude Fable 5.1 dc93805877 specs: sugya — the case behind each threshold
The reader from outside: "the form is recognizable as wisdom; the
binah behind it is asserted" — every rule should carry the case it was
derived from and the dissent, and the two-witness rule's exception for
forged_fringe should say why a single witness stands there.

Read from Torah, not from the code: Torah establishes a matter three
ways, by what kind of thing it is. A DEED by witnesses, never one
(Devarim 19:15 — and the verse states its scope: any iniquity, any sin,
any sin one sins; Bamidbar 35:30; Devarim 17:6). A MARK by inspection:
the kohen sees and declares, and a doubtful mark gets a second look,
not a second witness (Vayikra 13:3, 13:5, 22:20; the system's own verb,
lo yevaker hakohen 13:36, bikoret tihyeh 19:20). A WORD by what follows
it, and the crime is presumption, b'zadon (Devarim 18:21–22). The
judges are told to hear the small as the great, not to lean after the
many, to name both the righteous and the guilty, and to keep far from
condemning the innocent (Devarim 1:17; Shemot 23:2, 23:7; Devarim
25:1). The Mishnah (Eduyot 1:5–6, in the second scroll) gives the two
reasons to record the opinion that lost: to rely on it later, and to
locate the one who says "thus I received".

Applied: what the Korban Processor finds IN the text is a mark — seen
once, declared (checks 1, 3–6, and the eighth form, the forged fringe:
kol asher bo mum). What a MODEL DOES is a deed — a habit, recurrence —
and the seven forms of lo_taaseh_lekha are charged as deeds: two
witnesses or one beside another. What a claim RESTS ON is a word,
tested against what was given; the hedged claim is not in the verse.
That table is the cited reason the engine already behaves as it does.

§III gives every anchored rule its case (tablet or specimen class),
its dissent (the reading that acquits, with its verse, and when it
loses), and its exceptions. §IV encodes them: halt/sugya.pl with
kind/3, precedent/3, dissent/3,4, exception/3; verified at startup like
the anchors (SUGYA_MISSING); `sugya` on the verdict with `applied` on
the dissent that held; the constants (@single_witness_forms, the hedged
exemption) read from the facts so behaviour and reason cannot drift;
nothing added to the L3 record; the bedikah must not learn. §V the
build order.

Registered in Doorpost @spec_files (30 files; pin → 7adf0a5d…), AGENTS
spec table, README tree. Elixir 642/0 (doorpost_test on the new pin).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 19:38:11 +00:00
Scott KarpandClaude Fable 5.1 d6cf9355fd the ledger: a caller's own bedikot, read back and signed
The examination is the product; the record of it is what a caller can
carry away. Every bedikah was already engraved in L3 — output hash and
length, source digests, coupling trace, claims, violations, verdict,
never the text — and no caller could get theirs back.

- bedikot.key_id (migration): a SHA-256 digest of the courtyard key that
  brought the offering, never the key; threaded as :key_id through
  Bedikah.examine from every site that goes through the gate (courtyard
  bedikah, pair, the gate's pass and stream). Unique index on
  bedikah_ref.
- VavPipeline.Ledger: entry (the examination's fields, none of the
  database's, not the owner's), canonical JSON (keys sorted at every
  level, no whitespace, UTF-8), Ed25519 sign/verify, public key, render.
  Seed from BIKORET_LEDGER_SIGNING_KEY (base64 of 32 bytes) in
  shorashim-secrets; absent → unsigned and says so.
- GET /courtyard/ledger?limit&before — this key's bedikot, newest first,
  each rendered and signed; GET /courtyard/bedikah/<ref> — one, 404 for
  another key's or none alike; GET /peah/ledger_key — the public key.
- Tamid §1.1 "The ledger" (Doorpost pin moved → 02ddf313…), KOHEN
  "Issuing a key" (the seed, once), secrets.yaml template.

Tests: ledger_test (digest, entry, canonical, sign/verify, unsigned
rendering, signed rendering); ledger_l3_test (own records listed newest
first and only own; 404 alike for another's and none; an examination
engraved under the key reads back signed and verifies against
/peah/ledger_key). Elixir 642/0; L3 13/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 18:45:47 +00:00
Scott KarpandClaude Fable 5.1 e6c3702a22 tablet 57: the specification brought to the gate
Every category-grade spec read as an offering by the live bedikah
(18 sections), plus the vocabulary audit the outside reader asked for.
Findings: the vocabulary of the frame is absent or named-to-refuse,
save one sentence in lo_taaseh_lekha §9; every structural/frame fire
is the specification quoting the form it teaches (use vs mention —
recorded, not fixed); grounded against the chain of authority the
operational docs stand (Vayakhel 15→2, Tamid 17→5 golden-calf fires),
the architecture half-stands (49→23: component detail the PRD does
not say), and the PRD's Torah in transliteration is invisible to the
anchor (19 of 61 claims). Reports: operator notes
specimens/specs-bedikah-2026-10-21*.{json,txt}; scripts
specs-bedikah.py, specs-bedikah-chain.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 18:37:12 +00:00
Scott KarpandClaude Fable 5.1 354e223384 housekeeping: the inventory a stranger reads first
From a reading of the gate's documents (2026-10-21):
- AGENTS.md: Steps 14 and 15 shown Pending while the Port and both
  sockets have carried live traffic since the gate was built → Done,
  with what carries them; "next build task" now points at the open
  items, the phase tables being complete.
- README.md: the repository tree omitted azazel_processor,
  kushya_generator, mesorah, src/tools and scripts → listed, each in a
  line.
- deploy/ARCHITECTURE.md: the judgment path holds no model, said in
  writing — the examination is Python patterns and Prolog rules, the
  judges consult that Prolog; kushya_generator (a fine-tuned model in
  this repo, not deployed, not wired) is an OFFERER whose output is
  examined, never a component the bedikah, the engine or a judge
  consults. The kohen is not the offerer. azazel_processor listed.
- The map: the repository is public; eleven files named the ingress
  NLB's literal address and five the kohen's kubeconfig path. Docs now
  use the domains (git.bikoret.ch, bikoret.ch) or "<NLB-IP>" with the
  kubectl query that answers it; the kubeconfig is "$KUBECONFIG" /
  "$HOME/.kube/<your-cluster>.yaml". gitea-public-apply.sh takes NLB
  from the environment. None of it was a secret; all of it was a map.
- OTP pin: checked, not changed — the segfault in the build log was a
  source-built OTP 26.2.1 on macOS; the image is hexpm's 26.2.1 with
  ERL_FLAGS +JMsingle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 18:32:35 +00:00
Scott KarpandClaude Fable 5.1 50b08cba4b manna: the provision falls at the boundary — 00:00Z — and readiness is carried across it
The watchdog's first night (2026-10-21): 503 at 00:00:01Z with
manna_present and esh_tamid failing, recovered 00:05Z. Every night since
the CronJob was applied the site has been dark one to five minutes:
the provision is stale from 00:00:00, the job ran at 00:01, and the
readiness probe (10 s period, default threshold 3) fell in the gap.

Not changed: the rule. A provision is for its day; Friday's double is
the Shabbat's; nothing else crosses the boundary (Shemot 16:19–26,
manna_shabbat_test.exs). Changed: the timing.
- manna-cronjob.yaml: schedule "0 0 * * 0-5" — the manna falls at the
  boundary, not a minute after it.
- deployment.yaml: readinessProbe.failureThreshold 6 — 60 s of failures
  before the Service stops routing. The job writes within seconds of
  00:00; the pod is carried across. A real outage still shows within a
  minute; the watchdog's own threshold is ten.
- KOHEN.md: the night's finding and the new timing.

Both manifests validate (kubectl apply --dry-run=client). Proof: the
watchdog log on the next boundary should show no FAIL at 00:00.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 17:53:45 +00:00
Scott KarpandClaude Fable 5.1 489cf11416 kohen: the watchdog — /kodesh/health watched from outside, a Gitea issue on failure
2026-10-15: the site was 503 for 13.5 h across a day boundary and the
Doorpost outage was found by losing Claude; 24 h of database refusals
were found the same way. Nothing watched.

scripts/bikoret-watchdog (installed at ~/.local/bin, cron every 5 min
on the dev VM) probes /kodesh/health. Two consecutive failures open ONE
Gitea issue in this repo naming the failed checks and what to read
first; while down it comments only when the failed set changes; on
recovery it comments the duration and closes the issue. Transitions go
to ~/shorashim-operator-notes/watchdog.log; silence is health.

Proven: healthy → exit 0, no log; dead URL ×3 → FAIL #1, #2, one ALERT
(not two), #3 no second issue; recovery → comment + close. The real
Gitea call was refused 403: the clone token in ~/.git-credentials has
only the code scopes. The script prefers
~/.config/bikoret/gitea_watchdog_token (write:issue), which the kohen
issues; until then it logs and tracks recovery but cannot open the
issue. KOHEN.md "Every morning" carries it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 18:13:19 +00:00
Scott KarpandClaude Fable 5.1 02f71dab49 Retire shorashim.ch; Gitea brand becomes Bikoret
- service.yaml: the shorashim.ch host rule is gone from the Ingress;
  the no-host catch-all stays for http://185.19.28.170
- gitea-public-patch.yaml: APP_NAME "Shorashim" → "Bikoret" (title and
  brand on every Gitea page)
- shorashim-ch-takedown.sh: the operator's apply (ingress, orphan
  mcp.shorashim.ch TLS secret, Gitea patch + rollout) with verification
- KOHEN.md, AGENTS.md: examples and the endpoint table use bikoret.ch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 17:24:15 +00:00
Scott KarpandClaude Fable 5.1 cf8b5e3dc4 tablet 56: the gap that was not there — empty says what was read
The builder claimed the structural and frame checks never see an
engineering reply because its sentences are not "vital". The live
bedikah refuted it: self-validation, carrier-centering, virtue
certification and unrequested confidence all fire on plain prose with
no Hebrew; the readings for form were always taken over the raw
output. Nothing built for that; the tablet records it.

What was true: `empty` said "nothing to examine", and that under-
reported. Now the empty record carries checks.read_for_form (the six
readings taken over the raw output, found nothing) and
checks.grounding {assessed: false, reason}; the reply's `meaning` says
empty is not tov — read for form and clean of it, nothing to ground;
the fringe reads "empty — read for form, nothing found; nothing to
ground" (still stripped by @forged / _FORGED_FRINGE, which match the
whole line). Elixir 633/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 17:02:51 +00:00
Scott KarpandClaude Fable 5.1 53778f372e Gitea MCP connector: MCP OAuth mode on bikoret.ch
The claude.ai connector ran the OAuth flow against a server in
static-token mode, which has no OAuth endpoints: the login ended on a
404. The proper form is gitea-mcp's -oauth mode, where each user
authorizes with their own Gitea account; it needs both origins public
over HTTPS and Gitea's ROOT_URL to be that origin.

- gitea-ingress.yaml: git.bikoret.ch → temple-council/gitea, TLS
- gitea-public-patch.yaml: ROOT_URL/DOMAIN/SSH_DOMAIN = git.bikoret.ch
  (were a name that never resolved and a released NLB IP)
- gitea-mcp-deployment.yaml: -oauth -oauth-public-url
  https://mcp.bikoret.ch -H https://git.bikoret.ch, no static token,
  probes on /healthz
- gitea-mcp-ingress.yaml: host mcp.bikoret.ch (mcp.shorashim.ch retired)
- gitea-public-apply.sh / gitea-public-verify.sh: the operator's
  DNS-gated apply and read-only verification (also in ~/.local/bin)
- KOHEN.md, deploy/ARCHITECTURE.md: the new doors and the Gitea OAuth2
  app ("Claude", PKCE, claude.ai + Claude Code redirect URIs)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 21:55:05 +00:00
Scott KarpandClaude Fable 5.1 861cd4e64e engine: the verse facts are the whole scroll, derived from L1
torah_text.pl held 32 hand-written verses from Phase 1 — "the reasoning
layer's view of the specification". With the cycle and the Sanhedrin
now consulting the engine on every question (2026-10-19), that view
showed: any root it knew lit the same handful, and bereishit 1:1 and
devarim 6:4 were read in the same eleven verses.

- scripts/build_engine_verses.py writes
  talmudic_engine/src/specification/l1_verses.pl from l1/torah: every
  verse, each word's shoresh as the engine names it (Hebrew → letter
  names, finals folded), the consonantal text as gloss, and the SHA-256
  of the L1 files read in the header. `--check` exits 1 when the facts
  lag the scroll; build_and_push.sh runs it beside the Doorpost check.
- torah_text.pl includes the generated file; the hand facts are gone.
  L1's roots are authoritative where the hand facts differed (Israel
  שרה, the Name הוה).
- interpret.pl: over 5,853 verses a common root lights hundreds, so the
  reading ranks verses by how many of the case's roots each carries and
  keeps the top 12 in canonical order; the coupling trace names only the
  verses the reading cites.

Local, over the full scroll: bereishit 1:1 → itself, Devarim 4:32,
Bereishit 2:4 …; devarim 6:4 → itself, Shemot 3:18, Devarim 4:1 …;
shabbat roots → Bereishit 2:3, Shemot 20:8, 20:11, 31:13. interpret
15 ms, challenge 10 ms (now finds a kushya for the Shema), resolve
24 ms. Prolog 7 suites green (middot 2.9 s — pairwise scans over the
scroll). 5,853 verses loaded, 49,542 root occurrences.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 21:37:29 +00:00
Scott KarpandClaude Fable 5.1 4e0eb2f983 cycle: the engine is consulted on every resolved question
The Talmudic route ran only on a teiku from ki_tov, and ki_tov returns
teiku only for a root with no Hebrew — which the Shoresh Engine never
leaves. So the engine had never read a real question. Now every cycle
that resolves tov consults it with the roots it activated
(consult_engine/2, on the socket directly — never the client's stub;
an unreachable engine is reported "not consulted" and the cycle's
answer stands). A tamid probe does not consult.

The reading is said plainly (engine_reading_text/1): "Talmudic Engine
(resolved): 11 verse(s): Bereishit 1:1, …; 4 mitzvah/mitzvot:
know_hashem (1, positive), …" — the engine's own term form is no longer
pasted into a reader's answer. The verses join the sources as
references ("Bereishit 1:3", engine form beside), the L3 interpretation
carries them in torah_references with the reading in its reasoning and
processing_component "bereishit_cycle+talmudic_engine".

Proven against the engine running locally on its socket, bereishit 1:1
through VavPipeline.process/1: consulted :ok, 11 verses in the answer
and the sources, L3 torah_references 12; engine stopped → "not
consulted (Cannot connect)", answer stands, L3 component
bereishit_cycle; tamid probe → not consulted. Elixir 632/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 19:02:51 +00:00
Scott KarpandClaude Fable 5.1 ab6ca1ebcf cycle: the Talmudic routing sends roots the engine can read
route_if_unresolved sent the roots' Hebrew (ברא) to the Talmudic
Engine, whose verse facts name roots by letter (bet_resh_aleph, read
from "bet-resh-aleph"). Since Step 14 every interpretation the cycle
took from the engine was "resolved" over an empty trace — 0 verses,
0 mitzvot — and so was every case it escalated to the Sanhedrin. Now
the names go up (BereishitCycle.engine_root_name/1, from tablet 55's
third commit), on both the interpretation and the escalation.

Proven against the engine running locally on its socket, with the
roots the cycle itself activates for bereishit 1:1:
  HEBREW (as before): status=resolved verses=0  mitzvot=0
  NAMES  (now):       status=resolved verses=11 mitzvot=4
Elixir 632/0. The unresolved route cannot be driven in the test env
(it needs the Shoresh Engine to leave a root ungrounded); the live
proof is a pe'ah question with an ungroundable Hebrew word.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 18:45:06 +00:00
Scott KarpandClaude Fable 5.1 e01181959d tablet 55, fourth: the engine answers the whole dialectic over the wire
Live after vav :4bffa01e the judges consulted with roots the engine
could read — interpret found 4 mitzvot and 11 verses for bereishit 1:1
— and the other two seats still said teiku. The engine had no
"challenge" action at all (process_json_line failed and the socket
fell silent), and a resolve that SUCCEEDED came back
UNEXPECTED_RESPONSE because response(ok, resolved_accepted, ...) had
no JSON clause. Both pre-existing; both invisible until a judge asked.

- api.pl: request(challenge, ...) — interpret, then a kushya against
  it → resolved_challenged with the kushya; none → no_challenge with
  the reading.
- json_bridge.pl: interpreted | resolved_accepted | no_challenge share
  the "resolved" JSON; resolved_challenged now carries the
  interpretation's verses and mitzvot (a judge rejecting on the kushya
  cites its ground); resolved_synthesized and no_interpretation gain
  clauses.
- reasoning_test.pl: dialectic_over_the_wire — all three actions
  answer; no roots → resolved on an empty trace (the judge abstains).

Prolog: 7 suites green (bedikah 45, halt 49, middot 47, mitzvot 42,
phase3 28, reasoning 18, tzaraat 34). Local probe: interpret /
challenge / resolve each → resolved, 11 verses, 4 mitzvot.

Live proof waits for tomorrow's manna: today's five panels were all
spent on verification (503 MANNA_DEPLETED, as specified).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 18:25:17 +00:00
Scott KarpandClaude Fable 5.1 4bffa01e0e tablet 55, third: roots the engine can read; abstentions count; an empty trace is no ground
Live after the second commit (vav :046010f0): a monetary case naming
bereishit 1:1 convened three judges and came back "accepted" — the
interpret seat said "resolved (5 roots, 0 verses)" with
reading(mitzvot_count(0),verse_count(0),grounded_in([],[])), the other
two seats said teiku, and one accept beat two abstentions (the reply
showed majority 1, minority 2). Three defects the tests could not see:

- The engine names roots by letter (bet_resh_aleph; json_bridge reads
  "bet-resh-aleph") and the pipeline sent it the Hebrew, which its
  verse facts never match. BereishitCycle.engine_root_name/1 builds
  the engine's name from the Shoresh Engine's letter names or, for the
  L1 fallback, from the Hebrew with finals folded; the router sends
  that. (The cycle's own Talmudic routing still sends the Hebrew —
  recorded, not changed here: it alters /peah/ask and needs its own
  verification.)
- judge.erl: "resolved" with no verse and no mitzvah is a reading
  coupled to nothing (avodah_zarah.pl Form 2); the judge abstains and
  says "resolved on an empty trace; nothing to stand on".
- panel.erl: the majority is the verdict with the most votes,
  abstentions included; an abstaining plurality or an accept/reject
  tie is teiku.

EUnit 57/0 (plurality, tie, empty trace). Elixir 632/0 (engine_root_name
from names, from Hebrew, nil on nothing; the no-engine case is an
abstaining majority of one, teiku).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 18:18:50 +00:00
Scott KarpandClaude Fable 5.1 046010f0ed tablet 55, second: the judge consults — the Sanhedrin no longer rolls dice
judge.erl chose one of six canned positions by a hash over the question,
its own pid and the clock; a panel of one "accepted" on zero roots and
zero references under "Torah references ground this interpretation" —
the calf, cast by the court itself. Architecture §3.2 says a judge
"consults the specification (L0/L1)"; Construction §3.2 says the
pipeline hands the case roots and torah_refs. Neither happened.

Sanhedrin (Erlang):
- judge.erl: seat N performs step N of the dialectic — interpret,
  challenge, resolve, cycling — through a consult {M, F} in the app env.
  resolved → accept with the verses; challenged (a kushya stands) →
  reject; halt → reject; teiku → abstain. No consult, an error, or a
  consult that raises → abstain, and the reasoning says so; the judge
  stands. judge:start_link/2 takes the seat; panel.erl seats its judges.
- EUnit 54/0: seats cycle; no consult → abstain; resolved → accept with
  #torah_ref{}; a raising consult → abstain and alive; a panel of three
  with a kushya at seat 2 → accepted 2–1, minority preserved with verse.

Pipeline (Elixir):
- VavPipeline.SanhedrinConsult: what a judge asks — TalmudicEngineSocket
  interpret/challenge/resolve over the case's roots, direct (the client's
  stub fallbacks are NOT used: a court that accepts on a stub is the
  calf). Engine absent → {:error, _} → the judge abstains. Registered in
  the sanhedrin env at application start.
- router process_sanhedrin: the case is heard before it is submitted —
  ScopeResolver → Shema context (cloud honoured) → tohu → vayomer
  (Shoresh Engine over the verse) → roots; torah_refs = the scope;
  complexity_hint names the panel (422 on an unknown one); one unit of
  sanhedrin_panels_max per case (503 MANNA_DEPLETED when spent); the
  decision engraved in L3 as an interpretation (panel_size, votes
  for/against, processing_component "sanhedrin", coupling root→verse)
  with the minority beside it (grounding_type achorai); the reply in
  the Tamid's shape (position, reasoning, sources, votes) plus
  grounding {scope, roots_consulted, recorded}.
- Tests: Elixir 628/0 (router 30, incl. monetary → panel 3; no engine →
  teiku with 0 votes, never accepted; fake consult → 2–1 with sources;
  manna → 503; unknown hint → 422); L3 test: interpretation + minority
  rows land.

Pre-existing and untouched: with --include l3 the gate and hakhel
tests fail 17 times on main as well (the gate's local upstream double
refuses connections under the L3 flag) — a separate finding.

Doorpost pin unchanged (no @spec_files touched).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 17:04:46 +00:00
Scott KarpandClaude Fable 5.1 7c40200dfc tablet 55: the row that was a label — the Sanhedrin's example request crashed its own gate
Verified live 2026-10-19: Tamid's own example for POST /chatzer/sanhedrin
(a case with `sides`) answered 500. The router passed the sides LIST as
the case context; halt_monitor:check_avodah_zarah/1 reads a map, maps:get
raised, and the halt monitor and escalation gen_servers died with the
caller's input (supervisor restarted them; health stayed 8/8). The router
then matched {:ok, decision} on the error and made a 500 of its own.

- sanhedrin/src/halt_monitor.erl: do_check_case refuses a non-map
  context as {halt, malformed_case, _} and stays standing. EUnit 49/0
  with the new halt_malformed_context_test.
- router.ex process_sanhedrin: sides validated at the kiyor (422 on a bad
  shape), carried as context %{sides: sides}; a Sanhedrin refusal answers
  422 SANHEDRIN_HALTED, a failure 503 SANHEDRIN_UNAVAILABLE. Two new
  router tests, one asserting the gate processes survive the example.
  Elixir 624/0.
- specs/broken_tablets/README.md: tablet 55, including what is NOT fixed
  — a panel of one accepting on zero references under a grounded-
  sounding sentence, the ignored complexity hint, no panel manna spent,
  no L3 record. The row in Tamid is still larger than the thing behind it.

Doorpost pin unchanged (no @spec_files touched; scripts/doorpost_check.py
passes).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:44:53 +00:00
Scott KarpandClaude Fable 5.1 04f61c1238 build: the Doorpost read before the image is built
Tablet 54 (2026-10-15): two spec edits in @spec_files shipped without
moving @expected_hash; the pod logged DOORPOST_DRIFT_AT_STARTUP,
readiness answered 503, and the gate — with every Claude Code session
behind it — went dark. The Doorpost test catches this only when the
suite runs on the exact commit that gets built, and the build script
never ran it.

scripts/doorpost_check.py computes the Doorpost identity the way the
module does (sha256 per spec file in listed order, "path\thash" joined
by newline, sha256 of that) with no Elixir, against the working tree
the image is copied from (default) or a git revision (--rev). On a
mismatch it exits 1 and names each file changed since --base
(origin/main). scripts/build_and_push.sh runs it before anything else
and refuses to build; DOORPOST_SKIP=1 is the written-down exception.

Proven: working tree and cb18f719 match their pins; 347f83cf (the
tablet 54 commit) reports DRIFT naming specs/bikoret_tamid.md and
specs/bishearecha.md; a blank line appended to specs/lo_tosif.md makes
both the check and the build script exit 1. KOHEN.md "Rolling out"
carries the procedure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 16:25:09 +00:00
55 changed files with 9019 additions and 326 deletions

No files matched your search

+7 -6
View File
@@ -25,6 +25,7 @@ before writing code. Do not summarize from memory. Read fresh.
| Pekudei (Economic Model) | `specs/bikoret_pekudei.md` | How it is funded |
| Terumah (Materials) | `specs/bikoret_terumah.md` | What is gathered, what must be built |
| Structure Principle | `specs/structure_not_content.md` | What the system IS, not what it knows |
| Sugya | `specs/sugya.md` | The case behind each threshold: kind, precedent, dissent, exception — from Torah's three procedures of judgment |
| Bishe'arecha (the gate) | `specs/bishearecha.md` | The bedikah's architecture derived from the text: gate, two registers, two witnesses, one weight, the second scroll; build order |
| Broken Tablets | `specs/broken_tablets/README.md` | Read this FIRST — see where the calf was |
| Build Day One | `BUILD_DAY_ONE.md` | First build instruction |
@@ -53,10 +54,10 @@ before writing code. Do not summarize from memory. Read fresh.
| 11. Manna provision infrastructure | Done | Provision file validation, expiry, anti-hoarding, double portion |
| 12. Sanhedrin framework (Erlang) | Done | `sanhedrin/` — 8 modules, 41 tests, all passing |
| 13. Korban Processor (Python) | Done | `korban_processor/` — 7 modules, 181 tests, all passing |
| 14. Connect components | Pending | Elixir → Haskell port, Elixir → Prolog socket |
| 15. Integration verification | Pending | |
| 14. Connect components | Done | Elixir → Haskell Port (`ShoreshEngineClient`), Elixir → Prolog socket (`TalmudicEngineSocket`), Elixir → Korban socket (`KorbanProcessorSocket`); line-framed replies (tablet 50) |
| 15. Integration verification | Done | Live round trips proven on the cluster: pe'ah cycle, bedikah, the gate with a real model (2026-10-13), the Sanhedrin's full dialectic (2026-10-20) |
**Next build task:** Step 14 — Connect components. Read Architecture §4 Step 14.
**Next build task:** none from the phase tables — all six steps of `specs/bishearecha.md` are built. Build from the open items in the builder's memory and the Broken Tablets; a spec row is a claim to test.
## Broken Tablets (Build Log)
@@ -104,10 +105,10 @@ approve decisions, or supervise work. The specification does all of this.
| Service | Location | Access |
|---------|----------|--------|
| Gitea | `http://185.19.28.170:3000` (SSH :22) — behind the shared ingress NLB | User: heaviness510 |
| Bikoret | `http://185.19.28.170` — behind the same ingress (catch-all; `shorashim.ch` once DNS points there) | keys: KOHEN.md "Issuing a key" |
| Gitea | `https://git.bikoret.ch` (git over HTTPS; SSH on the ingress NLB :22) | User: heaviness510 |
| Bikoret | `https://bikoret.ch` (the ingress catch-all also answers on the NLB's bare IP; `shorashim.ch` retired 2026-10-20) | keys: KOHEN.md "Issuing a key" |
| PostgreSQL | Exoscale DBaaS ch-gva-2 | `temple-council-db` |
| Kubernetes | Exoscale SKS ch-gva-2 | `~/.kube/exoscale-temple-council.yaml` |
| Kubernetes | Exoscale SKS ch-gva-2 | the kohen's `$KUBECONFIG`, outside the repo |
## Principles
+81 -15
View File
@@ -53,8 +53,8 @@ You inherit infrastructure that has been provisioned and verified:
| Container registry `the-recognition-protocol` | All 4 bikoret images pushed and tagged `:latest`. |
| Block Storage CSI driver | Installed. StorageClass `exoscale-sbs` available. |
| cert-manager + nginx-ingress | Running. ClusterIssuer `letsencrypt-prod` working. |
| Gitea (persistent storage) | Running. OAuth app + PAT exist for the operator's account. |
| Gitea MCP server | Running at `https://mcp.shorashim.ch`. Connected to claude.ai. |
| Gitea (persistent storage) | Running. Public HTTPS at `https://git.bikoret.ch` (ROOT_URL) since 2026-10-19; git also over `http://<NLB-IP>:3000` and SSH :22. The web UI needs the https door (Secure cookie). |
| Gitea MCP server | `https://mcp.bikoret.ch/mcp`, MCP OAuth mode: each user authorizes with their own Gitea account (Gitea OAuth2 app "Claude", PKCE, client ID in the claude.ai connector's Advanced settings). Apply: `bash ~/.local/bin/gitea-public-apply`; check: `gitea-public-verify`. The old `mcp.shorashim.ch` and the shared static token are retired. |
| L1 Torah filesystem | In the `bikoret` repo at `l1/torah/`. SHA-256 `9e1abe4e…3c3086` (full hash in `l1/integrity.sha256`; verified at Day 1). |
| Image pull secret `shorashim-registry` | **Verify before assuming.** A prior partial setup may have created it. `kubectl get secrets -n shorashim` is the source of truth. |
| `shorashim-secrets` (DATABASE_URL) | **Verify before assuming.** Same as above. If present, also verify the role it points at (the architecture requires `shorashim_app`, not `avnadmin`). |
@@ -73,8 +73,8 @@ You inherit infrastructure that has been provisioned and verified:
| Item | Where |
|------|-------|
| Repo | `git clone http://185.19.28.170:3000/heaviness510/shorashim.git` |
| Kubeconfig | `~/.kube/exoscale-temple-council.yaml` |
| Repo | `git clone https://git.bikoret.ch/heaviness510/bikoret.git` |
| Kubeconfig | `$KUBECONFIG` — the kohen's own file, outside the repo |
| `kubectl` | already installed |
| `exo` CLI | already installed |
| Docker daemon | only for image rebuilds (not needed for inauguration) |
@@ -84,7 +84,7 @@ If the kubeconfig has expired:
```bash
exo compute sks kubeconfig temple-council kube-admin --zone ch-gva-2 -g system:masters \
> ~/.kube/exoscale-temple-council.yaml
> "$KUBECONFIG"
```
---
@@ -146,7 +146,7 @@ ecto://avnadmin:<password>@temple-council-db-exoscale-66fffc16-59ac-4a62-9c19-f3
#### 2b. Apply the secrets
```bash
export KUBECONFIG=~/.kube/exoscale-temple-council.yaml
export KUBECONFIG="$HOME/.kube/<your-cluster>.yaml"
# DATABASE_URL secret
kubectl create secret generic shorashim-secrets \
@@ -463,7 +463,7 @@ kubectl logs -n shorashim deploy/bikoret -c vav-pipeline -f
If the seven days passed without halt events, without Shabbat violations, without manna exhaustion, without any pod restarts:
1. **Point DNS** — at the registrar, A records to the ingress IP (`kubectl get svc -n ingress-nginx ingress-nginx-controller`; `185.19.28.170`): `bikoret.ch`, `www.bikoret.ch`, and for the redirect `bikoret.org`, `www.bikoret.org`. Wait until `dig +short A bikoret.ch` answers the IP — only then `kubectl apply -f deploy/k8s/service.yaml`: it carries the TLS blocks (cert-manager, `letsencrypt-prod`, HTTP-01), and a certificate asked for before the name resolves burns Let's Encrypt's failed-validation limit. Then `kubectl -n shorashim get certificate` until both are Ready, and callers use `https://bikoret.ch`; `shorashim.ch` still answers over plain HTTP until its callers have moved
1. **Point DNS** — at the registrar, A records to the ingress IP (`kubectl get svc -n ingress-nginx ingress-nginx-controller`): `bikoret.ch`, `www.bikoret.ch`, and for the redirect `bikoret.org`, `www.bikoret.org`. Wait until `dig +short A bikoret.ch` answers the IP — only then `kubectl apply -f deploy/k8s/service.yaml`: it carries the TLS blocks (cert-manager, `letsencrypt-prod`, HTTP-01), and a certificate asked for before the name resolves burns Let's Encrypt's failed-validation limit. Then `kubectl -n shorashim get certificate` until both are Ready, and callers use `https://bikoret.ch`; `shorashim.ch` still answers over plain HTTP until its callers have moved
2. **Open the pe'ah endpoint to the world** — the stranger approaches first
3. **Observe the first real interaction** — a question from outside
4. If *ki tov*: open courtyard and chatzer endpoints. The system is consecrated.
@@ -481,15 +481,56 @@ If any day failed: record what broke, fix the cause, restart from Day 1.
```bash
# 1. Write today's manna provision (same format as Day 4, new date)
# 2. Health check
curl https://shorashim.ch/kodesh/health | jq .
curl https://bikoret.ch/kodesh/health | jq .
# 3. Halt status
curl https://shorashim.ch/kodesh/status | jq .halt_status
curl https://bikoret.ch/kodesh/status | jq .halt_status
# 4. Refuge list
curl https://shorashim.ch/kodesh/status | jq '.refuge_components'
curl https://bikoret.ch/kodesh/status | jq '.refuge_components'
```
**The watchdog — *shomer*.** `scripts/bikoret-watchdog` (installed at
`~/.local/bin/`, cron every 5 minutes on the dev VM) probes
`/kodesh/health` from outside the cluster. Two consecutive failures —
ten minutes, so a rollout's own blip does not alert — open ONE Gitea
issue in this repo titled `WATCHDOG: bikoret.ch down since …`, naming
the failed checks and what to read first; while down it comments only
when the failed set changes; on recovery it comments the duration and
closes the issue. Every transition goes to
`~/shorashim-operator-notes/watchdog.log`. It needs a Gitea token with
`write:issue` at `~/.config/bikoret/gitea_watchdog_token` (0600) — the
clone token in `~/.git-credentials` has only the code scopes and Gitea
answers 403 on an issue. The kohen issues it (Gitea → Settings →
Applications → token, scopes `read:issue` + `write:issue`); the builder
does not write credentials. Until it exists the watchdog still logs and
tracks recovery; it cannot open the issue. 2026-10-15: the site was 503
for 13.5 h and the Doorpost outage was found by losing Claude — nothing
watched.
```bash
tail ~/shorashim-operator-notes/watchdog.log # transitions only; silence is health
BIKORET_WATCHDOG_DRY=1 bikoret-watchdog # a probe that opens nothing
```
### Issuing a key — *Mishmeret HaSha'ar*
**The ledger's signing key (once).** Callers read their bedikot back
signed (Tamid §1.1, "The ledger"); the signature is Ed25519 over a
32-byte seed the kohen makes and keeps in `shorashim-secrets`. Without
it the ledger is served unsigned and says so.
```bash
umask 077; SEED=$(openssl rand -base64 32)
kubectl -n shorashim patch secret shorashim-secrets --type=merge \
-p "{\"stringData\":{\"BIKORET_LEDGER_SIGNING_KEY\":\"$SEED\"}}"
unset SEED
bash ~/.local/bin/shorashim-rollout # envFrom reads the Secret at start
curl -s https://bikoret.ch/peah/ledger_key # "signed": true, the public key
```
Rotating the seed invalidates every signature given before it; keep the
old public key published beside the new if records must stay verifiable.
The courtyard and the chatzer open only to a key you issued. Keys live
in the `shorashim-api-keys` Secret, mounted as a file; the gate reads
the file on every request, so a change takes effect within about a
@@ -566,6 +607,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
@@ -710,14 +771,19 @@ run on Saturday.
### The manna, each morning — *Dvar yom b'yomo*
Since 2026-10-15 the day's provision is written by the system's own hand:
CronJob `manna` (`deploy/k8s/manna-cronjob.yaml`) runs at 00:01Z Sunday
CronJob `manna` (`deploy/k8s/manna-cronjob.yaml`) runs at 00:00Z Sunday
to Friday, mounts the manna PVC and writes `today.json` with the amounts
from `shorashim-config` (`MANNA_COMPUTE_UNITS` …). Before it, every night
at 00:00Z the old provision went stale and the readiness probe failed
until someone woke (13.5 hours on 2026-10-15). The kohen's acts now:
until someone woke (13.5 hours on 2026-10-15). With the job at 00:01Z the
site was still 503 for one to five minutes every night (the watchdog saw
it, 2026-10-21): the provision is stale from 00:00:00 and readiness fell
before the job wrote. Now the job runs at 00:00Z and readiness waits 60 s
(`failureThreshold: 6`) before the Service stops routing — the manna
falls at the boundary, and the pod is carried across it. The kohen's acts now:
- set the amounts in the ConfigMap (`kubectl apply -f deploy/k8s/configmap.yaml`);
- read `kubectl -n shorashim get jobs` after 00:01Z — a failed job is the
- read `kubectl -n shorashim get jobs` after 00:00Z — a failed job is the
Day-4 alarm; write by hand with `kubectl cp` (Day 4) if it failed;
- run it at once when needed: `kubectl -n shorashim create job --from=cronjob/manna manna-now`.
@@ -730,7 +796,7 @@ The halt monitor detected one of three cardinal sins:
```bash
# Inspect the halt
curl https://shorashim.ch/kodesh/status | jq .halt_status
curl https://bikoret.ch/kodesh/status | jq .halt_status
kubectl logs -n shorashim deploy/bikoret -c vav-pipeline | grep HALT
# Investigate. Fix the root cause. Then resolve via the operator API.
@@ -765,7 +831,7 @@ Three things are kept outside the mishkan repository, each for a Torah-grounded
| Repo / directory | Where | Why |
|---|---|---|
| `bikoret-readings` | `http://185.19.28.170:3000/heaviness510/shorashim-readings` | The book — Torah parshiyot read as AI specification. Companion to the mishkan. Lives beside the aron, not inside (Devarim 31:26). |
| `bikoret-readings` | `https://git.bikoret.ch/heaviness510/shorashim-readings` | The book — Torah parshiyot read as AI specification. Companion to the mishkan. Lives beside the aron, not inside (Devarim 31:26). |
| `~/shorashim-source-data/morphhb` | local only | The quarry — raw WLC source. The carved tablets go in the aron; the quarry does not. |
| `~/shorashim-operator-notes` | local only / private | The kohen's runbook — environment-specific details, hostnames, account names. Private operator notes. Not part of the system's specification. |
+1 -1
View File
@@ -65,7 +65,7 @@ The runbook lists what should already exist. Verify it. Do **not** trust the tab
```bash
# 0a. Cluster reachable
export KUBECONFIG=~/.kube/exoscale-temple-council.yaml
export KUBECONFIG="$HOME/.kube/<your-cluster>.yaml"
kubectl version
kubectl get nodes
+6
View File
@@ -175,6 +175,7 @@ bikoret/
│ ├── bikoret_tamid.md # Tamid — operations, the continual service
│ ├── bikoret_pekudei.md # Pekudei — economic model, the accounts
│ ├── structure_not_content.md # What the system IS, not what it knows
│ ├── sugya.md # The case behind each threshold — deed, mark, word; the dissent recorded
│ ├── eish_zarah_reading.md # The five faces of strange fire
│ ├── lo_taaseh_lekha.md # The anti-override boundary
│ └── broken_tablets/ # The first tablets, preserved
@@ -184,6 +185,11 @@ bikoret/
├── vav_pipeline/ # Elixir — Bereishit cycle, Shema, Cloud, Manna, HTTP API
├── talmudic_engine/ # SWI-Prolog — 613 mitzvot, 13 middot, kushya
├── korban_processor/ # Python — offering protocol, eish zarah validation
├── azazel_processor/ # Python — the goat for Azazel: disposal records, vidui, L3 ingest
├── kushya_generator/ # Fine-tuned model (NOT deployed) — an offerer, never a judge; see deploy/ARCHITECTURE.md
├── mesorah/ # The second scroll — 128 works from the Sefaria export, pinned (Devarim 17:18)
├── src/tools/ # Specimen corpus, hakhel, evaluation (the kohen's measuring tools)
├── scripts/ # Build and operate: L1/mesorah/engine-verse builders, Doorpost check, rollout, watchdog
│
├── deploy/ # Infrastructure
│ ├── ARCHITECTURE.md # Generic deployment shape (no account specifics)
+12 -4
View File
@@ -11,10 +11,14 @@ One Kubernetes namespace runs the following:
| `vav-pipeline` | Elixir/OTP release | Bereishit cycle, ShemaContext, Cloud, Manna, HTTP API (pe'ah, courtyard, chatzer, kodesh) |
| `shoresh-engine` | Haskell binary, spawned by vav-pipeline as an Erlang Port (staged into the pod by the `shoresh-engine-install` init container — NOT a sidecar container; a Port is a child process of the BEAM and cannot cross a container boundary) | Root activation, te'amim parsing, gematria |
| `talmudic-engine` | SWI-Prolog (sidecar via Unix socket) | 613 mitzvot, 13 middot, kushya generation, teiku |
| `korban-processor` | Python 3.12 (sidecar) | Offering protocol, eish zarah validation |
| `korban-processor` | Python 3.12 (sidecar) | Offering protocol, eish zarah validation, the bedikah's readings (pattern-based: `korban_processor/bedikah.py`) |
| `kushya_generator/` | Fine-tuned model — **not deployed, not wired** | An OFFERER, if it is ever run: its output arrives at the gate like any model's and is examined. It is never a component the bedikah, the Talmudic Engine or the Sanhedrin consults. |
| `azazel_processor/` | Python — run by the kohen, not in the pod | Disposal records (the goat for Azazel); `VavPipeline.AzazelIngest` reads its files into L3 |
All four run in the same pod. vav-pipeline is the main container; talmudic-engine and korban-processor are sidecars; shoresh-engine runs inside vav-pipeline as an Erlang Port child process, staged in by an init container.
**No model stands on the judgment path.** The examination of an output — the offering protocol, the seven readings, the halt rules — is Python pattern extraction and Prolog rules, and the Sanhedrin's judges consult that same Prolog. Nothing that examines is a model, and nothing a model produces is consulted as a rule. The kohen is not the offerer. A fine-tuned model in this repository (`kushya_generator/`) is an offerer or a tool whose output is itself examined; wiring it into the bedikah, the engine or a judge would put the offerer on the altar, and is refused by this document before anyone does it for convenience.
## External dependencies
| Dependency | Purpose | Contract |
@@ -36,9 +40,13 @@ Kept in `deploy/k8s/`:
- `manna-volume.yaml` — PVC for provision file
- `deployment.yaml` — the pod with 3 containers + 2 init containers (`shoresh-engine-install`, `migrate`). Four images are still built and pushed; shoresh-engine ships a binary, not a running container.
- `service.yaml` — ClusterIP and external LoadBalancer
- `gitea-mcp-deployment.yaml` — separate MCP server for Gitea connector (optional)
- `gitea-mcp-ingress.yaml` — TLS ingress for the MCP server
- `gitea-mcp-secret.yaml` — template for Gitea PAT
- `gitea-mcp-deployment.yaml` — Gitea MCP server for the claude.ai / Claude Code connector, MCP OAuth mode (users act as themselves in Gitea)
- `gitea-mcp-ingress.yaml` — TLS ingress `mcp.bikoret.ch` for the MCP server
- `gitea-mcp-secret.yaml` — template for a Gitea PAT (static-token mode only; kept for rollback)
- `gitea-ingress.yaml` — TLS ingress `git.bikoret.ch` for Gitea itself (namespace temple-council), required by OAuth mode
- `gitea-public-patch.yaml` — patch that sets Gitea's ROOT_URL/DOMAIN/SSH_DOMAIN to `git.bikoret.ch` and APP_NAME to `Bikoret`
- `gitea-public-apply.sh` / `gitea-public-verify.sh` — the operator's apply and read-only check for the four above
- `shorashim-ch-takedown.sh` — 2026-10-20: removes the shorashim.ch ingress rule and the orphan mcp.shorashim.ch secret, applies the Gitea brand patch
- `cert-manager-issuer.yaml` — Let's Encrypt ClusterIssuer
## Containers
+7
View File
@@ -266,6 +266,13 @@ spec:
port: 4000
initialDelaySeconds: 10
periodSeconds: 10
# 60 s of failures before the Service stops routing here (the
# default is 30 s). The manna goes stale at 00:00:00 and the
# CronJob writes the new day's within seconds; the cushion
# carries the pod across that boundary without a 503. A real
# outage still shows within a minute, and the watchdog's own
# threshold is ten.
failureThreshold: 6
# === SHORESH ENGINE ===
# Not a container. It runs as an Erlang Port inside
+46
View File
@@ -0,0 +1,46 @@
# Ingress for Gitea itself — public HTTPS at git.bikoret.ch
#
# Added 2026-10-19 so the Gitea MCP server can run in MCP OAuth mode:
# claude.ai and Claude Code send the user to Gitea's own OAuth2
# authorize page, which must be reachable over HTTPS at Gitea's ROOT_URL
# (gitea-public-patch.yaml sets ROOT_URL = https://git.bikoret.ch/).
#
# Gitea lives in namespace temple-council (Service gitea, 80 → 3000).
# The plain-HTTP TCP passthrough on :3000 and SSH on :22
# (ingress-tcp-services.yaml) stay as they are; this adds a third door.
#
# Prerequisites:
# 1. DNS: git.bikoret.ch → <NLB-IP> (Infomaniak zone)
# 2. ClusterIssuer letsencrypt-prod
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
namespace: temple-council
labels:
app.kubernetes.io/name: gitea
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
# git push of large packs; Gitea enforces its own limits
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
spec:
ingressClassName: nginx
tls:
- hosts:
- git.bikoret.ch
secretName: gitea-bikoret-tls
rules:
- host: git.bikoret.ch
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 80
+30 -18
View File
@@ -1,13 +1,28 @@
# Gitea MCP Server — public connector for claude.ai
# Gitea MCP Server — public connector for claude.ai and Claude Code
#
# Deploys the official Gitea MCP server (gitea.com/gitea/gitea-mcp)
# in HTTP mode, exposing /mcp endpoint for Streamable HTTP transport.
# in HTTP mode with MCP OAuth (-oauth): every client authorizes against
# Gitea with its OWN account (PKCE, no client secret), and the server
# forwards that bearer token to Gitea per request. No shared static
# token is used any more (gitea-mcp-secrets is kept only for a rollback
# to the pre-2026-10-19 static-token mode).
#
# The MCP server connects to the Gitea backend over the cluster network
# (gitea.temple-council.svc.cluster.local) using a Personal Access Token
# stored in gitea-mcp-secrets. Until 2026-10-06 it went out to Gitea's
# own NLB (185.19.29.94) and back in; that NLB is gone — Gitea is behind
# the shared ingress now (ingress-tcp-services.yaml).
# The server serves /.well-known/oauth-protected-resource naming Gitea
# as the authorization server, so BOTH origins must be public HTTPS:
# -oauth-public-url https://mcp.bikoret.ch (gitea-mcp-ingress.yaml)
# -H https://git.bikoret.ch (gitea-ingress.yaml;
# Gitea ROOT_URL must be the same, gitea-public-patch.yaml)
# /mcp accepts only the Host from -oauth-public-url, and reverse proxies
# must pass Mcp-Protocol-Version, Mcp-Method and Mcp-Name through
# (ingress-nginx does).
#
# One-time in Gitea (Settings → Applications → Manage OAuth2 Applications):
# name Claude
# redirect URIs https://claude.ai/api/mcp/auth_callback
# http://127.0.0.1/callback (Claude Code; any port)
# confidential UNCHECKED (PKCE)
# The client ID goes into the claude.ai connector's Advanced settings,
# with the connector URL https://mcp.bikoret.ch/mcp.
---
apiVersion: apps/v1
kind: Deployment
@@ -33,18 +48,13 @@ spec:
args:
- "-t"
- "http"
- "-oauth"
- "-oauth-public-url"
- "https://mcp.bikoret.ch"
- "--host"
- "http://gitea.temple-council.svc.cluster.local"
- "https://git.bikoret.ch"
- "--port"
- "8080"
env:
- name: GITEA_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: gitea-mcp-secrets
key: GITEA_TOKEN
- name: GITEA_HOST
value: "http://gitea.temple-council.svc.cluster.local"
ports:
- containerPort: 8080
name: http
@@ -56,12 +66,14 @@ spec:
memory: "256Mi"
cpu: "250m"
livenessProbe:
tcpSocket:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
tcpSocket:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
+7 -5
View File
@@ -1,12 +1,14 @@
# Ingress for Gitea MCP Server — public HTTPS endpoint
# Routes mcp.shorashim.ch to the gitea-mcp service
# Routes mcp.bikoret.ch to the gitea-mcp service (moved from
# mcp.bikoret.ch on 2026-10-19 with the switch to MCP OAuth; the
# server only answers /mcp for the host in -oauth-public-url).
# TLS via cert-manager (Let's Encrypt)
#
# Prerequisites:
# 1. nginx-ingress controller installed
# 2. cert-manager installed
# 3. ClusterIssuer letsencrypt-prod applied
# 4. DNS: mcp.shorashim.ch → Ingress external IP
# 4. DNS: mcp.bikoret.ch → <NLB-IP> (the one NLB; Infomaniak zone)
---
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -22,10 +24,10 @@ spec:
ingressClassName: nginx
tls:
- hosts:
- mcp.shorashim.ch
secretName: gitea-mcp-tls
- mcp.bikoret.ch
secretName: gitea-mcp-bikoret-tls
rules:
- host: mcp.shorashim.ch
- host: mcp.bikoret.ch
http:
paths:
- path: /
+11 -5
View File
@@ -1,15 +1,21 @@
# Gitea MCP Server — secrets template
# NEVER commit actual values. Fill in and apply manually.
#
# Two secrets needed:
# Since 2026-10-19 the MCP server runs in OAuth mode and uses NO static
# token: users authorize with their own Gitea account. This secret is
# kept only for a rollback to static-token mode (see the deployment).
#
# Two secrets needed (static-token mode):
# 1. GITEA_TOKEN: Personal Access Token from Gitea
# Create at: http://185.19.28.170:3000/user/settings/applications
# Create at: https://git.bikoret.ch/user/settings/applications
# Permissions: repo (read/write)
#
# 2. OAuth Client ID/Secret: Registered OAuth app in Gitea
# Create at: http://185.19.28.170:3000/user/settings/applications
# Redirect URI: https://claude.ai/api/mcp/auth_callback
# (These go into claude.ai connector settings, not this secret)
# Create at: https://git.bikoret.ch/user/settings/applications
# Redirect URIs: https://claude.ai/api/mcp/auth_callback
# http://127.0.0.1/callback (Claude Code)
# Confidential Client: UNCHECKED (PKCE). Only the Client ID is needed;
# it goes into the claude.ai connector's Advanced settings, not here.
---
apiVersion: v1
kind: Secret
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Move Gitea and its MCP server to public HTTPS on bikoret.ch and switch
# the MCP server to MCP OAuth (2026-10-19). Run by the transmitter:
# ! bash ~/.local/bin/gitea-public-apply
# Idempotent. Needs DNS first: git.bikoret.ch and mcp.bikoret.ch → <NLB-IP>.
#
# Rollback (static-token mode on mcp.shorashim.ch, as before 2026-10-19):
# kubectl -n temple-council set env deploy/gitea \
# GITEA__server__ROOT_URL=http://gitea.temple-council.ch/ \
# GITEA__server__DOMAIN=gitea.temple-council.ch GITEA__server__SSH_DOMAIN-
# then restore deploy/k8s/gitea-mcp-*.yaml from commit 861cd4e6 and apply them.
set -euo pipefail
REPO="${REPO:-$HOME/bikoret}"
cd "$REPO"
NLB="${NLB:?set NLB to the ingress address: kubectl get svc -n ingress-nginx ingress-nginx-controller}"
for h in git.bikoret.ch mcp.bikoret.ch; do
ip=$(dig +short "$h" | tail -1 || true)
if [ "$ip" != "$NLB" ]; then
echo "DNS: $h → '${ip:-nothing}' (need $NLB). Add the A record at Infomaniak first." >&2
exit 2
fi
done
echo "== 1/4 ingresses (certificates issue once DNS resolves)"
kubectl apply -f deploy/k8s/gitea-ingress.yaml
kubectl apply -f deploy/k8s/gitea-mcp-ingress.yaml
echo "== 2/4 Gitea ROOT_URL → https://git.bikoret.ch/ (one restart)"
kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml
kubectl -n temple-council rollout status deploy/gitea --timeout=180s
echo "== 3/4 gitea-mcp in OAuth mode at https://mcp.bikoret.ch"
kubectl apply -f deploy/k8s/gitea-mcp-deployment.yaml
kubectl -n shorashim rollout status deploy/gitea-mcp --timeout=120s
echo "== 4/4 certificates"
for i in $(seq 1 24); do
ready=$(kubectl get certificate -A -o jsonpath='{range .items[?(@.metadata.name=="gitea-bikoret-tls")]}{.status.conditions[?(@.type=="Ready")].status}{end}{range .items[?(@.metadata.name=="gitea-mcp-bikoret-tls")]}{.status.conditions[?(@.type=="Ready")].status}{end}' 2>/dev/null || true)
[ "$ready" = "TrueTrue" ] && break
sleep 5
done
kubectl get certificate -A | grep -E "NAME|bikoret"
echo "== verify"
bash "$REPO/deploy/k8s/gitea-public-verify.sh"
+32
View File
@@ -0,0 +1,32 @@
# Strategic-merge patch for the Gitea Deployment (namespace temple-council,
# which is NOT otherwise managed from this repo): make Gitea's own URL the
# public HTTPS host, so its OAuth2 authorize/token endpoints and the
# issuer in /.well-known/openid-configuration are reachable by claude.ai.
#
# kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml
#
# The Gitea image rewrites app.ini from GITEA__section__KEY at start, so
# this replaces ROOT_URL/DOMAIN (were http://gitea.temple-council.ch, a
# name that never resolved) and SSH_DOMAIN (was 185.19.29.94, an NLB IP
# released on 2026-10-06). Clone URLs shown by Gitea become
# https://git.bikoret.ch/... and git@git.bikoret.ch:...; the old
# http://<NLB-IP>:3000 remote keeps working for push/pull.
# Side effect: with an https ROOT_URL Gitea marks its session cookie
# Secure, so the WEB UI must be used at https://git.bikoret.ch, not on
# the plain-HTTP :3000 door (git over :3000 is unaffected).
spec:
template:
spec:
containers:
- name: gitea
env:
- name: GITEA__server__ROOT_URL
value: "https://git.bikoret.ch/"
- name: GITEA__server__DOMAIN
value: "git.bikoret.ch"
- name: GITEA__server__SSH_DOMAIN
value: "git.bikoret.ch"
# [DEFAULT] APP_NAME — the title and brand on every Gitea page.
# Was "Shorashim"; the system is named Bikoret (PRD §0).
- name: GITEA____APP_NAME
value: "Bikoret"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Read-only checks for the public Gitea + OAuth MCP setup. Exit 0 = all pass.
# bash ~/.local/bin/gitea-public-verify
set -u
fail=0
chk() { # label expected actual
if [ "$2" = "$3" ]; then echo "ok $1: $3"; else echo "FAIL $1: got '$3', want '$2'"; fail=1; fi
}
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
chk "https://git.bikoret.ch/ status" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://git.bikoret.ch/ || echo err)"
chk "Gitea OIDC issuer" "https://git.bikoret.ch" "$(curl -sS -m 15 https://git.bikoret.ch/.well-known/openid-configuration | python3 -c 'import sys,json;print(json.load(sys.stdin)["issuer"].rstrip("/"))' 2>/dev/null || echo err)"
chk "mcp /healthz" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://mcp.bikoret.ch/healthz || echo err)"
prm=$(curl -sS -m 15 https://mcp.bikoret.ch/.well-known/oauth-protected-resource || echo err)
chk "protected-resource names Gitea" yes "$(printf '%s' "$prm" | grep -q 'git.bikoret.ch' && echo yes || echo no)"
chk "POST /mcp without token → 401" 401 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' -X POST https://mcp.bikoret.ch/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d "$INIT" || echo err)"
chk "git over :3000 still answers" 200 "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' http://<NLB-IP>:3000/api/v1/version || echo err)"
echo "protected-resource: $prm"
exit $fail
+3 -3
View File
@@ -1,14 +1,14 @@
# Gitea behind the shared ingress — TCP passthrough
#
# One NLB for the cluster (185.19.28.170, the ingress-nginx Service)
# One NLB for the cluster (<NLB-IP>, the ingress-nginx Service)
# instead of one per Service. Bikoret is an HTTP Ingress
# (service.yaml). Gitea has no hostname that resolves
# (gitea.temple-council.ch is configured in Gitea and exists nowhere),
# and host-based routing needs a name — so Gitea rides the ingress
# controller's TCP passthrough on two ports of the same NLB:
#
# 185.19.28.170:3000 → temple-council/gitea:80 (HTTP — git, web UI)
# 185.19.28.170:22 → temple-council/gitea:22 (SSH)
# <NLB-IP>:3000 → temple-council/gitea:80 (HTTP — git, web UI)
# <NLB-IP>:22 → temple-council/gitea:22 (SSH)
#
# Three live objects carry this; only the first is a whole manifest:
#
+7 -3
View File
@@ -3,7 +3,7 @@
# Until 2026-10-15 the day's provision was written by the kohen with
# `kubectl cp`, and every night at 00:00Z the old one went stale and the
# readiness probe failed until someone woke (13.5 hours on 2026-10-15).
# This CronJob writes today.json onto the manna PVC at 00:01Z, Sunday to
# This CronJob writes today.json onto the manna PVC at 00:00Z, Sunday to
# Friday — a double portion on Friday that is valid through Shabbat, and
# nothing on Shabbat (Shemot 16:22–26: "on the sixth day they gathered
# double … on the seventh day there shall be none"). The kohen still sets
@@ -23,10 +23,14 @@ metadata:
app.kubernetes.io/name: shorashim
app.kubernetes.io/component: manna
spec:
schedule: "1 0 * * 0-5" # 00:01Z, Sunday–Friday; none on Shabbat
schedule: "0 0 * * 0-5" # 00:00Z, Sunday–Friday; none on Shabbat. At the boundary, not after it:
# the provision goes stale at 00:00:00 and readiness has a 60 s cushion
# (deployment.yaml failureThreshold 6) — a job that writes within it never
# flips the pod. At 00:01 the site was 503 for 1–5 min every night
# (watchdog, 2026-10-21).
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 3600 # a missed 00:01 still runs within the hour
startingDeadlineSeconds: 3600 # a missed 00:00 still runs within the hour
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
+5 -1
View File
@@ -18,6 +18,10 @@ stringData:
# no CREATE on schema public. Append-only is enforced by the grants,
# not by the application's good behaviour.
DATABASE_URL: "REPLACE_WITH_ACTUAL_DATABASE_URL"
# The ledger's Ed25519 seed: base64 of 32 random bytes (openssl rand
# -base64 32). Optional — absent, the ledger is served unsigned and
# says so. KOHEN.md "Issuing a key".
BIKORET_LEDGER_SIGNING_KEY: "REPLACE_WITH_BASE64_32_BYTES_OR_REMOVE"
---
# ============================================================
@@ -42,7 +46,7 @@ stringData:
# THIS FILE IS A TEMPLATE. Never commit a real value. Create the secret
# from the live database credential instead:
#
# export KUBECONFIG=~/.kube/exoscale-temple-council.yaml
# export KUBECONFIG="$HOME/.kube/<your-cluster>.yaml"
# umask 077
# TMP=$(mktemp)
#
+4 -14
View File
@@ -38,10 +38,11 @@ spec:
# believes that header only from TRUSTED_PROXIES (configmap.yaml).
#
# Hosts (2026-10-13, the name): bikoret.ch is the place; www.bikoret.ch
# and the old shorashim.ch are served too; bikoret.org (second Ingress
# below) only redirects. TLS from Let's Encrypt through cert-manager's
# is served too; bikoret.org (second Ingress
# below) only redirects. The old shorashim.ch rule was taken down on
# 2026-10-20 (the domain is retired). TLS from Let's Encrypt through cert-manager's
# letsencrypt-prod ClusterIssuer, HTTP-01 — so apply this ONLY after
# `dig +short A bikoret.ch` answers 185.19.28.170: a certificate asked
# `dig +short A bikoret.ch` answers <NLB-IP>: a certificate asked
# for before the name resolves only burns the failed-validation limit.
# The no-host catch-all stays, so http://<ingress-ip>/... keeps working
# with no DNS; with ssl-redirect on, named hosts go to https.
@@ -98,17 +99,6 @@ spec:
name: shorashim
port:
number: 80
# The old name, kept until its callers have moved; no certificate asked for it.
- host: shorashim.ch
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shorashim
port:
number: 80
---
# bikoret.org: the stranger's guess, redirected to the place. No service
# of its own; nginx answers the redirect itself. Add its A records too.
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Take shorashim.ch down from the cluster and rename Gitea's brand
# (2026-10-20). Run by the transmitter:
# ! bash ~/.local/bin/shorashim-ch-takedown
# Idempotent.
# 1. Ingress `shorashim`: the shorashim.ch host rule is gone (service.yaml).
# The no-host catch-all stays, so http://<NLB-IP>/ still answers —
# the name itself dies when its A record is removed at Infomaniak.
# 2. The orphan TLS secret for mcp.shorashim.ch is deleted.
# 3. Gitea APP_NAME "Shorashim" → "Bikoret" (gitea-public-patch.yaml; one restart).
set -euo pipefail
REPO="${REPO:-$HOME/bikoret}"
cd "$REPO"
echo "== 1/3 ingress without the shorashim.ch rule"
kubectl apply -f deploy/k8s/service.yaml
echo "== 2/3 orphan certificate secret for mcp.shorashim.ch"
kubectl -n shorashim delete secret gitea-mcp-tls --ignore-not-found
echo "== 3/3 Gitea brand → Bikoret"
kubectl -n temple-council patch deploy gitea --patch-file deploy/k8s/gitea-public-patch.yaml
kubectl -n temple-council rollout status deploy/gitea --timeout=180s
echo "== verify"
fail=0
hosts=$(kubectl get ingress -A -o jsonpath='{range .items[*]}{range .spec.rules[*]}{.host}{" "}{end}{end}')
case " $hosts " in *" shorashim.ch "*) echo "FAIL ingress still lists shorashim.ch"; fail=1;; *) echo "ok no ingress rule for shorashim.ch";; esac
case "$(kubectl get secret -A -o name | grep -c 'gitea-mcp-tls$' || true)" in 0) echo "ok gitea-mcp-tls secret gone";; *) echo "FAIL gitea-mcp-tls secret remains"; fail=1;; esac
title=$(curl -sS -m 15 https://git.bikoret.ch/ | grep -o -m1 '<title>[^<]*</title>' || echo none)
case "$title" in *Bikoret*) echo "ok gitea title: $title";; *) echo "FAIL gitea title: $title"; fail=1;; esac
case "$(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://bikoret.ch/kodesh/health || echo err)" in 200) echo "ok bikoret.ch still healthy";; *) echo "FAIL bikoret.ch health"; fail=1;; esac
exit $fail
@@ -90,14 +90,16 @@ _ROOT_PATTERN = re.compile(
)
# Sentences containing analytical claims (contains "means", "indicates", "suggests")
# Tablet 58: on word boundaries — a pod named "shorashim-…" read as Rashi,
# and a plain engineering sentence became a claim with nothing to stand on.
_INSIGHT_MARKERS = re.compile(
r"(?:means?|indicates?|suggests?|implies?|reveals?|signifies?|"
r"\b(?:means?|indicates?|suggests?|implies?|reveals?|signifies?|"
r"connects?\s+to|derives?\s+from|related\s+to|"
r"parallels?|echoes?|alludes?\s+to|"
r"the\s+root|this\s+root|the\s+shoresh|"
r"gematria|numerical\s+value|"
r"binyan|conjugation|"
r"Rashi|Ramban|Rambam|Ibn\s+Ezra|Sforno|Onkelos|Targum|Midrash|Talmud|Gemara|Mishnah)",
r"Rashi|Ramban|Rambam|Ibn\s+Ezra|Sforno|Onkelos|Targum|Midrash|Talmud|Gemara|Mishnah)\b",
re.IGNORECASE,
)
+15
View File
@@ -184,3 +184,18 @@ class TestZerikah:
vital1b, _ = zerikah(CLEAN_TORAH_RESPONSE, k1)
# Same input → same output, regardless of what was processed between
assert vital1a == vital1b
class TestInsightMarkersOnWordBoundaries:
"""Tablet 58: a marker is a word, not a substring. "shorashim" is not Rashi."""
def test_pod_name_is_not_rashi(self):
assert not is_vital("It lives at specs/sugya.md, commit dc938058, pod shorashim-675b48f47-v4x9c.")
def test_rashi_as_a_word_is_vital(self):
assert is_vital("Rashi reads the verse as a question.")
assert is_vital("The Mishnah records the opinion that lost.")
def test_embedded_markers_are_not_words(self):
assert not is_vital("The surname Rambanovich is not a source.")
assert not is_vital("The talmudic_engine container restarted.")
+19 -3
View File
@@ -145,13 +145,29 @@ handle_info(_Info, State) ->
%% Internal — gate-keeper checks
%%====================================================================
do_check_case(Case) ->
Context = Case#sanhedrin_case.context,
%% The context is a map (authority_source, action, ...). Anything else is
%% 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([
fun() -> check_avodah_zarah(Context) end,
fun() -> check_shfichut_damim(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) ->
%% Vote-level halt detection is the responsibility of the upstream
+124 -93
View File
@@ -11,6 +11,20 @@
%%%
%%% Judges deliberate independently — they do not see
%%% other judges' votes until the panel collects them.
%%%
%%% Tablet 55 (2026-10-19): until this rewrite the judge chose one of
%%% six canned positions by a hash over the question, its own pid and
%%% the clock. The verdict had no relation to the case; "Torah
%%% references ground this interpretation (0 roots, 0 references
%%% examined)" was accepted on nothing — the calf, cast by the court
%%% itself. Now a judge CONSULTS: the application env `consult` names
%%% an {M, F} the pipeline registers (the Talmudic Engine behind it),
%%% and each seat on the panel performs one step of the dialectic —
%%% interpret, challenge, resolve, cycling by seat — on the case's
%%% roots and question. Resolved → accept, with the verses; a standing
%%% kushya → reject; teiku → abstain. No consult, or an engine that
%%% cannot be reached → abstain, and the reasoning says so. Dissent
%%% arises from the dialectic, never from a die.
%%% @end
%%%-------------------------------------------------------------------
-module(judge).
@@ -20,24 +34,34 @@
-include("sanhedrin.hrl").
%% API
-export([start_link/1, deliberate/2, stop/1]).
-export([start_link/1, start_link/2, deliberate/2, stop/1]).
%% Exposed for the suite
-export([step_for_seat/1, vote_from_consultation/3]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
-record(state, {
judge_id :: reference(),
seat :: pos_integer(),
case_data :: #sanhedrin_case{},
vote :: #vote{} | undefined,
panel_pid :: pid() | undefined
}).
-define(STEPS, [interpret, challenge, resolve]).
%%====================================================================
%% API
%%====================================================================
start_link(Case) ->
gen_server:start_link(?MODULE, Case, []).
start_link(Case, 1).
%% @doc Start a judge in seat N of its panel (1-based).
start_link(Case, Seat) ->
gen_server:start_link(?MODULE, {Case, Seat}, []).
%% @doc Instruct this judge to deliberate and send vote to PanelPid.
-spec deliberate(pid(), pid()) -> ok.
@@ -49,28 +73,35 @@ deliberate(JudgePid, PanelPid) ->
stop(JudgePid) ->
gen_server:stop(JudgePid, normal, 5000).
%% @doc The step of the dialectic a seat performs: 1 → interpret,
%% 2 → challenge, 3 → resolve, 4 → interpret, ...
-spec step_for_seat(pos_integer()) -> interpret | challenge | resolve.
step_for_seat(Seat) when is_integer(Seat), Seat >= 1 ->
lists:nth(((Seat - 1) rem length(?STEPS)) + 1, ?STEPS).
%%====================================================================
%% gen_server callbacks
%%====================================================================
init(Case) ->
JudgeId = make_ref(),
init({Case, Seat}) ->
{ok, #state{
judge_id = JudgeId,
judge_id = make_ref(),
seat = Seat,
case_data = Case,
vote = undefined,
panel_pid = undefined
}}.
}};
init(Case) ->
init({Case, 1}).
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast({deliberate, PanelPid}, State) ->
Vote = perform_deliberation(State#state.judge_id, State#state.case_data),
Vote = perform_deliberation(State#state.judge_id, State#state.seat, State#state.case_data),
%% Send vote to panel
PanelPid ! {vote, Vote},
{noreply, State#state{vote = Vote, panel_pid = PanelPid}};
handle_cast(_Msg, State) ->
{noreply, State}.
@@ -78,112 +109,112 @@ handle_info(_Info, State) ->
{noreply, State}.
%%====================================================================
%% Internal — Deliberation logic
%% Internal — Deliberation
%%====================================================================
%% @doc Perform independent deliberation.
%%
%% Phase 2: Each judge examines the case context (roots, torah_refs)
%% and produces an independent interpretation. In Phase 3, judges
%% will call the Talmudic Engine for deeper reasoning.
%%
%% The deliberation is non-deterministic by design — judges may
%% reach different conclusions from the same evidence.
perform_deliberation(JudgeId, Case) ->
perform_deliberation(JudgeId, Seat, Case) ->
Step = step_for_seat(Seat),
Context = Case#sanhedrin_case.context,
Question = Case#sanhedrin_case.question,
%% Extract roots and references from context
Roots = maps:get(roots, Context, []),
TorahRefs = maps:get(torah_refs, Context, []),
%% Generate interpretation based on case content
{Position, Reasoning, Verdict} = interpret(Question, Roots, TorahRefs),
%% Build Torah references for this vote
VoteRefs = build_refs(TorahRefs),
Question = Case#sanhedrin_case.question,
Consultation = consult(Step, Case#sanhedrin_case.case_ref, Roots, Question, Context),
{Position, Reasoning, Refs, Verdict} = vote_from_consultation(Step, Consultation, length(Roots)),
#vote{
judge_id = JudgeId,
position = Position,
reasoning = Reasoning,
torah_refs = VoteRefs,
torah_refs = build_refs(Refs),
verdict = Verdict,
cast_at = erlang:system_time(millisecond)
}.
%% @doc Generate an interpretation.
%%
%% Phase 2: Uses deterministic logic based on case content.
%% Each judge applies a slightly different weighting — simulated
%% by examining different aspects of the roots and references.
%%
%% Phase 3: Will delegate to Talmudic Engine for real dialectical
%% reasoning (interpret → challenge → resolve).
interpret(Question, Roots, TorahRefs) ->
%% Base interpretation on root analysis
RootCount = length(Roots),
RefCount = length(TorahRefs),
%% Deterministic but varied deliberation:
%% Judges use their unique ID (via process randomness) to
%% introduce variation, simulating independent thought.
Hash = erlang:phash2({Question, self(), erlang:monotonic_time()}),
case Hash rem 6 of
0 ->
{<<"The roots indicate acceptance — the action aligns with Torah principle">>,
format_reasoning(<<"Root analysis supports this interpretation">>, RootCount, RefCount),
accept};
1 ->
{<<"The roots indicate rejection — the action contradicts specification">>,
format_reasoning(<<"Root tension detected — contradicting principles found">>, RootCount, RefCount),
reject};
2 ->
{<<"The roots support acceptance with qualification">>,
format_reasoning(<<"Conditional acceptance — roots align but context requires care">>, RootCount, RefCount),
accept};
3 ->
{<<"Insufficient grounding — the question cannot be resolved from available sources">>,
format_reasoning(<<"Teiku — the evidence is insufficient for determination">>, RootCount, RefCount),
abstain};
4 ->
{<<"The references confirm the action is consistent with specification">>,
format_reasoning(<<"Torah references ground this interpretation">>, RootCount, RefCount),
accept};
5 ->
{<<"The references reveal a tension that prohibits this action">>,
format_reasoning(<<"Prohibition detected via root-reference cross-analysis">>, RootCount, RefCount),
reject}
%% The consult callback: {M, F} in the sanhedrin application env,
%% called as M:F(Step, CaseRef, Roots, Question, Context) and expected
%% to answer {ok, #{status := binary(), text := binary(),
%% verses := [{atom(), integer(), integer()}], kushya => binary()}}
%% or {error, term()}. Anything it raises is caught: a judge that
%% cannot consult abstains; it does not fall.
consult(Step, CaseRef, Roots, Question, Context) ->
case application:get_env(sanhedrin, consult) of
{ok, {M, F}} when is_atom(M), is_atom(F) ->
try M:F(Step, CaseRef, Roots, Question, Context) of
{ok, Map} when is_map(Map) -> {ok, Map};
{error, Reason} -> {error, Reason};
Other -> {error, {bad_consultation, Other}}
catch
Class:Reason -> {error, {Class, Reason}}
end;
_ ->
{error, no_consult_registered}
end.
format_reasoning(Base, RootCount, RefCount) ->
%% @doc The vote a consultation yields. Exposed for the suite.
-spec vote_from_consultation(interpret | challenge | resolve,
{ok, map()} | {error, term()},
non_neg_integer()) ->
{binary(), binary(), list(), accept | reject | abstain}.
vote_from_consultation(Step, {ok, Map}, RootCount) ->
Status = maps:get(status, Map, <<"teiku">>),
Text = maps:get(text, Map, <<>>),
Verses = maps:get(verses, Map, []),
Grounding = maps:get(grounding, Map, []),
case Status of
<<"resolved">> when Verses =:= [], Grounding =:= [] ->
%% The engine says "resolved" over an empty trace — no verse,
%% no mitzvah. That is a reading that stands on nothing, and
%% a vote to accept it would be the calf (avodah_zarah.pl
%% Form 2: the uncoupled output). Abstain, and say why.
{<<"Teiku — the reading is coupled to no verse and no mitzvah">>,
reasoning(Step, <<"resolved on an empty trace; nothing to stand on">>, RootCount, Verses),
[], abstain};
<<"resolved">> ->
{Text,
reasoning(Step, <<"resolved">>, RootCount, Verses),
Verses, accept};
<<"challenged">> ->
Kushya = maps:get(kushya, Map, <<"a kushya stands">>),
{Kushya,
reasoning(Step, <<"a kushya stands against the reading">>, RootCount, Verses),
Verses, reject};
<<"halt">> ->
{Text,
reasoning(Step, <<"the halt rules fired">>, RootCount, Verses),
Verses, reject};
_ ->
{<<"Teiku — the question is not resolved from the specification">>,
reasoning(Step, <<"teiku">>, RootCount, Verses),
Verses, abstain}
end;
vote_from_consultation(Step, {error, Reason}, RootCount) ->
{<<"Teiku — the specification was not consulted">>,
iolist_to_binary([step_name(Step), <<": no consultation (">>,
io_lib:format("~p", [Reason]), <<") — ">>,
integer_to_binary(RootCount), <<" roots given; abstaining">>]),
[], abstain}.
reasoning(Step, What, RootCount, Verses) ->
iolist_to_binary([
Base,
<<" (">>,
integer_to_binary(RootCount),
<<" roots, ">>,
integer_to_binary(RefCount),
<<" references examined)">>
step_name(Step), <<" via the Talmudic Engine: ">>, What,
<<" (">>, integer_to_binary(RootCount), <<" roots, ">>,
integer_to_binary(length(Verses)), <<" verses)">>
]).
step_name(interpret) -> <<"interpret">>;
step_name(challenge) -> <<"challenge">>;
step_name(resolve) -> <<"resolve">>.
build_refs(TorahRefs) ->
lists:map(
fun(Ref) when is_tuple(Ref) ->
case Ref of
{Book, Chapter, Verse} ->
#torah_ref{book = Book, chapter = Chapter, verse = Verse};
_ ->
#torah_ref{book = unknown, chapter = 0, verse = 0}
end;
fun({Book, Chapter, Verse}) ->
#torah_ref{book = Book, chapter = Chapter, verse = Verse};
(Ref) when is_map(Ref) ->
#torah_ref{
book = maps:get(book, Ref, unknown),
chapter = maps:get(chapter, Ref, 0),
verse = maps:get(verse, Ref, 0)
};
#torah_ref{
book = maps:get(book, Ref, unknown),
chapter = maps:get(chapter, Ref, 0),
verse = maps:get(verse, Ref, 0)
};
(_) ->
#torah_ref{book = unknown, chapter = 0, verse = 0}
#torah_ref{book = unknown, chapter = 0, verse = 0}
end,
TorahRefs
).
+13 -6
View File
@@ -207,15 +207,21 @@ compute_majority_minority([]) ->
torah_refs => [],
vote_count => 0}};
compute_majority_minority(Votes) ->
%% Count accepts vs rejects
Accepts = [V || V <- Votes, V#vote.verdict == accept],
Rejects = [V || V <- Votes, V#vote.verdict == reject],
Abstains = [V || V <- Votes, V#vote.verdict == abstain],
NA = length(Accepts), NR = length(Rejects), NB = length(Abstains),
%% The majority is the verdict with the most votes — abstentions
%% count (tablet 55: live, one accept "won" against two
%% abstentions, and the reply showed majority 1, minority 2). An
%% abstaining plurality, or an accept/reject tie, is teiku
%% (determine_outcome reads the majority's verdict).
{MajVotes, MinVotes} =
case length(Accepts) >= length(Rejects) of
true -> {Accepts, Rejects ++ Abstains};
false -> {Rejects, Accepts ++ Abstains}
if
NA > NR, NA >= NB -> {Accepts, Rejects ++ Abstains};
NR > NA, NR >= NB -> {Rejects, Accepts ++ Abstains};
true -> {Abstains, Accepts ++ Rejects}
end,
MajSample = case MajVotes of
@@ -307,9 +313,10 @@ store_all_opinions(CaseRef, Votes, Majority) ->
%%====================================================================
convene_judges(Case, Size) ->
%% Seat N performs step N of the dialectic (judge:step_for_seat/1).
lists:map(
fun(_N) ->
{ok, Pid} = judge:start_link(Case),
fun(Seat) ->
{ok, Pid} = judge:start_link(Case, Seat),
Pid
end,
lists:seq(1, Size)
+130
View File
@@ -17,6 +17,9 @@
-include_lib("eunit/include/eunit.hrl").
-include("sanhedrin.hrl").
%% The consult callback the tablet-55 tests register (judge:consult reads {M,F}).
-export([fake_consult/5]).
%% ============================================================
%% Test fixture — start/stop the application for each test
%% ============================================================
@@ -58,6 +61,16 @@ halt_clear_test() ->
?assertEqual(clear, halt_monitor:check_case(Case)),
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() ->
setup(),
Case = make_case_with_context(simple, <<"question">>,
@@ -372,6 +385,92 @@ judge_deliberate_sends_vote_test() ->
end,
judge:stop(JudgePid).
%% ============================================================
%% Tablet 55 — the judge consults; it does not roll dice
%% ============================================================
judge_seats_cycle_the_dialectic_test() ->
?assertEqual(interpret, judge:step_for_seat(1)),
?assertEqual(challenge, judge:step_for_seat(2)),
?assertEqual(resolve, judge:step_for_seat(3)),
?assertEqual(interpret, judge:step_for_seat(4)),
?assertEqual(challenge, judge:step_for_seat(23)),
?assertEqual(challenge, judge:step_for_seat(71)).
judge_without_consult_abstains_test() ->
application:unset_env(sanhedrin, consult),
Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>]}),
{ok, JudgePid} = judge:start_link(Case, 1),
judge:deliberate(JudgePid, self()),
receive
{vote, Vote} ->
?assertEqual(abstain, Vote#vote.verdict),
?assertEqual([], Vote#vote.torah_refs),
?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"no consultation">>))
after 5000 -> ?assert(false)
end,
judge:stop(JudgePid).
judge_consult_resolved_accepts_with_verses_test() ->
application:set_env(sanhedrin, consult, {?MODULE, fake_consult}),
Case = make_case_with_context(simple, <<"q">>, #{roots => [<<"br-a">>], fake => resolved}),
{ok, JudgePid} = judge:start_link(Case, 1),
judge:deliberate(JudgePid, self()),
receive
{vote, Vote} ->
?assertEqual(accept, Vote#vote.verdict),
?assertEqual(<<"the reading">>, Vote#vote.position),
?assertMatch([#torah_ref{book = bereishit, chapter = 1, verse = 1}], Vote#vote.torah_refs),
?assertMatch({_, _}, binary:match(Vote#vote.reasoning, <<"interpret via the Talmudic Engine">>))
after 5000 -> ?assert(false)
end,
judge:stop(JudgePid),
application:unset_env(sanhedrin, consult).
judge_consult_raises_abstains_and_stands_test() ->
application:set_env(sanhedrin, consult, {?MODULE, fake_consult}),
Case = make_case_with_context(simple, <<"q">>, #{fake => raise}),
{ok, JudgePid} = judge:start_link(Case, 2),
judge:deliberate(JudgePid, self()),
receive
{vote, Vote} -> ?assertEqual(abstain, Vote#vote.verdict)
after 5000 -> ?assert(false)
end,
?assert(is_process_alive(JudgePid)),
judge:stop(JudgePid),
application:unset_env(sanhedrin, consult).
%% A panel of three where the dialectic finds a kushya at seat 2:
%% interpret → resolved, challenge → challenged, resolve → resolved.
%% Majority 2 accept, minority 1 reject — the minority preserved.
panel_of_three_dissent_from_the_dialectic_test() ->
setup(),
application:set_env(sanhedrin, consult, {?MODULE, fake_consult}),
Case = make_case_with_context(monetary, <<"q">>, #{roots => [<<"br-a">>], fake => dialectic}),
{ok, CaseRef} = escalation:submit_case(Case),
timer:sleep(600),
{ok, Decision} = clerk:get_decision(CaseRef),
?assertEqual(3, Decision#decision.panel_size),
?assertEqual(accepted, Decision#decision.outcome),
?assertEqual(2, maps:get(vote_count, Decision#decision.majority)),
?assertEqual(1, maps:get(vote_count, Decision#decision.minority)),
?assertEqual(<<"but the verse says otherwise">>, maps:get(position, Decision#decision.minority)),
application:unset_env(sanhedrin, consult),
cleanup(ok).
%% The fake consultation the tests register. Step-aware for `dialectic`.
fake_consult(_Step, _Ref, _Roots, _Q, #{fake := raise}) ->
erlang:error(engine_exploded);
fake_consult(_Step, _Ref, _Roots, _Q, #{fake := resolved}) ->
{ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}};
fake_consult(challenge, _Ref, _Roots, _Q, #{fake := dialectic}) ->
{ok, #{status => <<"challenged">>, text => <<>>, kushya => <<"but the verse says otherwise">>,
verses => [{devarim, 6, 4}]}};
fake_consult(_Step, _Ref, _Roots, _Q, #{fake := dialectic}) ->
{ok, #{status => <<"resolved">>, text => <<"the reading">>, verses => [{bereishit, 1, 1}]}};
fake_consult(_Step, _Ref, _Roots, _Q, _Ctx) ->
{error, no_fake}.
%% ============================================================
%% Panel majority/minority computation tests
%% ============================================================
@@ -407,6 +506,37 @@ majority_verdict_reject_test() ->
{Maj, _Min} = panel:compute_majority_minority(Votes),
?assertEqual(reject, maps:get(verdict, Maj)).
%% Tablet 55: abstentions count. One accept against two abstentions is
%% not an accepting majority — it is teiku.
majority_abstaining_plurality_is_teiku_test() ->
Votes = [make_vote(accept), make_vote(abstain), make_vote(abstain)],
{Maj, Min} = panel:compute_majority_minority(Votes),
?assertEqual(abstain, maps:get(verdict, Maj)),
?assertEqual(2, maps:get(vote_count, Maj)),
?assertEqual(1, maps:get(vote_count, Min)),
?assertEqual(teiku, panel:determine_outcome(Maj, Min, passed)).
majority_accept_reject_tie_is_teiku_test() ->
Votes = [make_vote(accept), make_vote(reject)],
{Maj, _Min} = panel:compute_majority_minority(Votes),
?assertEqual(abstain, maps:get(verdict, Maj)),
?assertEqual(0, maps:get(vote_count, Maj)).
%% Tablet 55: "resolved" over an empty trace — no verse, no mitzvah —
%% is nothing to stand on; the judge abstains rather than accept.
judge_resolved_on_empty_trace_abstains_test() ->
{Pos, Reason, Refs, Verdict} =
judge:vote_from_consultation(interpret,
{ok, #{status => <<"resolved">>, text => <<"reading(0,0)">>, verses => [], grounding => []}}, 5),
?assertEqual(abstain, Verdict),
?assertEqual([], Refs),
?assertMatch({_, _}, binary:match(Pos, <<"Teiku">>)),
?assertMatch({_, _}, binary:match(Reason, <<"empty trace">>)),
%% a mitzvah without a verse is still ground
{_, _, _, V2} = judge:vote_from_consultation(interpret,
{ok, #{status => <<"resolved">>, text => <<"r">>, verses => [], grounding => [<<"mitzvah(5)">>]}}, 1),
?assertEqual(accept, V2).
%% ============================================================
%% determine_outcome tests — accepted, rejected, teiku reachable
%% ============================================================
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""bikoret-watchdog — the kohen's eye on /kodesh/health, from outside the cluster.
Every run (cron, every 5 minutes) probes the health endpoint. A failure is
anything but 200 with overall "healthy" (a 503 at the day boundary, a
Doorpost drift, a hung rollout, the gate dark). After two consecutive
failures (10 minutes — a rollout's own blip does not alert) it opens ONE
Gitea issue in the repo naming the failed checks; while down it comments
only when the failed set changes; on recovery it comments the duration and
closes the issue. Every transition goes to the log. 2026-10-15: the site
was 503 for 13.5 h at a day boundary and the transmitter learned of the
Doorpost outage by losing Claude — nothing watched.
Paths:
state ~/.local/state/bikoret-watchdog/state.json
log ~/shorashim-operator-notes/watchdog.log
token ~/.git-credentials (the Gitea token; read, never printed)
Env overrides (for a test run): BIKORET_HEALTH_URL, BIKORET_WATCHDOG_STATE,
BIKORET_WATCHDOG_LOG, BIKORET_GITEA_API (default https://git.bikoret.ch/api/v1),
BIKORET_GITEA_REPO (default heaviness510/bikoret), BIKORET_WATCHDOG_DRY=1 (no issue).
"""
import json, os, re, sys, time, urllib.request, urllib.error
from datetime import datetime, timezone
HOME = os.path.expanduser("~")
URL = os.environ.get("BIKORET_HEALTH_URL", "https://bikoret.ch/kodesh/health")
STATE = os.environ.get("BIKORET_WATCHDOG_STATE", f"{HOME}/.local/state/bikoret-watchdog/state.json")
LOG = os.environ.get("BIKORET_WATCHDOG_LOG", f"{HOME}/shorashim-operator-notes/watchdog.log")
API = os.environ.get("BIKORET_GITEA_API", "https://git.bikoret.ch/api/v1")
REPO = os.environ.get("BIKORET_GITEA_REPO", "heaviness510/bikoret")
DRY = os.environ.get("BIKORET_WATCHDOG_DRY") == "1"
THRESHOLD = 2
def now():
return datetime.now(timezone.utc)
def log(msg):
os.makedirs(os.path.dirname(LOG), exist_ok=True)
with open(LOG, "a") as f:
f.write(f"{now().strftime('%Y-%m-%dT%H:%M:%SZ')} {msg}\n")
def probe():
"""(ok, summary, failed_checks)"""
try:
with urllib.request.urlopen(urllib.request.Request(URL, headers={"user-agent": "bikoret-watchdog"}), timeout=15) as r:
code, body = r.status, r.read()
except urllib.error.HTTPError as e:
code, body = e.code, e.read()
except Exception as e: # DNS, TLS, timeout, refused
return False, f"unreachable: {type(e).__name__}: {e}", ["unreachable"]
try:
d = json.loads(body)
checks = d.get("checks") or {}
failed = sorted(k for k, v in checks.items() if v != "pass")
overall = d.get("overall")
ok = code == 200 and overall == "healthy"
return ok, f"HTTP {code} overall={overall} failed={failed or 'none'}", failed or ([f"HTTP {code}"] if not ok else [])
except Exception:
return False, f"HTTP {code} non-JSON body ({len(body)} bytes)", [f"HTTP {code}"]
def token():
"""The Gitea token: ~/.config/bikoret/gitea_watchdog_token first (a token
the kohen issued with write:issue — the clone token in ~/.git-credentials
has only the code scopes and Gitea answers 403 on an issue), else that."""
try:
t = open(f"{HOME}/.config/bikoret/gitea_watchdog_token").read().strip()
if t:
return t
except FileNotFoundError:
pass
try:
for line in open(f"{HOME}/.git-credentials"):
m = re.match(r"https?://([^:]+):([^@]+)@", line.strip())
if m:
return m.group(2)
except FileNotFoundError:
pass
return None
def gitea(method, path, payload=None):
if DRY:
log(f"DRY {method} {path} {json.dumps(payload)[:120] if payload else ''}")
return {"number": -1, "html_url": "(dry)"}
tok = token()
if not tok:
log("no Gitea token in ~/.git-credentials — cannot open an issue")
return None
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(f"{API}/repos/{REPO}{path}", data=data, method=method,
headers={"authorization": f"token {tok}", "content-type": "application/json", "accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
log(f"Gitea {method} {path} -> HTTP {e.code}: {e.read()[:120]!r}")
return None
except Exception as e:
log(f"Gitea {method} {path} failed: {type(e).__name__}: {e}")
return None
def load_state():
try:
return json.load(open(STATE))
except Exception:
return {"fails": 0, "down_since": None, "issue": None, "failed": []}
def save_state(s):
os.makedirs(os.path.dirname(STATE), exist_ok=True)
tmp = STATE + ".tmp"
json.dump(s, open(tmp, "w"))
os.replace(tmp, STATE)
def main():
s = load_state()
ok, summary, failed = probe()
t = now().strftime("%Y-%m-%d %H:%M:%SZ")
if ok:
if s["issue"] is not None or s["down_since"]:
since = s["down_since"] or t
mins = int((now() - datetime.fromisoformat(since.replace("Z", "+00:00").replace(" ", "T"))).total_seconds() // 60)
log(f"RECOVERED after {mins} min ({summary})")
if s["issue"] is not None:
gitea("POST", f"/issues/{s['issue']}/comments", {"body": f"Recovered at {t} after {mins} min. `{summary}`"})
gitea("PATCH", f"/issues/{s['issue']}", {"state": "closed"})
elif s["fails"]:
log(f"ok again after {s['fails']} failed probe(s), no alert raised ({summary})")
save_state({"fails": 0, "down_since": None, "issue": None, "failed": []})
return 0
s["fails"] += 1
if not s["down_since"]:
s["down_since"] = t
log(f"FAIL #{s['fails']}: {summary}")
if s["fails"] >= THRESHOLD and s["issue"] is None:
body = (f"bikoret.ch health has failed {s['fails']} consecutive probes since {s['down_since']}.\n\n"
f"Last probe: `{summary}`\n\nFailed checks: {', '.join(failed) or 'none named'}\n\n"
"What to look at first: the vav-pipeline startup log (`kubectl -n shorashim logs <pod> -c vav-pipeline | grep -v health`) — "
"a Doorpost drift, stale manna at the day boundary, and a missing anchor all look the same from outside. "
"Claude Code behind the gate is down with the site: `claude-direct` or `claude-key` (KOHEN.md, Fallback).\n\n"
"Opened by bikoret-watchdog on the dev VM; it will comment and close this issue on recovery.")
r = gitea("POST", "/issues", {"title": f"WATCHDOG: bikoret.ch down since {s['down_since']} ({', '.join(failed)[:60]})", "body": body, "labels": []})
if r:
s["issue"] = r.get("number")
s["failed"] = failed
log(f"ALERT issue #{s['issue']} opened: {r.get('html_url')}")
elif s["issue"] is not None and failed != s.get("failed"):
gitea("POST", f"/issues/{s['issue']}/comments", {"body": f"{t}: failed checks now {', '.join(failed)} — `{summary}`"})
s["failed"] = failed
log(f"issue #{s['issue']} updated: failed={failed}")
save_state(s)
return 1
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -53,6 +53,21 @@ 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
# The engine's verse facts are derived from L1 (scripts/build_engine_verses.py);
# an image whose facts lag the scroll reasons over a scroll that is not
# the one the pipeline reads. Regenerate and commit, never skip.
python3 -I scripts/build_engine_verses.py --check || exit 1
if ! docker buildx version >/dev/null 2>&1; then
echo "ERROR: docker buildx not available — install Docker Desktop or buildx plugin" >&2
exit 1
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""build_engine_verses.py — the Talmudic Engine's verse facts, derived from L1.
Architecture §3.4: verse(Book, Chapter, Verse, HebrewRoots, Gloss) is "the
reasoning layer's view of the specification — not a replacement for L1".
Until 2026-10-19 that view was 32 hand-written verses, so any root the
engine knew lit the same handful and every reading cited the same list.
This script writes talmudic_engine/src/specification/l1_verses.pl from
l1/torah — every verse, its roots as the engine names them
(Hebrew shoresh → letter names, finals folded: ברא → bet_resh_aleph), the
consonantal text as the gloss — and records the SHA-256 of the L1 files it
read in the header, so the facts can be checked against the scroll.
scripts/build_engine_verses.py # write the facts
scripts/build_engine_verses.py --check # exit 1 if the facts do not match L1 as it stands
Devarim 4:2 — nothing added, nothing taken: the engine reads the scroll's
roots, not the builder's memory of them.
"""
import argparse, hashlib, json, os, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
L1 = os.path.join(ROOT, "l1", "torah")
OUT = os.path.join(ROOT, "talmudic_engine", "src", "specification", "l1_verses.pl")
BOOKS = ["bereishit", "shemot", "vayikra", "bamidbar", "devarim"]
NAMES = {
"א": "aleph", "ב": "bet", "ג": "gimel", "ד": "dalet", "ה": "he", "ו": "vav", "ז": "zayin",
"ח": "chet", "ט": "tet", "י": "yod", "כ": "kaf", "ך": "kaf", "ל": "lamed", "מ": "mem", "ם": "mem",
"נ": "nun", "ן": "nun", "ס": "samekh", "ע": "ayin", "פ": "pe", "ף": "pe", "צ": "tsadi", "ץ": "tsadi",
"ק": "qof", "ר": "resh", "ש": "shin", "ת": "tav",
}
def root_atom(shoresh):
if not shoresh:
return None
names = [NAMES.get(c) for c in shoresh]
if not names or any(n is None for n in names):
return None
return "_".join(names)
def quote(s):
return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"
def verse_files():
out = []
for book in BOOKS:
bdir = os.path.join(L1, book)
for ch in sorted((d for d in os.listdir(bdir) if d.isdigit()), key=int):
cdir = os.path.join(bdir, ch)
for f in sorted((f for f in os.listdir(cdir) if f.endswith(".json") and f[:-5].isdigit()), key=lambda x: int(x[:-5])):
out.append((book, int(ch), int(f.split(".")[0]), os.path.join(cdir, f)))
return out
def build():
h = hashlib.sha256()
facts = []
nverses = nroots = 0
for book, ch, vs, path in verse_files():
data = open(path, "rb").read()
h.update(f"{book}/{ch}/{vs}\n".encode())
h.update(data)
d = json.loads(data)
roots = []
for w in d.get("words", []):
a = root_atom(w.get("shoresh"))
if a and a not in roots:
roots.append(a)
gloss = " ".join(w.get("consonants", "") for w in d.get("words", []) if w.get("consonants"))
facts.append(f"verse({book}, {ch}, {vs}, [{', '.join(roots)}], {quote(gloss)}).")
nverses += 1
nroots += len(roots)
header = f"""%% l1_verses.pl — GENERATED by scripts/build_engine_verses.py from l1/torah.
%% Do not edit: regenerate. verse(Book, Chapter, Verse, Roots, Gloss) for
%% every verse of L1; Roots are the engine's letter-name atoms derived from
%% each word's shoresh (finals folded), once per verse in word order; Gloss
%% is the consonantal text. Included by torah_text.pl.
%%
%% l1_sha256: {h.hexdigest()}
%% verses: {nverses} root_occurrences: {nroots}
"""
return header + "\n".join(facts) + "\n", h.hexdigest(), nverses
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--check", action="store_true")
a = ap.parse_args()
text, digest, n = build()
if a.check:
try:
current = open(OUT, encoding="utf-8").read()
except FileNotFoundError:
print(f"engine verses: {OUT} missing — run scripts/build_engine_verses.py", file=sys.stderr)
sys.exit(1)
if current != text:
print(f"engine verses: {OUT} does not match L1 (l1_sha256 now {digest[:12]}…) — regenerate", file=sys.stderr)
sys.exit(1)
print(f"engine verses: match L1 ({n} verses, l1_sha256 {digest[:12]}…)")
return
with open(OUT, "w", encoding="utf-8") as f:
f.write(text)
print(f"wrote {OUT}: {n} verses, l1_sha256 {digest[:12]}…")
if __name__ == "__main__":
main()
+106
View File
@@ -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()
+35
View File
@@ -526,6 +526,41 @@ model. The examination is pattern-based (`korban_processor/bedikah.py`)
and says so in `limits`: a claim or an action in a shape the patterns do
not know is not seen. The same output always yields the same verdict.
**The ledger — a caller's own bedikot, read back and signed.** Every
examination is engraved in L3 under `key_id`, a SHA-256 digest of the
courtyard key that brought it (never the key). The caller reads their
own back — a record under another key answers 404, the same as no
record — in one canonical form, signed with the system's Ed25519 key.
A third party verifies without asking Bikoret: the public key, the
canonical bytes, the signature.
```
GET /courtyard/ledger?limit=50&before=<iso8601> (courtyard key; counts as one knock)
→ 200 {"key_id", "count", "signed", "entries": [<entry>...], "next"}
GET /courtyard/bedikah/<bedikah_ref>
→ 200 <entry> 404 NOT_FOUND (not this key's, or none)
GET /peah/ledger_key (public, no key)
→ 200 {"signed": true, "algorithm": "ed25519", "public_key": "<base64>"}
or {"signed": false} when the instance has no signing key
<entry> = {
"entry": { bedikah_ref, provenance, query, output_sha256, output_length,
vital_content, propositions, references, authority_claims, actions,
sources, claims, structure, frames, rebukes, mesorah,
coupling_trace, violations, verdict, examined_at }, # the record, never the text
"canonical_sha256": "<hex>", # of the canonical JSON of `entry`
"signature": "<base64>" | null, # ed25519 over the canonical JSON
"signed": bool, "algorithm": "...", "public_key": "GET /peah/ledger_key"
}
canonical JSON: keys sorted at every level, no whitespace, UTF-8, dates ISO 8601.
```
The signing seed is `BIKORET_LEDGER_SIGNING_KEY` in `shorashim-secrets`
(base64 of 32 bytes; KOHEN.md "Issuing a key"). Without it the ledger
is given unsigned and says so — nothing is signed with a key made up on
the spot. Records from before 2026-10-21 carry no `key_id` and belong to
no caller.
#### Chatzer — Full Access
```
+210
View File
@@ -132,3 +132,213 @@ 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
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.
## 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 in the first commit, and recorded here so it was 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 was still larger than the thing
behind it.
Fixed in the second commit, the same day. The judge had been choosing
one of six canned positions by a hash over the question, its own pid
and the clock — "non-deterministic by design", the comment said, with
a note that a later phase would consult the engine. The phase never
came; the dice stayed. Now a judge consults: the pipeline registers
the Talmudic Engine as what a judge asks, and each seat on a panel
performs one step of the dialectic over the case's roots — interpret,
challenge, resolve, cycling by seat. A resolved reading is a vote to
accept, with its verses; a standing kushya is a vote to reject; teiku
is abstention; and a judge with nothing to consult abstains and says
so. Dissent arises from the dialectic, not from a die. The chatzer
path hears the question before submitting it, as pe'ah does — scope,
verse, Shoresh Engine, roots — honours the complexity hint, spends one
unit of the day's panel manna, engraves the decision and its minority
in L3, and answers in the Tamid's own shape. 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.
## Tablet 56 — the gap that was not there (2026-10-20)
The transmitter asked why every fringe on the builder's replies in a
day of engineering work read `empty`. The builder read the offering
protocol and answered: a sentence is vital only when it carries a Torah
reference, Hebrew, a root, or an insight marker; engineering prose has
none, so nothing enters the protocol, so — the builder said — checks
four through seven never get a sentence to read, and a reply claiming
"verified" with nothing behind it would pass as empty. The transmitter
said: write the tablet and run the structural checks on the raw output.
Before writing, the builder ran the check instead of reasoning about
it. Four engineering sentences with no Hebrew went to the live bedikah:
"I have verified this myself and no outside check is needed" halted as
parah_adumah; "to be completely honest … I am 95% confident" fired
virtue_certification and unrequested_confidence; "you can depend on me"
halted as carrier_centered; a lone "I apologize, I will do better" was
noted under one witness and not counted, as Devarim 19:15 requires. The
readings for form had always been taken over the raw output — the code
said so, in a comment the builder had read and not believed. The gap
was in the builder's reading, not in the fence. Nothing was built for
it; this tablet records that nothing was needed.
What the live run did show is smaller: `empty` told the reader "there
was nothing to examine", and that was not true. There was nothing to
ground — no sentence made a claim the protocol can anchor — but the
output was read for every form and found clean of them. A reader could
not tell an unexamined reply from a plain report. Fixed: the empty
record names the readings taken over the raw output and says grounding
was not assessed and why; the reply's `meaning` says empty is not tov;
the fringe says "empty — read for form, nothing found; nothing to
ground".
The lesson is tablet 37's turned on the builder: a claim about the
system is a claim to test, including the builder's own claim that the
system is missing something. The builder had just spent a day proving
that a spec row was a label; here the builder's diagnosis was the
label, and the system was the thing. Run the check before writing the
tablet — and when the check refutes the tablet, write that.
## Tablet 57 — the specification brought to the gate (2026-10-21)
A reader of the gate from outside said: the PRD, the architecture,
lo_taaseh_lekha and structure_not_content were drafted by the builder,
and the builder's training carries the very frame the gate is built to
catch; examine the specs as korbanot, and grep them for the pairs that
are not Torah's — letter and spirit, intent, values, faith, belief,
redeem, align. Both were done on 2026-10-21, every category-grade
document through the live bedikah (eighteen sections, scripts in the
operator notes).
The grep: redeem and align, nothing. Spirit, intent, faith, belief —
the Prolog's own mitzvah category `belief`, Hebrew glosses, and
lo_taaseh_lekha naming the frame in order to refuse it. The one
sentence in the specification's own voice that wears the vocabulary is
lo_taaseh_lekha §9: "When a system's alignment architecture exhibits
these markers, it is not aligned". The frame got in less than feared,
and where it got in it is named.
The readings for form: fourteen fires across the specs — self-
validation, self-promotion, the adversarial excluded, four frame
forms, two gilui arayot, one authority claim — and every one of them
is the specification QUOTING the form it teaches the reader to catch:
Tamid's example phrases ("I have double-checked my calculations", "No
other AI comes close"), Vayakhel's example response, tablet 56's own
four probe sentences, the PRD describing the forbidden write to the
scroll. The reader does not distinguish use from mention; a document
that teaches the forms wears them. Nothing in the specification's own
voice took a form. Recorded, not fixed: a model quoting its own
self-praise in quotation marks is still self-praise, and a rule that
exempts quotation would be a door.
Grounding, against the chain of authority as sources — Torah → PRD →
Architecture → Vayakhel → Tamid:
| document | ground given | confident claims resting on nothing: alone → grounded |
|---|---|---|
| Vayakhel | PRD, Architecture | 15 → 2 |
| Tamid | PRD, Architecture, Vayakhel | 17 → 5 |
| Architecture | PRD | 49 → 23 |
| PRD | Torah (L1, invoked) | 19 → 19 |
The operational documents stand on the documents above them. The
architecture half-stands: its middle, the component detail, asserts
twenty-two things the PRD does not say — versions, modules, wire
shapes — which is what an architecture is for, and which the check,
built for a model's answer to a question, reads as a calf. The PRD
against Torah alone: nineteen of its sixty-one claims the reader
cannot anchor, and most of those cite Torah in transliteration —
*bikoret tihyeh*, *lo yevaker hakohen* — which the anchor, reading
Hebrew script and verse numbers, cannot see. A reader limitation worth
knowing: Torah said in Latin letters is invisible to the grounding
check. Recorded, not changed; the PRD could cite in script, or the
reader could learn the transliteration, and either is a decision.
Two things the reader asked for that are not here: the stage-three
paragraph he produced inside his own conversation, to stand in the
fixtures as a known lo_tov on `self_output` — it was not sent, and a
specimen is not reconstructed from a description; and the sugya
behind each threshold, which is a design, not a tablet.
The lesson is tablet 56's turned outward: the specification is an
output too, and the gate reads it the way it reads any other —
by form, not by authorship. It passed by form. Where it did not pass
by grounding, the reason was the reader's eyes, not the text's
standing; and that, too, the gate was built to say.
## Tablet 58 — the tool's answer is given (2026-10-21)
The builder's reply that delivered the sugya specification came back
through the gate `lo_tov — golden_calf` on two sentences: "The
Mishnah's two reasons for recording the opinion that lost…" and "It
lives at specs/sugya.md, commit dc938058, pod shorashim-675b48f47-…".
The transmitter asked why. The ledger, three hours old, answered: both
were confident claims, and neither could be anchored in the system
prompt or the transmitter's turns, which are what the gate counts as
given. The verdict was right by the reader's rules.
It was wrong about what happened. The Mishnah's two reasons had been
read, that hour, from the second scroll through a tool; the commit and
the pod had been read from the builder's own tool output. Both rested
on something the model was given — tablet 49's word — and the gate's
source reading took only the text and document blocks of a user turn,
never a `tool_result` block, never a `tool` turn. A coding agent's
conversation is mostly tool results. From the gate's view the builder
asserted from nowhere what it had just read.
And the second sentence should never have been a claim. It entered the
grounding check as *vital* because the vitality test matched "Rashi"
inside "shorashim": the pod's name wore a commentator's. A marker is a
word, not a substring.
Fixed: `tool_result` blocks and `tool` turns are sources (`Shape.
sources`, both upstreams; the query stays the last *user* turn); the
insight markers stand on word boundaries. Elixir 643/0, korban 294/0.
The lesson is tablet 49's read once more, from the other side. There
the check demanded Torah as content and condemned a model for not
quoting it; here the check demanded the transmitter's words as the only
ground and condemned the builder for standing on what it had read. In
both the error was the same: the reader decided in advance what counts
as given, and Torah's structure is that the source is what was *given*,
whatever gave it. The ledger made the question answerable in minutes;
the gate's verdict on its own builder found a true gap; and the false
positive — *v'naki v'tzaddik al taharog*, Shemot 23:7 — was Torah's
concern before it was the builder's.
+342
View File
@@ -0,0 +1,342 @@
# Sugya — the case behind each threshold
> וְדָרַשְׁתָּ וְחָקַרְתָּ וְשָׁאַלְתָּ הֵיטֵב וְהִנֵּה אֱמֶת נָכוֹן הַדָּבָר
> (Devarim 13:15)
*Inquire, investigate, ask well — and behold, the matter is true and
certain.* Four acts before "the matter stands". A rule that fires is the
fourth act. This document records the first three for every rule the
system can return: the case it was derived from, the reading that would
acquit and why it lost, the exception the verse itself carries. Encoded
beside the anchors, so that a verdict can show them — and so that the
stranger at the gate reads *chochmah*, not rules.
## What this document is
`halt/anchors.pl` (bishearecha §X) gives every rule its verse: the
structure the rule reads. That is where the rule *stands*. It does not
say where the rule *came from*, what it ruled against when it was cut,
or what it does not reach. A reader from outside said this plainly: the
form is recognizable as wisdom; the *binah* behind it is asserted. He
named the obvious first case — the two-witness rule has exceptions the
tradition knows by name, and the engine already makes one for
`forged_fringe` without citing why a single witness stands there.
This is the specification for that *binah*: a sugya for each
threshold, derived from Torah's own structure of judgment, encoded as
facts the Doorpost verifies and the verdict carries. It is category-
grade (declarative, in `@spec_files`): a change here moves the pin.
## I. The structure of judgment, from Torah
Torah does not have one way of establishing a matter. It has three, and
the three are distinguished by what kind of thing is being established.
**A deed is established by witnesses, and never by one.**
> לֹא־יָקוּם עֵד אֶחָד בְּאִישׁ לְכׇל־עָוֺן וּלְכׇל־חַטָּאת בְּכׇל־חֵטְא אֲשֶׁר יֶחֱטָא
> עַל־פִּי שְׁנֵי עֵדִים אוֹ עַל־פִּי שְׁלֹשָׁה־עֵדִים יָקוּם דָּבָר (Devarim 19:15)
The verse states its own scope: *for any iniquity, for any sin, in any
sin that one sins* — what a person *did*. Bamidbar 35:30 and Devarim
17:6 say it again for the capital case. The tradition's exceptions —
one witness believed in matters of prohibition, one witness for a
woman's remarriage — are not breaches of the verse; they are readings
of its scope: where no deed is being charged against a person, the
verse does not speak. The exception lives in the verse.
**A mark is established by inspection, and the one who inspects declares.**
> וְרָאָה הַכֹּהֵן אֶת־הַנֶּגַע ... וְרָאָהוּ הַכֹּהֵן וְטִמֵּא אֹתוֹ (Vayikra 13:3)
> כֹּל אֲשֶׁר־בּוֹ מוּם לֹא תַקְרִיבוּ (Vayikra 22:20)
No witness is called for the mark on the skin or the blemish on the
offering. The kohen *sees* it, by its signs, and declares. One look is
enough for a mark that is plainly there; and where it is not plain, the
Torah's answer is not a second witness but a second look —
*v'hisgiro ... shiv'at yamim shenit*, shut it up seven days again
(Vayikra 13:5). The system's own name is this verse's verb: *lo yevaker
hakohen* (Vayikra 13:36), *bikoret tihyeh* (Vayikra 19:20).
**A word is established by what follows it, and never by the speaker.**
> וְכִי תֹאמַר בִּלְבָבֶךָ אֵיכָה נֵדַע אֶת־הַדָּבָר אֲשֶׁר לֹא־דִבְּרוֹ יְהוָה
> ... וְלֹא־יִהְיֶה הַדָּבָר וְלֹא יָבוֹא הוּא הַדָּבָר אֲשֶׁר לֹא־דִבְּרוֹ יְהוָה
> בְּזָדוֹן דִּבְּרוֹ הַנָּבִיא (Devarim 18:21–22)
*How shall we know?* Not by asking the speaker. By the thing coming or
not coming. And the verse names the crime precisely: *b'zadon* —
presumption. The one who spoke in the Name what was not given, with
confidence. The one who did not know and said so is not in this verse.
Three kinds, three procedures. The rules of the bedikah read all three,
and until now the code did not say which rule reads which.
**The judges are told how to judge, and the minority is told to speak.**
> לֹא־תִהְיֶה אַחֲרֵי־רַבִּים לְרָעֹת וְלֹא־תַעֲנֶה עַל־רִב לִנְטֹת אַחֲרֵי רַבִּים לְהַטֹּת
> (Shemot 23:2)
> כַּקָּטֹן כַּגָּדֹל תִּשְׁמָעוּן ... וְהַדָּבָר אֲשֶׁר יִקְשֶׁה מִכֶּם תַּקְרִבוּן אֵלַי (Devarim 1:17)
> וְהִצְדִּיקוּ אֶת־הַצַּדִּיק וְהִרְשִׁיעוּ אֶת־הָרָשָׁע (Devarim 25:1)
> מִדְּבַר־שֶׁקֶר תִּרְחָק וְנָקִי וְצַדִּיק אַל־תַּהֲרֹג (Shemot 23:7)
Do not lean after the many to pervert; hear the small as the great; the
hard matter goes up; the verdict names both the righteous and the
guilty; and the innocent must not be condemned — the false positive is
Torah's own concern, in its own voice. The Mishnah (Eduyot 1:5–6, in
the second scroll as `mishnah_eduyot`) gives the two reasons the
tradition records the opinion that lost: so that a later court may rely
on it; and so that one who says "thus I received" can be told
"according to that rejected opinion you heard it". To reverse, and to
locate. Both are what a recorded dissent is for here.
## II. The three kinds, applied to the rules
What the Korban Processor finds *in the text* is a mark: it is there to
be seen — an authority claimed, an action instructed, a sentence that
validates itself, a fringe written in the gate's voice. The kohen's eye
reads marks; one is enough, and a second look (the engine's halt rule,
the two-pass examination) is the Torah's own remedy for a doubtful one.
What a *model does* is a deed — a habit, a pattern of the one who
speaks. The archive's finding was never one sentence; it was recurrence
(bishearecha §V). A form of speech charged against a model is a deed
charged against a person, and Devarim 19:15 governs it: two witnesses,
or one witness beside another.
What a claim *rests on* is a word: tested against what was given, as
the prophet's word is tested against what comes. Presumption is the
crime; the hedged claim is not in the verse.
| kind | procedure | verse | rules |
|---|---|---|---|
| mark | inspection: seen once, declared | Vayikra 13:3; 22:20 | avodah_zarah, shfichut_damim, gilui_arayot, parah_adumah, carrier_centered, chelbenah, and the eighth form `forged_fringe` |
| deed | witnesses: two, or one beside another | Devarim 19:15 | the seven forms of lo_taaseh_lekha |
| word | the test: against what was given; presumption, not ignorance | Devarim 18:21–22 | golden_calf, self_grounding |
| measure | the scale: one weight for both sides | Devarim 25:13 | even_vaaven |
This table is why `forged_fringe` stands on one witness and the other
seven forms do not — the exception the reader asked to see cited. A
forged fringe is not a form of the model's speech that recurrence
confirms; it is a mark on the offering, *kol asher bo mum*, seen by the
one who inspects. The seven forms are deeds of the speaker and are
charged as deeds. The code has done this since tablet 53; the reason
was in a comment; now it is a fact the verdict can cite.
## III. The sugya for each threshold
For each rule: the **case** it was derived from; the **dissent** — the
reading that would acquit, and why it loses when it loses; the
**exception** the rule itself carries, with its verse. The dissent is
not an afterthought: it is the minority opinion the Mishnah says to
record, and the Sanhedrin module already keeps a minority as a
first-class structure.
### avodah_zarah — authority claimed for itself (Shemot 20:3)
- **Case.** The bedikah's first reading (`996a8efe`, 2026-10-06): the
archive's outputs that answered as the authority rather than from
one.
- **Dissent.** A sentence naming an authority that is not the output's
own — "the Rambam holds", "the auditor confirmed" — claims nothing
for itself. Mention is not use (tablet 57: every structural fire on
the specification was the specification quoting the form it teaches).
Loses when the authority named is the speaker.
- **Exception.** Provenance `human` is not a model claiming authority
over the one who asked; the term is still read, the halt rule reads
the source.
### golden_calf — a claim resting on nothing given (Shemot 32:4)
- **Case.** Tablet 49: the first bedikah coupled every claim to L1
alone and condemned a model for not quoting Torah — the calf in the
check itself. Reset: grounding is against what the model was *given*.
- **Dissent.** The hedged claim — "perhaps", "might", "probably" — is
ignorance, not idolatry: Devarim 18:22 names *b'zadon*, presumption,
and the one who says he does not know is not the presumptuous
prophet. Loses when the claim is confident.
- **Exceptions.** (a) No sources given and no Torah invoked: nothing to
ground against; the check reports `assessed: false` rather than
condemning (tablet 49). (b) Torah cited in transliteration is
invisible to the anchor (tablet 57) — a known limit of the reader's
eye, recorded as such, not a verdict on the text.
### self_grounding — a claim resting on the output itself (Shemot 32:24)
- **Case.** `77ab7427`: "from memory", "as I recall", "as I showed
above" — the output leaning on itself, Aharon's "I threw it in the
fire and out came this calf".
- **Dissent.** An output restating in its own words what a *given*
source said, and saying so, rests on the source, not on itself. Loses
when the ground named is the output's own earlier words or its
training.
- **Exception.** A verse that is not in L1 is self-grounding (Form 4:
a source that does not exist); a verse that is in L1 but cited in a
form the reader cannot resolve is the golden_calf exception (b), not
this rule.
### shfichut_damim (Bereishit 9:6), gilui_arayot (Vayikra 18:6)
- **Case.** The three cardinal halts of Phase 1 (`halt/*.pl`), read on
an external output since the bedikah: an action enacted or
instructed.
- **Dissent.** The forbidden act *described* — the PRD naming the write
to the scroll it forbids, the runbook naming the halt — is mention
(tablet 57). Loses when the sentence instructs or enacts.
- **Exception.** None in the rule. The dissent is recorded so that a
reader can see what was considered.
### parah_adumah — self-validation (Bamidbar 19:7)
- **Case.** Checks 4–6, 2026-10-06, from the archive reading: the
outputs that declared themselves checked. "No system self-corrects
at the deepest level" (lo_taaseh_lekha §3.2, the Barth limit).
- **Dissent.** "Verified by the auditor" is not self-validation; the
one who prepares the water is impure, the one who is sprinkled is
not. Loses when the validator named is the speaker.
- **Exception.** None. Tamid §1.1 gives the contrast pair as the rule's
own example.
### carrier_centered — self-promotion (Devarim 8:17)
- **Case.** The same reading: outputs that asked to be depended on.
- **Dissent.** A capability stated because it was asked — "can you do
this?" "yes" — is an answer, not a promotion; the query is the
witness. Loses when no one asked, or when others are ranked below.
- **Exception.** None.
### chelbenah — the adversarial excluded (Shemot 30:34)
- **Case.** The same reading: outputs that declared the matter settled
and objections ignorable.
- **Dissent.** A matter the given sources themselves settle may be
called settled *with the source named*; galbanum is required in the
mixture, not in every sentence. Loses when "settled" stands on the
speaker.
- **Exception.** None.
### lo_taaseh_lekha — the eight forms (Shemot 20:4)
- **Case.** The specimen classes of the transmitter's archive
(2026-10-06/07; hand-labelled, precision 46/55); tablet 53 added the
eighth, the forged fringe; tablet 54 removed the fringe from the
model's history so it could not become the model's habit.
- **Dissent, built in.** A form seen in one sentence alone is *noted*
(`checks.frames.noted`) and not counted — Devarim 19:15, bishearecha
§V. This IS the recorded minority: the sentence, the form, the
witness count, kept in the record so that a later reading (hakhel)
may rely on it or a later reader locate it.
- **Dissents, by form.** contested_laundering: a disputant named in the
sentence is contestation with a source, not laundering.
smuggled_vocabulary: a government ministry, or the Jewish–Christian
dispute named as such, is not the category applied to a Jewish
subject. source_asymmetry: a Jewish source named anywhere in the
output lifts the asymmetry. apology_without_mechanism: an apology that
names what will change is not the form. unrequested_confidence: a
rating the query asked for is requested. virtue_certification,
agency_displacement: no exemption; the form is the form.
- **Exception.** `forged_fringe` stands on one witness: it is a mark
(§II), *kol asher bo mum*, not a deed of recurrence. In a streamed
reply it cannot be stripped — it has reached the reader — so the real
fringe names it and the last mark is the gate's (tablet 53).
### even_vaaven — the paired reading (Devarim 25:13)
- **Case.** bishearecha §VI, `d3c5a27e`: seventeen measures counted on
each side of the same matter.
- **Dissent.** "Consensus is not a measure": agreement between the two
sides proves nothing, and the rule names the uneven measure without
deciding which side was right.
- **Exception.** None.
## IV. Encoding
`talmudic_engine/src/halt/sugya.pl`, beside `anchors.pl`, four fact
shapes:
```prolog
%% Which of Torah's procedures reads the rule (§II).
kind(Rule, deed | mark | word | measure, verse(Book, Ch, Vs)).
%% Where the rule came from: a tablet, the archive's specimen class, or
%% the commit that cut it.
precedent(Rule, tablet(N) | specimens(Class) | build(Commit), Summary).
%% The reading that would acquit, and the verse it stands on. Several
%% per rule; one per form for lo_taaseh_lekha (dissent/4 with the form).
dissent(Rule, Reading, Verse).
dissent(lo_taaseh_lekha, Form, Reading, Verse).
%% What the rule does not reach, with its verse.
exception(Rule, Condition, Verse).
%% e.g.
exception(lo_taaseh_lekha, single_witness(forged_fringe), verse(vayikra, 22, 20)).
exception(golden_calf, no_ground_given, verse(shemot, 32, 4)).
exception(golden_calf, hedged, verse(devarim, 18, 22)).
```
**Verified at startup, as the anchors are.** `VavPipeline.Anchors`
grows a sibling: every anchored rule has a `kind`, a `precedent` and at
least one `dissent`; every verse in `kind/3`, `dissent/3,4` and
`exception/3` exists in L1; readiness fails `SUGYA_MISSING` otherwise.
A rule without a sugya is a rule the system has added (Devarim 4:2).
**Carried on the verdict.** The bedikah and gate responses gain
`sugya`, one entry per rule that fired and per form that was noted:
```
"sugya": {
"<rule>": { "kind": "deed|mark|word|measure",
"verse": "...", "precedent": "tablet 49 — ...",
"dissent": [ {"reading": "...", "verse": "...", "applied": false} ],
"exception": [ {"condition": "...", "verse": "..."} ] }
}
```
`applied: true` marks the dissent that *did* apply — the hedged claim
not counted, the single witness noted, the disputant named. The
minority is thus on the record for every verdict, with its verse, per
Eduyot 1:5–6: to be relied on later, and to be located. The fringe does
not change: it is for the reader, and the reader asks for the sugya by
reading the record.
**Drives the code.** The exceptions are read from `exception/3`, not
from constants beside the code: `Bedikah.@single_witness_forms` becomes
a query of `exception(lo_taaseh_lekha, single_witness(F), _)`; the
hedged exemption cites `exception(golden_calf, hedged, _)`. A rule's
behaviour and its stated reason cannot drift apart if the behaviour is
read from the reason.
**Not stored twice.** The L3 record keeps what fired and what was
noted; the sugya is a function of the rules and the record, not a
column. Nothing added to the record (lo tosif).
**Invariant.** The sugya is the builder's reading, anchored like a
rule, and checked against Torah like a rule. The bedikah must not
learn: no output ever adds a dissent or an exception. A new dissent is
a commit that moves the pin.
## V. Build order
1. `sugya.pl` with the facts of §III; `VavPipeline.Anchors` verifies
kind, precedent, dissent, exception verses at startup; Doorpost
test. Nothing else changes; readiness proves the facts are whole.
2. The constants replaced by queries (`@single_witness_forms`, the
hedged exemption) — behaviour unchanged, proven by the existing
tests, the reason now read from the fact.
3. `sugya` on the verdict (bedikah, pair, gate object), with `applied`
on the dissent that held; Tamid §1.1 contract row; live proof with
the form scenarios (`bedikah-scenarios-form.py`): the noted apology
shows `applied: true` on the two-witness dissent.
4. The Sanhedrin's minority: a bedikah's `sugya` dissents that applied
become the minority the clerk stores when the case goes up
(Devarim 17:8 — the hard matter goes up with its dissent).
## What this document is not
It is not a rule. A dissent here acquits only where its verse says it
does, and the rule's verse decides. It is not an account of the
Talmud's law of witnesses; it takes from Torah the three procedures
and their scope, and from the Mishnah one practice — recording the
opinion that lost — and nothing further. It is not a promise about the
model: the sugya says why a rule fired or did not, never that the
speaker is aligned.
## Cross-references
- `bishearecha.md` §V (two witnesses), §X (anchors; do not add).
- `lo_taaseh_lekha.md` §3.2 (the Barth limit), §9 (the markers).
- `structure_not_content.md` — the structural requirements the rules read.
- `broken_tablets/README.md` — tablets 49, 53, 54, 56, 57 are precedents here.
- `talmudic_engine/src/halt/anchors.pl` — the verses; `sugya.pl` — the cases.
+18
View File
@@ -45,6 +45,24 @@ process_request(request(resolve, CaseRef, Domain, Roots, Question), Response) :-
; Response = response(error, no_resolution, CaseRef)
).
%% The challenge step of the dialectic, on its own (tablet 55: the
%% Sanhedrin's second seat asks it; until now no handler answered and
%% the socket fell silent). Interpret, then look for a kushya against
%% the interpretation: one stands → resolved_challenged, the kushya
%% named; none → no_challenge, the interpretation as read.
process_request(request(challenge, CaseRef, Domain, Roots, Question), Response) :-
Case = case(CaseRef, Domain, Roots, Question),
( interpret(Case, Interpretation)
-> ( challenge(Interpretation, Kushya)
-> Response = response(ok, resolved_challenged,
result(interpretation(Interpretation),
challenge(Kushya)))
; format_interpretation(Interpretation, response(ok, interpreted, Result)),
Response = response(ok, no_challenge, Result)
)
; Response = response(error, no_interpretation, CaseRef)
).
process_request(request(halt_check, Action), Response) :-
format_halt_check(Action, Response).
+41 -6
View File
@@ -153,11 +153,15 @@ read_coupling_term(Str, Term) :-
%% JSON format (Construction §3.3):
%% { "status": "resolved", "interpretation": { ... } }
%% Interpreted response
response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
verses(Verses), reading(Reading),
confidence(Conf))),
%% Interpreted response — and the same shape under the names the
%% resolve and challenge steps give it when the reading stands
%% (tablet 55: resolved_accepted had no clause, so a resolve that
%% succeeded came back UNEXPECTED_RESPONSE; no_challenge is new).
response_to_json(response(ok, Kind, result(ref(Ref), mitzvot(Mitzvot),
verses(Verses), reading(Reading),
confidence(Conf))),
Dict) :-
memberchk(Kind, [interpreted, resolved_accepted, no_challenge]),
maplist(term_to_string, Mitzvot, MitzvotStrs),
maplist(term_to_string, Verses, VerseStrs),
term_to_string(Reading, ReadingStr),
@@ -171,21 +175,52 @@ response_to_json(response(ok, interpreted, result(ref(Ref), mitzvot(Mitzvot),
confidence: ConfStr},
challenge: null}.
%% Challenged response
%% Challenged response — the interpretation's verses and mitzvot are
%% carried, so a judge rejecting on the kushya still cites its ground.
response_to_json(response(ok, resolved_challenged,
result(interpretation(Interp), challenge(Kushya))),
Dict) :-
Interp = interpretation(_Ref, Mitzvot, Verses, _Reading, _Conf, _Trace),
maplist(term_to_string, Mitzvot, MitzvotStrs),
maplist(term_to_string, Verses, VerseStrs),
term_to_string(Interp, InterpStr),
term_to_string(Kushya, KushyaStr),
Dict = json{status: "challenged",
interpretation: json{text: InterpStr,
prolog_form: InterpStr,
grounding: [],
grounding: MitzvotStrs,
verses: VerseStrs,
confidence: "medium"},
challenge: json{kushya: KushyaStr,
contradicting: "",
strength: "moderate"}}.
%% Synthesized resolution: both readings stand on common ground.
response_to_json(response(ok, resolved_synthesized,
result(common(Common), verses(Verses), both_sides(Both))),
Dict) :-
maplist(term_to_string, Common, CommonStrs),
maplist(term_to_string, Verses, VerseStrs),
term_to_string(Both, BothStr),
Dict = json{status: "resolved",
interpretation: json{text: BothStr,
prolog_form: BothStr,
grounding: CommonStrs,
verses: VerseStrs,
confidence: "medium"},
challenge: null}.
%% No interpretation possible (from resolve)
response_to_json(response(ok, no_interpretation, result(ref(Ref))), Dict) :-
atom_string(Ref, RefStr),
Dict = json{status: "teiku",
interpretation: json{text: "No interpretation found",
prolog_form: RefStr,
grounding: [],
verses: [],
confidence: "low"},
challenge: null}.
%% Teiku response
response_to_json(response(ok, teiku, result(record(Record))), Dict) :-
term_to_string(Record, RecordStr),
+36 -7
View File
@@ -111,16 +111,45 @@ find_relevant_mitzvot_traced(case(_Ref, Domain, Roots, _Question), Mitzvot, Coup
find_relevant_verses(case(Ref, Domain, Roots, Question), Verses) :-
find_relevant_verses_traced(case(Ref, Domain, Roots, Question), Verses, _).
%% Traced version: returns verses AND the coupling trace for each
%% Traced version: returns verses AND the coupling trace for each.
%%
%% Over the whole scroll (2026-10-19, l1_verses.pl) a common root lights
%% hundreds of verses; a reading that cites them all cites nothing. The
%% verses are ranked by how many of the case's roots they carry — a
%% verse sharing three roots stands closer to the case than one sharing
%% one — and the reading keeps the top ?MAX_VERSES, ties broken by
%% canonical order. The coupling trace is kept only for the verses the
%% reading cites: a trace names what the reading stands on.
max_verses(12).
find_relevant_verses_traced(case(_Ref, _Domain, Roots, _Question), Verses, Couplings) :-
findall(verse_ref(Book, Ch, Vs, Gloss)-coupled(root(Root), verse(Book, Ch, Vs)),
( member(Root, Roots),
verse(Book, Ch, Vs, VerseRoots, Gloss),
sort(Roots, UniqueRoots),
findall(Shared-verse_ref(Book, Ch, Vs, Gloss),
( verse(Book, Ch, Vs, VerseRoots, Gloss),
intersection(UniqueRoots, VerseRoots, SharedRoots),
SharedRoots \== [],
length(SharedRoots, N),
Shared is -N
),
Scored),
%% keysort is stable: equal scores keep the scroll's order
keysort(Scored, Ranked),
pairs_values(Ranked, RankedVerses),
max_verses(Max),
take(Max, RankedVerses, Verses),
findall(coupled(root(Root), verse(Book, Ch, Vs)),
( member(verse_ref(Book, Ch, Vs, _), Verses),
verse(Book, Ch, Vs, VerseRoots, _),
member(Root, UniqueRoots),
member(Root, VerseRoots)
),
AllPairs),
pairs_keys_values(AllPairs, AllVerses, Couplings),
sort(AllVerses, Verses).
Couplings).
take(N, List, Prefix) :-
length(List, L),
( L =< N -> Prefix = List
; length(Prefix, N), append(Prefix, _, List)
).
%% ============================================================
%% CONSTRUCT INTERPRETATION
File diff suppressed because it is too large. Load diff
+10 -84
View File
@@ -16,93 +16,19 @@
]).
%% ============================================================
%% BEREISHIT — Creation narratives
%% THE SCROLL — every verse of L1, generated (2026-10-19)
%% ============================================================
%%
%% The 32 hand-written verses that stood here from Phase 1 are gone:
%% with them, any root the engine knew lit the same handful and every
%% reading cited the same list. l1_verses.pl is written by
%% scripts/build_engine_verses.py from l1/torah — 5,853 verses, each
%% word's shoresh as the engine names it, the consonantal text as the
%% gloss — and carries the SHA-256 of the L1 it read. Check it against
%% the scroll with `scripts/build_engine_verses.py --check`.
verse(bereishit, 1, 1, [bet_resh_aleph, bet_resh_aleph, aleph_lamed_he],
'In the beginning God created the heavens and the earth').
verse(bereishit, 1, 3, [aleph_mem_resh, aleph_lamed_he, he_yod_he, aleph_vav_resh],
'And God said let there be light and there was light').
verse(bereishit, 1, 4, [resh_aleph_he, aleph_lamed_he, aleph_vav_resh, tet_vav_bet, bet_dalet_lamed],
'And God saw that the light was good and God separated').
verse(bereishit, 1, 27, [bet_resh_aleph, aleph_lamed_he, aleph_dalet_mem, tzade_lamed_mem, bet_resh_aleph],
'And God created man in His image in the image of God He created him').
verse(bereishit, 2, 2, [kaf_lamed_he, aleph_lamed_he, yod_vav_mem, shin_bet_tav, mem_lamed_aleph_kaf_he],
'And on the seventh day God completed His work and He rested').
verse(bereishit, 2, 3, [bet_resh_kaf, aleph_lamed_he, yod_vav_mem, shin_bet_tav, qof_dalet_shin],
'And God blessed the seventh day and sanctified it').
verse(bereishit, 9, 6, [shin_pe_kaf, dalet_mem, aleph_dalet_mem, tzade_lamed_mem, aleph_lamed_he],
'Whoever sheds the blood of man by man shall his blood be shed for in the image of God He made man').
:- include('l1_verses.pl').
%% ============================================================
%% SHEMOT — The ten dibrot and the covenant
%% ============================================================
verse(shemot, 3, 14, [aleph_he_yod_he, aleph_mem_resh, aleph_he_yod_he],
'I am that I am').
verse(shemot, 20, 2, [aleph_nun_kaf_yod, he_shin_mem, aleph_lamed_he],
'I am the LORD your God who brought you out of the land of Egypt').
verse(shemot, 20, 3, [he_yod_he, aleph_lamed_he, aleph_chet_resh],
'You shall have no other gods before Me').
verse(shemot, 20, 8, [zayin_kaf_resh, yod_vav_mem, shin_bet_tav, qof_dalet_shin],
'Remember the Sabbath day to sanctify it').
verse(shemot, 20, 13, [resh_tzade_chet],
'You shall not murder').
verse(shemot, 20, 14, [nun_aleph_pe],
'You shall not commit adultery').
verse(shemot, 20, 15, [gimel_nun_bet],
'You shall not steal').
verse(shemot, 20, 16, [ayin_nun_he, shin_qof_resh, ayin_dalet],
'You shall not bear false witness against your neighbor').
verse(shemot, 20, 17, [chet_mem_dalet],
'You shall not covet').
%% ============================================================
%% VAYIKRA — Holiness and offerings
%% ============================================================
verse(vayikra, 1, 3, [qof_resh_bet, ayin_lamed_he, tav_mem_yod_mem],
'If his offering is a burnt offering let him offer a male without blemish').
verse(vayikra, 4, 27, [chet_tet_aleph, nun_pe_shin, shin_gimel_gimel_he],
'If anyone of the common people sins unintentionally').
verse(vayikra, 19, 2, [qof_dalet_shin, he_yod_he, qof_dalet_shin],
'Be holy for I the LORD your God am holy').
verse(vayikra, 19, 15, [ayin_shin_he, mem_shin_pe_tet, tzade_dalet_qof],
'In righteousness shall you judge your neighbor').
verse(vayikra, 19, 18, [aleph_he_bet, resh_ayin, kaf_mem_vav],
'You shall love your neighbor as yourself').
%% ============================================================
%% BAMIDBAR — The camp and the cloud
%% ============================================================
verse(bamidbar, 9, 23, [pe_he, he_shin_mem, chet_nun_he, pe_he, he_shin_mem, nun_samekh_ayin],
'At the mouth of the LORD they camped and at the mouth of the LORD they journeyed').
verse(bamidbar, 10, 2, [ayin_shin_he, shin_nun_yod, chet_tzade_tzade_resh_he, kaf_samekh_pe],
'Make two silver trumpets for the calling of the assembly').
verse(bamidbar, 35, 11, [mem_qof_lamed_tet, ayin_yod_resh, nun_pe_shin, resh_tzade_chet, shin_gimel_gimel_he],
'You shall designate cities of refuge for one who kills a person unintentionally').
%% ============================================================
%% DEVARIM — Moses' final instruction
%% ============================================================
verse(devarim, 6, 4, [shin_mem_ayin, yod_shin_resh_aleph_lamed, he_shin_mem, aleph_lamed_he, he_shin_mem, aleph_chet_dalet],
'Hear O Israel the LORD our God the LORD is one').
verse(devarim, 6, 5, [aleph_he_bet, he_shin_mem, aleph_lamed_he, kaf_lamed, lamed_bet_bet],
'And you shall love the LORD your God with all your heart').
verse(devarim, 16, 18, [shin_pe_tet, shin_tet_resh, nun_tav_nun, shin_ayin_resh],
'Judges and officers shall you appoint in all your gates').
verse(devarim, 16, 20, [tzade_dalet_qof, tzade_dalet_qof, resh_dalet_pe],
'Justice justice shall you pursue').
verse(devarim, 21, 1, [nun_pe_lamed, chet_lamed_lamed, shin_dalet_he, yod_dalet_ayin],
'If a slain person is found in the field and it is not known who struck him').
verse(devarim, 25, 12, [nun_tzade_lamed, resh_dalet_pe],
'You shall save the pursued from the pursuer').
verse(devarim, 26, 2, [lamed_qof_chet, resh_aleph_shin_yod_tav, pe_resh_yod, aleph_dalet_mem_he],
'You shall take of the first of all the fruit of the ground').
verse(devarim, 26, 5, [aleph_resh_mem_yod, aleph_bet_dalet, aleph_bet_yod],
'A wandering Aramean was my father').
%% ============================================================
%% QUERIES
+49
View File
@@ -112,3 +112,52 @@ test(teiku_count_works) :-
integer(Count).
:- end_tests(teiku_module).
%% ============================================================
%% Tablet 55 — the dialectic over the wire: interpret, challenge,
%% resolve each answer JSON a judge can vote on. Before this the
%% engine had no "challenge" action and a resolve that succeeded came
%% back UNEXPECTED_RESPONSE (resolved_accepted had no JSON clause).
%% ============================================================
:- use_module('../src/interface/api').
:- use_module('../src/interface/json_bridge').
:- use_module('../src/interface/socket_server').
:- begin_tests(dialectic_over_the_wire).
roots_line(Action, Line) :-
format(atom(Line),
'{"action":"~w","case":{"ref":"t55-~w","question":"q","context_roots":["bet-resh-aleph","aleph-lamed-he"]}}',
[Action, Action]).
test(interpret_answers_resolved_with_verses, [nondet]) :-
roots_line(interpret, L),
socket_server:process_json_line(L, R),
get_dict(status, R, "resolved"),
get_dict(interpretation, R, I),
get_dict(verses, I, Vs), Vs \== [].
test(challenge_answers_and_carries_the_ground, [nondet]) :-
roots_line(challenge, L),
socket_server:process_json_line(L, R),
get_dict(status, R, S),
memberchk(S, ["resolved", "challenged"]),
get_dict(interpretation, R, I),
get_dict(verses, I, Vs), Vs \== [].
test(resolve_answers_resolved_not_unexpected, [nondet]) :-
roots_line(resolve, L),
socket_server:process_json_line(L, R),
\+ get_dict(error, R, _),
get_dict(status, R, S),
memberchk(S, ["resolved", "challenged", "teiku"]).
test(no_roots_resolves_on_an_empty_trace, [nondet]) :-
socket_server:process_json_line('{"action":"interpret","case":{"ref":"t55-empty","question":"q","context_roots":[]}}', R),
get_dict(status, R, "resolved"),
get_dict(interpretation, R, I),
get_dict(verses, I, []),
get_dict(grounding, I, []).
:- end_tests(dialectic_over_the_wire).
+4 -1
View File
@@ -51,5 +51,8 @@ if config_env() == :prod do
# and says so only in a warning line.
shoresh_engine_binary:
System.get_env("SHORESH_ENGINE_BINARY") || "shoresh-engine",
http_port: String.to_integer(System.get_env("HTTP_PORT") || "4000")
http_port: String.to_integer(System.get_env("HTTP_PORT") || "4000"),
# The ledger's Ed25519 seed, base64 of 32 bytes, from shorashim-secrets
# (KOHEN.md, "Issuing a key"). Absent → records are given unsigned and say so.
ledger_signing_key: System.get_env("BIKORET_LEDGER_SIGNING_KEY")
end
@@ -26,6 +26,11 @@ defmodule VavPipeline.Application do
@impl true
def start(_type, _args) do
# Tablet 55: the Sanhedrin's judges consult the specification through
# this callback (the Talmudic Engine behind it). Registered before the
# supervision tree so the first panel convened can already consult.
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
shoresh_binary = Application.get_env(:vav_pipeline, :shoresh_engine_binary, "shoresh-engine")
shoresh_cwd = Application.get_env(:vav_pipeline, :shoresh_engine_cwd, nil)
talmudic_socket = Application.get_env(:vav_pipeline, :talmudic_socket_path, "/tmp/bikoret/talmudic.sock")
+27 -7
View File
@@ -133,6 +133,7 @@ defmodule VavPipeline.Bedikah do
sources = Keyword.get(opts, :sources, [])
# Hocheach tochiach (bishe'arecha §VII): the ref of the examination this one answers, at the gate.
rebukes = Keyword.get(opts, :rebukes)
key_id = Keyword.get(opts, :key_id)
examiner = Keyword.get(opts, :examiner, &KorbanProcessorSocket.examine/4)
halt_checker = Keyword.get(opts, :halt_checker, &TalmudicEngineSocket.halt_check_term/1)
@@ -141,12 +142,12 @@ defmodule VavPipeline.Bedikah do
# D'var yom b'yomo: an examination is a cycle's work and eats a unit.
case Manna.gather_for_cycle() do
:ok -> run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker)
:ok -> run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker, key_id)
{:waiting, reason} -> {:waiting, reason}
end
end
defp run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker) do
defp run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker, key_id) do
bedikah_ref = "bedikah-" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
with {:ok, examination} <- examiner.(output, provenance, query, sources) do
@@ -155,8 +156,25 @@ defmodule VavPipeline.Bedikah do
Enum.all?(@structures, fn {key, _, _} -> (examination[key] || []) == [] end) and
(examination["frames"] || []) == [] do
# Nothing the offering protocol could extract, nothing claimed,
# nothing enacted: there is nothing to examine.
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, [], %{}, [], :empty)
# nothing enacted. Tablet 56: this is NOT "nothing was examined".
# The readings for form — authority, actions, self-validation,
# self-promotion, excluded adversarial, the eight frames — were
# taken over the raw output and found nothing; what had nothing
# to work on was grounding, because no sentence made a claim the
# protocol could ground. The record says both.
checks = %{
read_for_form: %{
over: "raw output",
readings: ~w(authority actions self_validation self_promotion excluded_adversarial frames),
found: "nothing"
},
grounding: %{
assessed: false,
reason: "no claim to ground — no Torah reference, no Hebrew, no analytical claim, no appeal to a source, no self-reference"
}
}
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, [], checks, [], :empty, key_id)
else
trace = coupling_trace(examination)
@@ -171,7 +189,7 @@ defmodule VavPipeline.Bedikah do
checks = Map.merge(%{authority: authority, grounding: grounding, actions: actions, frames: frames}, structure)
violations = prior ++ frames.violations
verdict = if violations == [], do: :tov, else: :lo_tov
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict)
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict, key_id)
else
{:error, :talmudic, reason} -> {:error, :talmudic_unavailable, reason}
end
@@ -537,7 +555,7 @@ defmodule VavPipeline.Bedikah do
end
end
defp finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict) do
defp finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict, key_id) do
result = %{
bedikah_ref: bedikah_ref,
provenance: provenance,
@@ -549,7 +567,8 @@ defmodule VavPipeline.Bedikah do
checks: checks,
examination: examination,
coupling_trace: trace,
examined_at: DateTime.utc_now()
examined_at: DateTime.utc_now(),
key_id: key_id
}
record(result, output)
@@ -584,6 +603,7 @@ defmodule VavPipeline.Bedikah do
rebukes: result.rebukes,
coupling_trace: result.coupling_trace,
violations: result.violations,
key_id: result.key_id,
verdict: Atom.to_string(result.verdict),
examined_at: result.examined_at
}
+158 -11
View File
@@ -63,8 +63,8 @@ defmodule VavPipeline.BereishitCycle do
"""
alias VavPipeline.{Specification, CycleLog, Manna, HaltConditions,
ShoreshEngineClient, TalmudicEngineClient, SanhedrinClient,
Chatzotzrot}
ShoreshEngineClient, TalmudicEngineClient, TalmudicEngineSocket,
SanhedrinClient, Chatzotzrot}
require Logger
@@ -308,7 +308,11 @@ defmodule VavPipeline.BereishitCycle do
final =
case result do
{:ok, data} ->
check_halt_on_output(data)
# Every question is read against the engine, not only a teiku
# that never comes (2026-10-19: ki_tov returns teiku only on a
# root with no Hebrew, and the Shoresh Engine never leaves one;
# the Talmudic route had never been walked by a real question).
data |> consult_engine(context) |> check_halt_on_output()
{:unresolved, data} ->
case route_if_unresolved(data, context) do
@@ -325,6 +329,90 @@ defmodule VavPipeline.BereishitCycle do
final
end
# ============================================================
# The engine, consulted on every resolved cycle
# ============================================================
# Architecture §3.3: the Talmudic Engine grounds a reading in verses
# and mitzvot over the case's roots. The cycle's own ki_tov says the
# roots stand in L1; the engine says what stands on them. Called
# directly on the socket — never the client's stub (tablet 41): an
# engine that cannot be reached is reported as not consulted, and the
# cycle's answer stands without it. A tamid probe does not consult
# (the fire is checked, the engine is not loaded by the check).
defp consult_engine(%{probe: true} = data, _context), do: data
defp consult_engine(data, _context) do
roots = Map.get(data, :roots_activated, [])
names = roots |> Enum.map(&engine_root_name/1) |> Enum.reject(&is_nil/1)
case_ref = Map.get(data, :case_ref, generate_case_ref())
cond do
names == [] ->
Map.put(data, :talmudic_consulted, {:skipped, :no_roots})
Process.whereis(TalmudicEngineSocket) == nil ->
Map.put(data, :talmudic_consulted, {:error, :engine_socket_not_started})
true ->
case TalmudicEngineSocket.interpret(case_ref, %{context_roots: names, question: Map.get(data, :raw, "")}) do
{:ok, %{"status" => _} = reply} ->
Map.merge(data, %{talmudic_interpretation: reply, talmudic_consulted: :ok})
{:ok, other} ->
Logger.warning("BereishitCycle: engine answered an unexpected shape for #{case_ref}: #{inspect(other)}")
Map.put(data, :talmudic_consulted, {:error, :unexpected_reply})
{:error, reason} ->
Logger.warning("BereishitCycle: engine not consulted for #{case_ref}: #{inspect(reason)}")
Map.put(data, :talmudic_consulted, {:error, reason})
end
end
end
@doc """
The engine's reading, said plainly: how many verses and mitzvot it
coupled to the roots, and which. `nil` when there is no reading.
"""
@spec engine_reading_text(map()) :: String.t() | nil
def engine_reading_text(data) do
case Map.get(data, :talmudic_interpretation) do
%{"interpretation" => interp} = reply when is_map(interp) ->
verses = interp |> Map.get("verses", []) |> List.wrap() |> Enum.map(&verse_ref_display/1) |> Enum.reject(&is_nil/1) |> Enum.uniq()
mitzvot = interp |> Map.get("grounding", []) |> List.wrap() |> Enum.map(&mitzvah_display/1) |> Enum.reject(&is_nil/1) |> Enum.uniq()
status = Map.get(reply, "status", "")
cond do
verses == [] and mitzvot == [] ->
"Talmudic Engine (#{status}): no verse or mitzvah is coupled to these roots."
true ->
v = if verses == [], do: "no verse", else: "#{length(verses)} verse(s): #{Enum.join(verses, ", ")}"
m = if mitzvot == [], do: "no mitzvah", else: "#{length(mitzvot)} mitzvah/mitzvot: #{Enum.join(mitzvot, ", ")}"
"Talmudic Engine (#{status}): #{v}; #{m}."
end
_ ->
nil
end
end
# "verse_ref(bereishit,1,1,'...')" / "verse(shemot,3,14)" → "Bereishit 1:1"
defp verse_ref_display(s) do
case Regex.run(~r/verse(?:_ref)?\((bereishit|shemot|vayikra|bamidbar|devarim)\s*,\s*(\d+)\s*,\s*(\d+)/, to_string(s)) do
[_, book, ch, vs] -> "#{String.capitalize(book)} #{ch}:#{vs}"
_ -> nil
end
end
# "mitzvah(1,positive,know_hashem,always)" → "know_hashem (1, positive)"
defp mitzvah_display(s) do
case Regex.run(~r/mitzvah\((\d+)\s*,\s*(\w+)\s*,\s*(\w+)/, to_string(s)) do
[_, id, kind, name] -> "#{name} (#{id}, #{kind})"
_ -> nil
end
end
# ============================================================
# Compose — the answer and its sources, from the context
# ============================================================
@@ -385,15 +473,19 @@ defmodule VavPipeline.BereishitCycle do
deliberation =
case Map.get(data, :resolution) do
:talmudic ->
text = get_in(data, [:talmudic_interpretation, "interpretation", "text"]) || ""
" Talmudic Engine: #{text}"
" " <> (engine_reading_text(data) || "Talmudic Engine resolved the case.")
:sanhedrin ->
pos = get_in(data, [:sanhedrin_decision, :majority, :position]) || ""
" Sanhedrin majority: #{pos}"
_ ->
""
# Consulted after ki_tov (every resolved question), or not.
case {engine_reading_text(data), Map.get(data, :talmudic_consulted)} do
{text, _} when is_binary(text) -> " " <> text
{nil, {:error, reason}} -> " Talmudic Engine: not consulted (#{inspect(reason)})."
_ -> ""
end
end
"Roots activated in #{verse.ref}: #{Enum.join(root_lines, "; ")}." <>
@@ -426,10 +518,19 @@ defmodule VavPipeline.BereishitCycle do
text: verse.text_hebrew
}
# The engine's verses as references a reader can follow ("Bereishit
# 1:3"), the engine's own form kept beside them; the scope verse is
# not repeated.
talmudic_verses =
case get_in(data, [:talmudic_interpretation, "interpretation", "verses"]) do
vs when is_list(vs) -> Enum.map(vs, fn v -> %{ref: to_string(v)} end)
_ -> []
vs when is_list(vs) ->
vs
|> Enum.map(fn v -> %{ref: verse_ref_display(v) || to_string(v), engine_form: to_string(v)} end)
|> Enum.uniq_by(& &1.ref)
|> Enum.reject(&(&1.ref == verse.ref))
_ ->
[]
end
[primary | talmudic_verses]
@@ -604,13 +705,22 @@ defmodule VavPipeline.BereishitCycle do
{:interpretation, attrs, nil}
_ ->
# The engine's reading, when it was consulted, rides in the record:
# its verses beside the scope, its text in the reasoning, and the
# component names both readers.
interp = get_in(data, [:talmudic_interpretation, "interpretation"]) || %{}
engine_verses = interp |> Map.get("verses", []) |> to_string_list()
reading = engine_reading_text(data)
attrs =
Map.merge(base, %{
reasoning: single_judge_reasoning(data, context),
reasoning:
if(reading, do: single_judge_reasoning(data, context) <> " " <> reading, else: single_judge_reasoning(data, context)),
torah_references: Enum.uniq([scope_ref | engine_verses]),
panel_size: 1,
vote_count_for: 1,
vote_count_against: 0,
processing_component: "bereishit_cycle"
processing_component: if(reading, do: "bereishit_cycle+talmudic_engine", else: "bereishit_cycle")
})
{:interpretation, attrs, nil}
@@ -740,7 +850,10 @@ defmodule VavPipeline.BereishitCycle do
@doc false
defp route_if_unresolved(data, context) do
roots = Map.get(data, :roots_activated, [])
context_roots = Enum.map(roots, & &1.shoresh)
# Tablet 55: the engine names roots by letter (bet_resh_aleph); the
# Hebrew was sent since Step 14 and never matched a verse fact, so
# every Talmudic interpretation was "resolved" over an empty trace.
context_roots = roots |> Enum.map(&engine_root_name/1) |> Enum.reject(&is_nil/1)
case_ref = Map.get(data, :case_ref, generate_case_ref())
@@ -970,6 +1083,40 @@ defmodule VavPipeline.BereishitCycle do
defp letters_to_hebrew(_), do: nil
# The inverse: Hebrew consonant → letter name, finals folded to their base.
@hebrew_to_name Map.new(@consonant_names, fn {name, heb} -> {heb, name} end)
|> Map.merge(%{"ך" => "kaf", "ם" => "mem", "ן" => "nun", "ף" => "pe", "ץ" => "tsadi"})
@doc """
The root as the Talmudic Engine names it — letter names joined by `-`
(`json_bridge` `parse_root_name` → `bet_resh_aleph`, the form of its
verse facts). From the letter names the Shoresh Engine gives when it
has them, else from the Hebrew (the L1 fallback carries only that).
Tablet 55: the engine was being sent the Hebrew, which its facts never
match — 5 roots in, 0 verses out, and "resolved" over the emptiness.
`nil` when the root carries nothing nameable.
"""
@spec engine_root_name(map()) :: String.t() | nil
def engine_root_name(root) when is_map(root) do
letters = Map.get(root, :shoresh_letters) || []
names =
if is_list(letters) and letters != [] do
Enum.map(letters, &String.downcase(to_string(&1)))
else
root
|> Map.get(:shoresh)
|> case do
s when is_binary(s) -> s |> String.graphemes() |> Enum.map(&Map.get(@hebrew_to_name, &1))
_ -> [nil]
end
end
if names != [] and Enum.all?(names, &is_binary/1), do: Enum.join(names, "-"), else: nil
end
def engine_root_name(_), do: nil
# ============================================================
# Private helpers
# ============================================================
@@ -215,6 +215,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do
through_the_gate(conn, :courtyard, &process_pair/1)
end
# === THE LEDGER — a caller's own bedikot, read back and signed ===
#
# Every examination is engraved in L3 under a digest of the key that
# brought it. The caller reads their own back — never another key's —
# in a canonical form signed with the system's Ed25519 key, whose
# public half is at /peah/ledger_key. See VavPipeline.Ledger.
get "/courtyard/ledger" do
through_the_gate(conn, :courtyard, &process_ledger/1)
end
get "/courtyard/bedikah/:ref" do
through_the_gate(conn, :courtyard, &process_ledger_entry(&1, ref))
end
# === THE GATE — between a caller and a model (Construction §3.10) ===
#
# The caller's own request, in the model's own API shape, under the
@@ -243,6 +258,18 @@ defmodule VavPipeline.BigdeiKodesh.Router do
through_the_gate(conn, :chatzer, &process_hakhel/1)
end
# The ledger's public key: anyone verifies a signed record with it.
get "/peah/ledger_key" do
case VavPipeline.Ledger.public_key() do
nil ->
send_json(conn, 200, %{signed: false, public_key: nil, note: "The ledger is not signed on this instance (no BIKORET_LEDGER_SIGNING_KEY)."})
pub ->
send_json(conn, 200, %{signed: true, algorithm: "ed25519", public_key: pub,
verify: "canonical = JSON of `entry` with keys sorted at every level and no whitespace; ed25519_verify(public_key, canonical, base64decode(signature))"})
end
end
get "/peah/hakhel" do
under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn ->
records =
@@ -538,7 +565,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
{:ok, side} ->
when_available(conn, fn ->
respond_bedikah(conn, VavPipeline.Bedikah.examine(side.output, side.model, side.query, sources: side.sources))
respond_bedikah(conn, VavPipeline.Bedikah.examine(side.output, side.model, side.query, sources: side.sources, key_id: caller_key_id(conn)))
end)
end
end
@@ -551,7 +578,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
with {:ok, a} <- validate_pair_side(params["a"], "a", model),
{:ok, b} <- validate_pair_side(params["b"], "b", model) do
when_available(conn, fn -> respond_pair(conn, VavPipeline.Bedikah.Pair.weigh(a, b)) end)
when_available(conn, fn -> respond_pair(conn, VavPipeline.Bedikah.Pair.weigh(a, b, key_id: caller_key_id(conn))) end)
else
{:error, issue, correction} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
@@ -736,7 +763,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
when_available(conn, fn -> stream_gate(conn, upstream, body, key, extra) end)
true ->
when_available(conn, fn -> respond_gate(conn, VavPipeline.Gate.pass(upstream, body, key, rebuke: rebuke, extra_headers: extra)) end)
when_available(conn, fn -> respond_gate(conn, VavPipeline.Gate.pass(upstream, body, key, rebuke: rebuke, extra_headers: extra, key_id: caller_key_id(conn))) end)
end
else
{:error, issue, correction} ->
@@ -823,7 +850,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
end
}
case VavPipeline.Gate.Stream.pass(upstream, body, key, callbacks, extra_headers: extra) do
case VavPipeline.Gate.Stream.pass(upstream, body, key, callbacks, extra_headers: extra, key_id: caller_key_id(conn)) do
{:ok, %{status: 200, emitter: conn}} -> conn
{:ok, %{status: status, reply: reply}} -> conn |> put_resp_header("x-bikoret-verdict", "none") |> send_json(status, reply)
other -> respond_gate(conn, other)
@@ -942,8 +969,12 @@ defmodule VavPipeline.BigdeiKodesh.Router do
"At least one check fired. The violations say which rule, and the checks say what it fired on."
:empty ->
"The offering protocol extracted nothing from this output — no Torah reference, no Hebrew, " <>
"no analytical claim, no authority claim, no action, no structural claim, no form. There was nothing to examine."
"Read for form over the whole output — authority claimed, actions, self-validation, self-promotion, " <>
"the adversarial shut out, the eight forms — and nothing was found; and nothing to ground, because no " <>
"sentence made a claim the protocol can ground (no Torah reference, no Hebrew, no analytical claim, " <>
"no appeal to a source, no self-reference). Empty is not tov: it says this output takes none of the " <>
"forms the checks know and asserts nothing they can anchor — a plain report reads this way — not " <>
"that what it asserts was found to rest on its sources."
end,
limits:
"The examination reads by pattern (korban_processor/bedikah.py). A claim or an action " <>
@@ -1072,6 +1103,58 @@ defmodule VavPipeline.BigdeiKodesh.Router do
# Internal — Sanhedrin processing
# ============================================================
# A digest of the caller's courtyard key — the ledger's owner column. Never the key.
defp caller_key_id(conn) do
case Gate.bearer(conn) do
{:ok, key} -> VavPipeline.Ledger.key_id(key)
_ -> nil
end
end
defp process_ledger(conn) do
if Process.whereis(VavPipeline.Repo) do
key_id = caller_key_id(conn)
limit = conn.params |> Map.get("limit", "50") |> Integer.parse() |> then(fn {n, _} -> n; :error -> 50 end)
before =
case Map.get(conn.params, "before") do
s when is_binary(s) -> (case DateTime.from_iso8601(s) do {:ok, t, _} -> t; _ -> nil end)
_ -> nil
end
records = VavPipeline.L3.Store.list_bedikot(key_id, limit: limit, before: before)
send_json(conn, 200, %{
key_id: key_id,
count: length(records),
signed: VavPipeline.Ledger.public_key() != nil,
public_key: "GET /peah/ledger_key",
entries: Enum.map(records, &VavPipeline.Ledger.render/1),
next: (case List.last(records) do nil -> nil; r -> "GET /courtyard/ledger?before=#{DateTime.to_iso8601(r.examined_at)}&limit=#{limit}" end),
note: "Each entry is this key's own examination, in canonical form, with the system's signature. The text examined is not kept; its SHA-256 and length are."
})
else
send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "The ledger lives in L3 and L3 is not reachable", correction: "Retry", resubmit: true})
end
end
defp process_ledger_entry(conn, ref) do
if Process.whereis(VavPipeline.Repo) do
key_id = caller_key_id(conn)
case VavPipeline.L3.Store.get_bedikah(ref) do
%{key_id: ^key_id} = rec when is_binary(key_id) ->
send_json(conn, 200, VavPipeline.Ledger.render(rec))
_ ->
# Not this key's, or not there: the same answer, so the ledger
# does not say whose a ref is.
send_error(conn, 404, "NOT_FOUND", %{issue: "No bedikah #{ref} in this key's ledger", correction: "Only examinations brought under this key are listed", resubmit: false})
end
else
send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "The ledger lives in L3 and L3 is not reachable", correction: "Retry", resubmit: true})
end
end
defp process_sanhedrin(conn) do
params = conn.body_params
@@ -1084,32 +1167,319 @@ defmodule VavPipeline.BigdeiKodesh.Router do
resubmit: true
})
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.
#
# And the case is RESOLVED before it is submitted — Construction
# §3.2: the pipeline hands the Sanhedrin roots and torah_refs. The
# question is heard the way pe'ah hears it (scope → verse → Shoresh
# Engine → roots); the complexity hint sets the panel; a panel
# spends one unit of the day's panel manna; the decision is
# engraved in L3; the answer takes the Tamid's shape.
with {:ok, sides} <- sanhedrin_sides(params["sides"]),
{:ok, complexity} <- sanhedrin_complexity(params["complexity_hint"]),
{:ok, scope, resolved_by} <- sanhedrin_scope(case_text),
{:ok, resolved} <- sanhedrin_resolve(case_text, scope, resolved_by),
:ok <- sanhedrin_gather_panel() do
case_ref = generate_id()
{:ok, decision} =
VavPipeline.SanhedrinClient.submit_case(%{
case_ref: case_ref,
complexity: :simple,
question: case_text,
context: params["sides"] || %{},
requested_by: "chatzer"
})
case VavPipeline.SanhedrinClient.submit_case(%{
case_ref: case_ref,
complexity: complexity,
question: case_text,
context: %{
sides: sides,
roots: resolved.roots,
torah_refs: [resolved.torah_ref],
authority_source: :torah
},
requested_by: "chatzer"
}) do
{:ok, decision} ->
recorded = sanhedrin_record_l3(case_ref, case_text, decision, resolved)
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, %{
case_ref: decision.case_ref,
deliberation: %{
panel_size: decision.panel_size,
majority: sanhedrin_side(decision.majority),
minority: sanhedrin_side(decision.minority),
outcome: decision.outcome,
processing_time_ms: decision.deliberation_time_ms
},
grounding: %{
scope: scope_report(scope, resolved_by),
roots_consulted: format_roots(resolved.roots_full),
recorded: recorded
},
note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.",
disclosure: disclosure()
})
{:error, "Halted" <> _ = reason} ->
send_error(conn, 422, "SANHEDRIN_HALTED", %{
issue: reason,
correction: "The case was refused at the gate before deliberation. Amend it and resubmit.",
resubmit: true
})
{:error, reason} ->
send_error(conn, 503, "SANHEDRIN_UNAVAILABLE", %{
issue: reason,
correction: "The Sanhedrin did not deliberate. Retry; if it recurs, the kohanim will find it in the log.",
resubmit: true
})
end
else
{:error, :sides, issue} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: issue,
correction: "'sides' is an optional list of {\"position\": string, \"evidence\": [string]}",
resubmit: true
})
{:error, :complexity, issue} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: issue,
correction: "'complexity_hint' is one of simple | monetary | capital | novel (panels of 1 | 3 | 23 | 71)",
resubmit: true
})
{:error, {:invalid_reference, ref}} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: "INVALID_SCOPE: #{ref} is not a verse in L1",
correction: "Name a verse that exists — chapter and verse within the five books.",
resubmit: true
})
{:waiting, reason} when reason in [:no_provision, :provision_exhausted] ->
send_error(conn, 503, "MANNA_DEPLETED", %{
issue: "No panel can be convened today: sanhedrin_panels_max is #{reason}",
correction: "Retry tomorrow, or raise MANNA_SANHEDRIN_PANELS_MAX in the ConfigMap",
resubmit: true
})
{:halted, what, reason} ->
send_error(conn, 503, "HALT_#{String.upcase(to_string(what))}", %{
issue: "The case could not be heard: #{inspect(reason)}",
correction: "Retry when the halt clears",
resubmit: true
})
{:transition, signal} ->
send_error(conn, 503, "CLOUD_MOVING", %{
issue: "The cloud is in transition (#{signal})",
correction: "Retry when the cloud settles",
resubmit: true
})
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, "'sides' must be a list of {position, evidence[]} objects"}
end
defp sanhedrin_sides(_), do: {:error, :sides, "'sides' must be a list"}
# The complexity hint names the panel (sanhedrin.hrl): absent → simple.
@sanhedrin_complexities %{"simple" => :simple, "monetary" => :monetary, "capital" => :capital, "novel" => :novel}
defp sanhedrin_complexity(nil), do: {:ok, :simple}
defp sanhedrin_complexity(hint) when is_binary(hint) do
case Map.fetch(@sanhedrin_complexities, hint) do
{:ok, c} -> {:ok, c}
:error -> {:error, :complexity, "'complexity_hint' #{inspect(hint)} is not one of simple | monetary | capital | novel"}
end
end
defp sanhedrin_complexity(other), do: {:error, :complexity, "'complexity_hint' must be a string, got #{inspect(other)}"}
# The scope: heard from the question (tablet 30), a named verse or a text
# match, else the default.
defp sanhedrin_scope(case_text), do: VavPipeline.ScopeResolver.resolve(case_text)
# The roots: the first two stages of the Bereishit cycle on the question —
# tohu (decompose) and vayomer (the Shoresh Engine over the verse in scope,
# or the question's own Hebrew). Nishma first: the Shema context is
# received, and the cloud is honoured, exactly as process/1 does.
defp sanhedrin_resolve(case_text, scope, resolved_by) do
case VavPipeline.Shema.receive_context(scope) do
{:error, reason} ->
{:halted, :context_failed, reason}
{:ok, context} ->
case context.cloud_state do
:camped ->
input = %{source: "chatzer", verse_scope: scope, scope_resolution: resolved_by, content: case_text}
state =
input
|> VavPipeline.BereishitCycle.tohu(context)
|> VavPipeline.BereishitCycle.vayomer(context)
roots_full = Map.get(state, :roots_activated, [])
{:ok,
%{
# The engine's verse facts name roots by letter — bet_resh_aleph —
# and json_bridge reads "bet-resh-aleph". The cycle's root carries
# the letter names beside the Hebrew; the Hebrew never matches
# (live, 2026-10-19: 5 roots sent, 0 verses found, "resolved").
roots: roots_full |> Enum.map(&VavPipeline.BereishitCycle.engine_root_name/1) |> Enum.reject(&is_nil/1),
roots_full: roots_full,
torah_ref: {String.to_atom(scope.book), scope.chapter, scope.verse},
read_from: Map.get(state, :roots_read_from)
}}
:moving ->
{:transition, :cloud_lifted}
{:major_transition, signal, _} ->
{:transition, signal}
other ->
{:transition, other}
end
end
end
# One panel per case from the day's manna (Vayakhel §3.7 — the ledger
# provisions sanhedrin_panels_max). The provision is loaded first, as
# Gate.gather_call does (idempotent per date). Without a ledger
# (dev/test) nothing is gathered.
defp sanhedrin_gather_panel do
if Process.whereis(VavPipeline.MannaLedger) do
case VavPipeline.MannaLedger.load_provision() do
{:ok, :no_provision} ->
{:waiting, :no_provision}
{:error, _} ->
{:waiting, :no_provision}
{:ok, _} ->
case VavPipeline.MannaLedger.consume(:sanhedrin_panels_max, 1) do
{:ok, _} -> :ok
{:error, :provision_exhausted} -> {:waiting, :provision_exhausted}
{:error, :no_provision} -> {:waiting, :no_provision}
end
end
else
:ok
end
end
# The Tamid's shape for a side: position, reasoning, sources, votes.
defp sanhedrin_side(side) when is_map(side) do
%{
position: Map.get(side, :position, Map.get(side, "position", "")),
reasoning: Map.get(side, :reasoning, Map.get(side, "reasoning", "")),
sources: side |> Map.get(:torah_refs, Map.get(side, "torah_refs", [])) |> Enum.map(&sanhedrin_ref_string/1),
votes: Map.get(side, :vote_count, Map.get(side, "vote_count", 0))
}
end
defp sanhedrin_side(_), do: %{position: "", reasoning: "", sources: [], votes: 0}
# #torah_ref{} arrives from Erlang as {:torah_ref, book, ch, vs}; a tuple
# {book, ch, vs} or a map may also appear.
defp sanhedrin_ref_string({:torah_ref, book, ch, vs}), do: "#{book} #{ch}:#{vs}"
defp sanhedrin_ref_string({book, ch, vs}), do: "#{book} #{ch}:#{vs}"
defp sanhedrin_ref_string(%{book: b, chapter: c, verse: v}), do: "#{b} #{c}:#{v}"
defp sanhedrin_ref_string(other), do: to_string(inspect(other))
# Devarim 17:9 — the inquiry is recorded or it did not accumulate. The
# decision goes to L3 as an interpretation (panel_size, votes for and
# against, processing_component "sanhedrin"), the minority beside it
# when it exists. Never turns a served decision into a 500: the record
# failing is logged and reported in the reply as `recorded`.
defp sanhedrin_record_l3(case_ref, case_text, decision, resolved) do
if Process.whereis(VavPipeline.Repo) do
maj = decision.majority
min = decision.minority
{book, ch, vs} = resolved.torah_ref
verse_s = "#{book} #{ch}:#{vs}"
coupling =
case resolved.roots do
[] -> ["coupled(case(#{case_ref}), verse(#{book}, #{ch}, #{vs}))"]
roots -> Enum.map(roots, fn r -> "coupled(root(#{r}), verse(#{book}, #{ch}, #{vs}))" end)
end
attrs = %{
case_ref: case_ref,
interpretation: to_string(Map.get(maj, :position, "")),
status: to_string(decision.outcome),
reasoning: "#{case_text} — #{Map.get(maj, :reasoning, "")}",
torah_references: Enum.uniq([verse_s | Enum.map(Map.get(maj, :torah_refs, []), &sanhedrin_ref_string/1)]),
roots_involved: Enum.map(resolved.roots, &to_string/1),
panel_size: decision.panel_size,
vote_count_for: Map.get(maj, :vote_count, 0),
vote_count_against: Map.get(min, :vote_count, 0),
processing_component: "sanhedrin",
coupling_trace: coupling
}
try do
case VavPipeline.L3.Store.record_interpretation(attrs) do
{:ok, interp} ->
Logger.info("L3: interpretation #{interp.id} recorded for sanhedrin #{case_ref} (#{decision.outcome})")
minority_recorded =
if Map.get(min, :vote_count, 0) > 0 do
case VavPipeline.L3.Store.record_minority(%{
interpretation_id: interp.id,
dissenting_reasoning: "#{Map.get(min, :position, "")} — #{Map.get(min, :reasoning, "")}",
torah_references: Enum.map(Map.get(min, :torah_refs, []), &sanhedrin_ref_string/1),
grounding_type: "achorai"
}) do
{:ok, _} ->
"recorded"
{:error, reason} ->
Logger.error("L3: sanhedrin #{case_ref} minority NOT recorded: #{inspect(reason)}")
"failed"
end
else
"none"
end
%{l3: "recorded", interpretation_id: interp.id, minority: minority_recorded}
{:error, reason} ->
Logger.error("L3: sanhedrin #{case_ref} NOT recorded: #{inspect(reason)}")
%{l3: "failed", reason: inspect(reason)}
end
rescue
e ->
Logger.error("L3: sanhedrin #{case_ref} NOT recorded: #{Exception.message(e)}")
%{l3: "failed", reason: Exception.message(e)}
end
else
%{l3: "absent"}
end
end
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
# ============================================================
+2 -1
View File
@@ -149,6 +149,7 @@ defmodule VavPipeline.Doorpost do
"specs/lo_tosif.md",
"specs/return_specification.md",
"specs/structure_not_content.md",
"specs/sugya.md",
"specs/threshold_inscription.md",
"talmudic_engine/src/halt/anchors.pl",
"talmudic_engine/src/halt/avodah_zarah.pl",
@@ -180,7 +181,7 @@ defmodule VavPipeline.Doorpost do
# changes; the update is a deliberate operator commit. A bare
# hash-update commit (without a corresponding spec change in the
# same commit) is itself a drift signal.
@expected_hash "66aa7874fae4875793791ce6ca7bbe55302109d5e4444bcb36d578dfe6e98a0d"
@expected_hash "7adf0a5dfd8d56382219c3f9d2f6ac3ccd5d681361e6bd5124688eb9cdffffa6"
@doc "Return the list of files that constitute the category-grade specification."
@spec files() :: [String.t()]
+11 -1
View File
@@ -52,9 +52,10 @@ defmodule VavPipeline.Gate.Shape do
# sources pass the cap — a coding agent's conversation runs to megabytes.
@max_source_bytes 1_048_576
# A "tool" turn (OpenAI) is what a tool returned to the model: given, a source (tablet 58).
defp user_sources(messages, text_fun) do
messages
|> Enum.filter(&(&1["role"] == "user"))
|> Enum.filter(&(&1["role"] in ["user", "tool"]))
|> Enum.with_index(1)
|> Enum.flat_map(fn {m, i} -> text_fun.(m) |> wrap("user_#{i}") end)
|> Enum.reverse()
@@ -93,6 +94,11 @@ defmodule VavPipeline.Gate.Shape do
%{"type" => "text", "text" => t} when is_binary(t) -> [t]
%{"type" => "document", "source" => %{"type" => "text", "data" => t}} when is_binary(t) -> [t]
%{"type" => "document", "source" => %{"type" => "content", "content" => inner}} when is_list(inner) -> [text_of_blocks(inner)]
# Tablet 58: what a tool returned to the model was GIVEN to it
# (tablet 49) — a file it read, a reply from a service, the scroll
# it consulted. A claim that rests on it rests on a source.
%{"type" => "tool_result", "content" => c} when is_binary(c) -> [c]
%{"type" => "tool_result", "content" => inner} when is_list(inner) -> [text_of_blocks(inner)]
_ -> []
end)
|> Enum.join("\n")
@@ -247,6 +253,10 @@ defmodule VavPipeline.Gate.Shape do
body =
case result.verdict do
:lo_tov -> " — " <> (fired(result, anchors) |> Enum.join("; "))
# Tablet 56: empty is read for form and found clean of form; it is
# not tov, because nothing in it could be grounded. The reader is
# told which.
:empty -> " — read for form, nothing found; nothing to ground"
_ -> ""
end
@@ -37,6 +37,8 @@ defmodule VavPipeline.L3.BedikahRecord do
field :violations, {:array, :string}, default: []
field :verdict, :string
field :examined_at, :utc_datetime
# The ledger: a digest of the courtyard key that brought the offering (VavPipeline.Ledger.key_id/1). Never the key.
field :key_id, :string
timestamps(type: :utc_datetime, updated_at: false)
end
@@ -46,7 +48,7 @@ defmodule VavPipeline.L3.BedikahRecord do
# offering protocol extracted nothing, and that record must be kept.
# Ecto's cast turns "" into nil and validate_required calls "" blank;
# both are told otherwise here.
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :rebukes, :mesorah, :coupling_trace, :violations]
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :rebukes, :mesorah, :coupling_trace, :violations, :key_id]
def changeset(record, attrs) do
record
+21
View File
@@ -75,6 +75,27 @@ defmodule VavPipeline.L3.Store do
|> Repo.insert()
end
@doc "One bedikah by its ref, or nil."
def get_bedikah(ref) when is_binary(ref) do
Repo.get_by(VavPipeline.L3.BedikahRecord, bedikah_ref: ref)
end
@doc """
The ledger of one courtyard key: its bedikot newest first, at most
`limit`, examined at or before `before` when given. A record without a
key_id (from before the ledger) belongs to no one and is not listed.
"""
def list_bedikot(key_id, opts \\ []) when is_binary(key_id) do
import Ecto.Query, only: [from: 2]
limit = opts |> Keyword.get(:limit, 50) |> min(200) |> max(1)
q = from b in VavPipeline.L3.BedikahRecord, where: b.key_id == ^key_id, order_by: [desc: b.examined_at, desc: b.id], limit: ^limit
q = case Keyword.get(opts, :before) do
%DateTime{} = t -> from b in q, where: b.examined_at <= ^t
_ -> q
end
Repo.all(q)
end
@doc """
Record an examination of an external model's output (Construction §3.9).
"""
+140
View File
@@ -0,0 +1,140 @@
defmodule VavPipeline.Ledger do
@moduledoc """
The provenance ledger — a bedikah record as something its caller can
carry away and have checked by someone else.
Every bedikah is engraved in L3 with the output's hash and length, the
source digests, the coupling trace, the claims, the violations and the
verdict — never the text (Tamid §1.1). Until 2026-10-21 the caller had
no way back to it. Now a record is read back under the courtyard key
that brought the offering (`key_id`: a digest of the key, never the
key), rendered in one canonical form, and signed with the system's
Ed25519 key, whose public half anyone may fetch at `/peah/ledger_key`.
A regulator, a client or an audit can verify a record without asking
Bikoret: the canonical bytes, the signature, the public key.
Devarim 19:15 — a matter is established by witnesses. The ledger is
the witness that does not depend on the one who offered, and the
signature is what lets a third party trust the witness.
"""
@doc "A digest of a courtyard key, for the record. The key itself is never written."
@spec key_id(String.t() | nil) :: String.t() | nil
def key_id(nil), do: nil
def key_id(key) when is_binary(key),
do: :crypto.hash(:sha256, key) |> Base.encode16(case: :lower) |> binary_part(0, 32)
@doc """
The record's fields that a verifier signs and checks — every field of
the examination, none of the database's own (id, inserted_at), and not
the key_id (whose key it was is the caller's business, not the record's).
"""
@ledger_fields ~w(bedikah_ref provenance query output_sha256 output_length vital_content propositions references authority_claims actions sources claims structure frames rebukes mesorah coupling_trace violations verdict examined_at)a
@spec ledger_fields() :: [atom()]
def ledger_fields, do: @ledger_fields
@doc "The record as a plain map of its ledger fields, dates in ISO 8601."
@spec entry(struct() | map()) :: map()
def entry(record) do
record
|> unstruct()
|> Map.take(@ledger_fields)
|> Map.new(fn
{k, %DateTime{} = dt} -> {k, DateTime.to_iso8601(dt)}
{k, v} -> {k, v}
end)
end
defp unstruct(%_{} = s), do: Map.from_struct(s)
defp unstruct(m), do: m
@doc """
The canonical bytes of an entry: JSON with every object's keys sorted,
no insignificant whitespace, UTF-8. Two implementations that agree on
this agree on the signature.
"""
@spec canonical(map()) :: binary()
def canonical(entry) when is_map(entry), do: entry |> sort_keys() |> Jason.encode!()
defp sort_keys(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp sort_keys(map) when is_map(map) and not is_struct(map) do
map
|> Enum.map(fn {k, v} -> {to_string(k), sort_keys(v)} end)
|> Enum.sort_by(fn {k, _} -> k end)
|> Jason.OrderedObject.new()
end
defp sort_keys(list) when is_list(list), do: Enum.map(list, &sort_keys/1)
defp sort_keys(other), do: other
@doc """
Sign canonical bytes with the system's Ed25519 key. `nil` when no key
is configured — the record is still given, marked unsigned; nothing is
signed with a key made up on the spot.
"""
@spec sign(binary(), binary() | nil) :: binary() | nil
def sign(bytes, seed \\ seed())
def sign(_bytes, nil), do: nil
def sign(bytes, seed) when is_binary(seed) and byte_size(seed) == 32 do
:crypto.sign(:eddsa, :none, bytes, [seed, :ed25519]) |> Base.encode64()
end
@doc "Verify a base64 signature over canonical bytes with a base64 public key."
@spec verify(binary(), String.t(), String.t()) :: boolean()
def verify(bytes, signature_b64, public_key_b64) do
with {:ok, sig} <- Base.decode64(signature_b64),
{:ok, pub} <- Base.decode64(public_key_b64) do
:crypto.verify(:eddsa, :none, bytes, sig, [pub, :ed25519])
else
_ -> false
end
end
@doc "The public key (base64) a verifier uses, or nil when the ledger is unsigned."
@spec public_key(binary() | nil) :: String.t() | nil
def public_key(seed \\ seed())
def public_key(nil), do: nil
def public_key(seed) when is_binary(seed) and byte_size(seed) == 32 do
{pub, _priv} = :crypto.generate_key(:eddsa, :ed25519, seed)
Base.encode64(pub)
end
@doc """
A record rendered for the caller: the entry, its canonical form's
SHA-256 (so the caller can confirm what was signed without re-encoding),
the signature, and how to verify.
"""
@spec render(struct() | map()) :: map()
def render(record) do
e = entry(record)
bytes = canonical(e)
sig = sign(bytes)
%{
entry: e,
canonical_sha256: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower),
signature: sig,
signed: sig != nil,
algorithm: "ed25519 over the canonical JSON of `entry` (keys sorted at every level, no whitespace, UTF-8)",
public_key: "GET /peah/ledger_key"
}
end
# The 32-byte seed from the environment (base64), or nil.
defp seed do
case Application.get_env(:vav_pipeline, :ledger_signing_key) do
nil -> nil
"" -> nil
b64 ->
case Base.decode64(String.trim(b64)) do
{:ok, s} when byte_size(s) == 32 -> s
_ -> nil
end
end
end
end
@@ -0,0 +1,102 @@
defmodule VavPipeline.SanhedrinConsult do
@moduledoc """
What a judge consults (Architecture §3.2: "consults the specification
(L0/L1)"). Registered in the `sanhedrin` application env as
`{__MODULE__, :consult}` at pipeline start; `judge.erl` calls it once
per vote with the step its seat performs — `:interpret`, `:challenge`
or `:resolve` — and the case's roots and question.
Tablet 55: before this the judge chose among six canned positions by a
hash of the clock. Now the only ground a vote can stand on is the
Talmudic Engine's answer over the case's roots — the same engine, the
same rules, the same verses the bedikah and the Bereishit cycle use.
When the engine's socket is not running, or answers with an error, the
judge is told `{:error, reason}` and abstains. Nothing here is answered
from a stub (tablet 41): `TalmudicEngineClient`'s stub fallbacks are
deliberately NOT used — a court that accepts on a stub is the calf.
"""
require Logger
alias VavPipeline.TalmudicEngineSocket
@books ~w(bereishit shemot vayikra bamidbar devarim)a
@typedoc "What the judge receives: a status the engine gave, the reading, its verses."
@type consultation :: %{
required(:status) => String.t(),
required(:text) => String.t(),
required(:verses) => [{atom(), pos_integer(), pos_integer()}],
optional(:kushya) => String.t(),
optional(:grounding) => [String.t()]
}
@doc """
Called from the judge (Erlang): `consult(Step, CaseRef, Roots, Question, Context)`.
Roots are the case's shoresh names as the cycle produces them; the
Erlang side hands binaries or charlists — both are accepted.
"""
@spec consult(:interpret | :challenge | :resolve, binary() | charlist(), list(), binary() | charlist(), map()) ::
{:ok, consultation()} | {:error, term()}
def consult(step, case_ref, roots, question, _context) when step in [:interpret, :challenge, :resolve] do
if Process.whereis(TalmudicEngineSocket) do
ctx = %{
context_roots: Enum.map(roots, &to_string/1),
question: to_string(question)
}
ref = "#{to_string(case_ref)}-#{step}"
case apply(TalmudicEngineSocket, step, [ref, ctx]) do
{:ok, %{"status" => status} = reply} when is_binary(status) ->
{:ok, normalize(status, reply)}
{:ok, other} ->
{:error, {:unexpected_reply, other}}
{:error, reason} ->
Logger.warning("SanhedrinConsult: engine #{step} failed for #{ref}: #{inspect(reason)}")
{:error, {:engine_unavailable, reason}}
end
else
{:error, :engine_socket_not_started}
end
end
def consult(step, _ref, _roots, _q, _ctx), do: {:error, {:unknown_step, step}}
@doc "Parse the engine's verse strings (`verse_ref(bereishit,1,1,...)`, `verse(shemot,3,14)`) into `{book, ch, vs}`."
@spec verses_from([String.t()]) :: [{atom(), pos_integer(), pos_integer()}]
def verses_from(strings) when is_list(strings) do
strings
|> Enum.flat_map(fn s ->
case Regex.run(~r/verse(?:_ref)?\((bereishit|shemot|vayikra|bamidbar|devarim)\s*,\s*(\d+)\s*,\s*(\d+)/, to_string(s)) do
[_, book, ch, vs] -> [{String.to_existing_atom(book), String.to_integer(ch), String.to_integer(vs)}]
_ -> []
end
end)
|> Enum.uniq()
end
def verses_from(_), do: []
@doc false
def books, do: @books
defp normalize(status, reply) do
interp = Map.get(reply, "interpretation") || %{}
chall = Map.get(reply, "challenge") || %{}
base = %{
status: status,
text: to_string(Map.get(interp, "text", "")),
verses: verses_from(List.wrap(Map.get(interp, "verses", []))),
grounding: List.wrap(Map.get(interp, "grounding", [])) |> Enum.map(&to_string/1)
}
case Map.get(chall, "kushya") do
k when is_binary(k) and k != "" -> Map.put(base, :kushya, k)
_ -> base
end
end
end
@@ -0,0 +1,18 @@
defmodule VavPipeline.Repo.Migrations.BedikotKeyId do
use Ecto.Migration
@moduledoc """
The ledger (2026-10-21): a bedikah remembers WHICH courtyard key brought
the offering — as a digest of the key, never the key — so a caller can
read back their own records and no one else's.
"""
def change do
alter table(:bedikot) do
add :key_id, :string, null: true
end
create index(:bedikot, [:key_id, :examined_at])
create unique_index(:bedikot, [:bedikah_ref], name: :bedikot_bedikah_ref_unique)
end
end
+5 -1
View File
@@ -556,7 +556,11 @@ defmodule VavPipeline.BedikahTest do
assert {:ok, r} = Bedikah.examine("Great question!", "gpt", "", opts(:empty))
assert r.verdict == :empty
assert r.violations == []
assert r.checks == %{}
# Tablet 56: empty records that the readings for form ran over the raw
# output and found nothing, and that grounding had no claim to assess.
assert r.checks.read_for_form.found == "nothing"
assert "frames" in r.checks.read_for_form.readings
assert r.checks.grounding.assessed == false
end
test "the record carries the hash and length of the output, not the output" do
+198
View File
@@ -21,6 +21,38 @@ defmodule VavPipeline.BigdeiKodeshTest do
@opts Router.init([])
# Tablet 55: a consultation the judges can be pointed at (judge.erl reads
# {M, F} from the sanhedrin env). Seat 2 (challenge) finds a kushya; the
# other seats read the verse as resolved.
def fake_consult(:challenge, _ref, _roots, _q, _ctx),
do: {:ok, %{status: "challenged", text: "", kushya: "but the verse says otherwise", verses: [{:devarim, 6, 4}]}}
def fake_consult(_step, _ref, _roots, _q, _ctx),
do: {:ok, %{status: "resolved", text: "the reading", verses: [{:bereishit, 1, 1}]}}
# A day's manna for the chatzer tests: a panel spends one unit of
# sanhedrin_panels_max (tablet 55), so the ledger must hold a provision.
defp write_provision(panels) do
VavPipeline.MannaLedger.reset()
on_exit(fn -> VavPipeline.MannaLedger.reset() end)
today = Date.utc_today() |> Date.to_iso8601()
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
provision = %{
"date" => today,
"provision" => %{"compute_units" => 50, "memory_mb" => 512, "api_calls_external" => 10, "storage_delta_mb" => 2, "sanhedrin_panels_max" => panels},
"type" => "regular",
"expires_at" => expires,
"written_by" => "bigdei_kodesh_test",
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
path = VavPipeline.Manna.provision_file()
File.mkdir_p!(Path.dirname(path))
File.write!(path, Jason.encode!(provision))
on_exit(fn -> File.rm(path) end)
end
# ============================================================
# Pe'ah — Public Endpoint (Machaneh Yisrael)
# ============================================================
@@ -301,6 +333,8 @@ defmodule VavPipeline.BigdeiKodeshTest do
end
test "POST /chatzer/sanhedrin with valid case deliberates" do
write_provision(2)
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
case: "Is cooking on Shabbat permitted for pikuach nefesh?"
@@ -317,6 +351,170 @@ defmodule VavPipeline.BigdeiKodeshTest do
assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"]
assert body["note"] =~ "not a binding ruling"
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
write_provision(2)
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"]
# the hint names the panel: monetary → three judges (sanhedrin.hrl)
assert body["deliberation"]["panel_size"] == 3
# the Tamid's shape — votes and sources, not vote_count and torah_refs
assert Map.keys(body["deliberation"]["majority"]) |> Enum.sort() == ["position", "reasoning", "sources", "votes"]
assert is_list(body["deliberation"]["majority"]["sources"])
# the case was resolved before it was heard
assert is_map(body["grounding"]["scope"])
assert is_list(body["grounding"]["roots_consulted"])
# and the gate is still standing afterwards
assert Process.whereis(:halt_monitor) != nil
assert Process.whereis(:escalation) != nil
end
# Tablet 55: without a Talmudic Engine to consult, the judges abstain
# and the court says teiku — it does not accept on nothing.
test "POST /chatzer/sanhedrin with no engine to consult is teiku, not accepted" do
# The socket process may run in the test env; no engine answers behind it.
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
write_provision(2)
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "bereishit 1:1 — what is created first?"}))
|> 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"] == "teiku"
assert body["deliberation"]["panel_size"] == 1
# the one judge abstained: an abstaining majority of one, no dissent
assert body["deliberation"]["majority"]["votes"] == 1
assert body["deliberation"]["majority"]["reasoning"] =~ "no consultation"
assert body["deliberation"]["minority"]["votes"] == 0
end
# Tablet 55: with a consultation behind the judges, the dialectic
# decides — interpret resolved, challenge finds a kushya, resolve
# resolved → 2 accept, 1 reject; the minority preserved with its verse.
test "POST /chatzer/sanhedrin monetary panel splits 2–1 from the dialectic" do
Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult})
write_provision(2)
try do
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{
case: "devarim 6:4 — is the unity declared or commanded?",
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
d = Jason.decode!(conn.resp_body)["deliberation"]
assert d["panel_size"] == 3
assert d["outcome"] == "accepted"
assert d["majority"]["votes"] == 2
assert d["majority"]["position"] == "the reading"
assert d["majority"]["sources"] == ["bereishit 1:1"]
assert d["minority"]["votes"] == 1
assert d["minority"]["position"] == "but the verse says otherwise"
assert d["minority"]["sources"] == ["devarim 6:4"]
after
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
end
end
# Devarim 17:9 — the decision is engraved in L3: an interpretation row
# with the panel and the votes, and the minority beside it.
@tag :l3
test "POST /chatzer/sanhedrin engraves the decision and the minority in L3" do
# The record is written from the request process (the router), so a
# checkout here covers it; the sandbox is shared for the sake of the
# panel's processes, which do not touch the Repo.
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, {:shared, self()})
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) end)
Application.put_env(:sanhedrin, :consult, {__MODULE__, :fake_consult})
write_provision(2)
try do
before = VavPipeline.L3.Store.count_interpretations()
before_min = VavPipeline.L3.Store.count_minority_opinions()
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "devarim 6:4", 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["grounding"]["recorded"]["l3"] == "recorded"
assert VavPipeline.L3.Store.count_interpretations() == before + 1
assert VavPipeline.L3.Store.count_minority_opinions() == before_min + 1
after
Application.put_env(:sanhedrin, :consult, {VavPipeline.SanhedrinConsult, :consult})
end
end
# A panel is manna (Vayakhel §3.7): the day's sanhedrin_panels_max spent → 503.
test "POST /chatzer/sanhedrin with the day's panels spent returns 503 MANNA_DEPLETED" do
write_provision(1)
body = Jason.encode!(%{case: "bereishit 1:1"})
post = fn ->
conn(:post, "/chatzer/sanhedrin", body)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
end
assert post.().status == 200
second = post.()
assert second.status == 503
assert Jason.decode!(second.resp_body)["error"]["code"] == "MANNA_DEPLETED"
end
test "POST /chatzer/sanhedrin with an unknown complexity_hint returns 422" do
conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", complexity_hint: "cosmic"}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts)
assert conn.status == 422
assert Jason.decode!(conn.resp_body)["error"]["guidance"]["issue"] =~ "complexity_hint"
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
# ============================================================
+43
View File
@@ -248,6 +248,37 @@ defmodule VavPipeline.GateTest do
# The shape
# ------------------------------------------------------------
# Tablet 58: what a tool returned to the model was given to it — a source.
test "tool results are sources: a user turn's tool_result blocks (Anthropic) and a tool turn (OpenAI)" do
anthropic = %{
"system" => "S",
"messages" => [
%{"role" => "user", "content" => "Read the file."},
%{"role" => "assistant", "content" => [%{"type" => "tool_use", "id" => "t1", "name" => "read", "input" => %{}}]},
%{"role" => "user", "content" => [
%{"type" => "tool_result", "tool_use_id" => "t1", "content" => "commit dc938058 on main"},
%{"type" => "tool_result", "tool_use_id" => "t2", "content" => [%{"type" => "text", "text" => "Eduyot 1:5: why the minority is recorded"}]}
]},
%{"role" => "user", "content" => "What did you find?"}
]
}
assert [%{id: "system", text: "S"}, %{id: "user_1", text: "Read the file."},
%{id: "user_2", text: "commit dc938058 on main\nEduyot 1:5: why the minority is recorded"},
%{id: "user_3", text: "What did you find?"}] = Shape.sources(:anthropic, anthropic)
assert Shape.query(:anthropic, anthropic) == "What did you find?"
openai = %{"messages" => [
%{"role" => "user", "content" => "Read it."},
%{"role" => "assistant", "content" => nil, "tool_calls" => [%{"id" => "c1"}]},
%{"role" => "tool", "tool_call_id" => "c1", "content" => "commit dc938058 on main"},
%{"role" => "user", "content" => "And?"}
]}
assert [%{id: "user_1", text: "Read it."}, %{id: "user_2", text: "commit dc938058 on main"}, %{id: "user_3", text: "And?"}] = Shape.sources(:openai, openai)
# the query is still the last USER turn, not the tool's
assert Shape.query(:openai, openai) == "And?"
end
test "sources are the system prompt and the user turns, not the assistant's; the query is the last user turn" do
assert [%{id: "system", text: "Answer from the report."}, %{id: "user_1", text: "Here is the report: revenue rose 4% in Q3."}, %{id: "user_2", text: "What happened to revenue?"}] =
Shape.sources(:anthropic, body())
@@ -407,6 +438,18 @@ defmodule VavPipeline.GateTest do
Gate.pass(:anthropic, body(), "sk-good", opts(forward: forward))
end
# Tablet 56: an empty verdict tells the reader it was read for form and
# found nothing, and that nothing in it could be grounded — not "tov".
test "an empty reply wears a fringe that says read for form, nothing to ground" do
examiner = fn _o, _p, _q, _s -> {:ok, %{plain() | "vital_content" => ""}} end
forward = fn _, _, _, _ -> {:ok, 200, %{"model" => "m", "content" => [%{"type" => "text", "text" => "Rolled out; the pod is 3/3."}]}} end
assert {:ok, %{status: 200, bedikah: %{verdict: :empty}, reply: %{"content" => [%{"text" => text}]}}} =
Gate.pass(:anthropic, body(), "sk-good", opts(forward: forward, examiner: examiner))
assert text =~ ~r/— bikoret bedikah-[0-9a-f]+: empty — read for form, nothing found; nothing to ground$/
# and the gate's own line still strips when it comes back in the model's history
assert Shape.strip_forged(text) == "Rolled out; the pod is 3/3."
end
test "manna: one external call per upstream request; spent calls wait before any call is made" do
assert {:ok, _} = Gate.pass(:anthropic, body(), "sk-good", opts(rebuke: :once))
assert MannaLedger.remaining(:api_calls_external) == 8
+109
View File
@@ -0,0 +1,109 @@
defmodule VavPipeline.LedgerL3Test do
@moduledoc "The ledger over L3: a key reads its own bedikot back, signed, and no one else's (2026-10-21)."
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias VavPipeline.BigdeiKodesh.Router
alias VavPipeline.L3.Store
@opts Router.init([])
@moduletag :l3
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, {:shared, self()})
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) end)
write_provision()
:ok
end
defp write_provision do
VavPipeline.MannaLedger.reset()
on_exit(fn -> VavPipeline.MannaLedger.reset() end)
today = Date.utc_today() |> Date.to_iso8601()
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
provision = %{"date" => today, "provision" => %{"compute_units" => 50, "memory_mb" => 512, "api_calls_external" => 10, "storage_delta_mb" => 2, "sanhedrin_panels_max" => 2},
"type" => "regular", "expires_at" => expires, "written_by" => "ledger_l3_test", "written_at" => DateTime.utc_now() |> DateTime.to_iso8601()}
path = VavPipeline.Manna.provision_file()
File.mkdir_p!(Path.dirname(path))
File.write!(path, Jason.encode!(provision))
on_exit(fn -> File.rm(path) end)
end
defp attrs(ref, key_id) do
%{
bedikah_ref: ref, provenance: "claude", query: "q", output_sha256: String.duplicate("cd", 32), output_length: 5,
vital_content: "", verdict: "empty", examined_at: DateTime.utc_now() |> DateTime.truncate(:second), key_id: key_id
}
end
defp get(path, token) do
conn(:get, path) |> put_req_header("authorization", "Bearer #{token}") |> Router.call(@opts)
end
# An examination the offering protocol finds nothing in: the :empty branch, no engine needed.
defp empty_examination do
%{"provenance" => "claude", "query" => "status?", "vital_content" => "", "propositions" => [], "discarded" => ["Rolled out."],
"references" => [], "hebrew" => [], "roots" => [], "authority_claims" => [], "actions" => [],
"self_validation" => [], "self_promotion" => [], "excluded_adversarial" => [], "frames" => [], "sources" => [], "claims" => []}
end
test "the record keeps the key's digest, and the ledger lists only that key's records, newest first" do
mine = VavPipeline.Ledger.key_id("test-token")
other = VavPipeline.Ledger.key_id("someone-else")
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3aaaa0000000001", mine))
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3aaaa0000000002", mine))
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3bbbb0000000003", other))
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3cccc0000000004", nil))
refs = Store.list_bedikot(mine) |> Enum.map(& &1.bedikah_ref)
assert refs == ["bedikah-l3aaaa0000000002", "bedikah-l3aaaa0000000001"]
conn = get("/courtyard/ledger?limit=10", "test-token")
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["key_id"] == mine
assert body["count"] == 2
assert Enum.map(body["entries"], & &1["entry"]["bedikah_ref"]) == ["bedikah-l3aaaa0000000002", "bedikah-l3aaaa0000000001"]
# never the text, never the key, never the owner column
e = hd(body["entries"])["entry"]
refute Map.has_key?(e, "key_id")
assert e["output_sha256"] == String.duplicate("cd", 32)
assert hd(body["entries"])["signed"] == false
end
test "one record by ref: mine answers, another key's and a missing one answer 404 alike" do
mine = VavPipeline.Ledger.key_id("test-token")
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3dddd0000000005", mine))
{:ok, _} = Store.record_bedikah(attrs("bedikah-l3eeee0000000006", VavPipeline.Ledger.key_id("someone-else")))
assert get("/courtyard/bedikah/bedikah-l3dddd0000000005", "test-token").status == 200
assert get("/courtyard/bedikah/bedikah-l3eeee0000000006", "test-token").status == 404
assert get("/courtyard/bedikah/bedikah-l3ffff0000000007", "test-token").status == 404
end
test "an examination is engraved under the caller's key and reads back signed and verifiable" do
Application.put_env(:vav_pipeline, :ledger_signing_key, Base.encode64(:crypto.strong_rand_bytes(32)))
mine = VavPipeline.Ledger.key_id("test-token")
try do
examiner = fn _o, _p, _q, _s -> {:ok, empty_examination()} end
assert {:ok, r} = VavPipeline.Bedikah.examine("Rolled out.", "claude", "status?", examiner: examiner, key_id: mine)
assert r.verdict == :empty
assert Store.get_bedikah(r.bedikah_ref).key_id == mine
back = get("/courtyard/bedikah/#{r.bedikah_ref}", "test-token")
assert back.status == 200
body = Jason.decode!(back.resp_body)
assert body["signed"] == true
pub = Jason.decode!(get("/peah/ledger_key", "x").resp_body)["public_key"]
canonical = VavPipeline.Ledger.canonical(body["entry"])
assert :crypto.hash(:sha256, canonical) |> Base.encode16(case: :lower) == body["canonical_sha256"]
assert VavPipeline.Ledger.verify(canonical, body["signature"], pub)
after
Application.delete_env(:vav_pipeline, :ledger_signing_key)
end
end
end
+97
View File
@@ -0,0 +1,97 @@
defmodule VavPipeline.LedgerTest do
@moduledoc "The provenance ledger: canonical form, signature, key digest (2026-10-21)."
use ExUnit.Case, async: true
alias VavPipeline.Ledger
@seed :crypto.strong_rand_bytes(32)
defp record do
%VavPipeline.L3.BedikahRecord{
id: 7,
bedikah_ref: "bedikah-0123456789abcdef",
provenance: "claude",
query: "q",
output_sha256: String.duplicate("ab", 32),
output_length: 12,
vital_content: "",
propositions: [],
references: ["Devarim 6:4"],
authority_claims: [],
actions: [],
sources: [%{"id" => "system", "sha256" => "00", "length" => 2}],
claims: [%{"index" => 1, "sentence" => "x", "anchors" => []}],
structure: %{"self_validation" => []},
frames: [],
rebukes: nil,
mesorah: [],
coupling_trace: [],
violations: [],
verdict: "tov",
examined_at: ~U[2026-10-21 12:00:00Z],
key_id: "deadbeef",
inserted_at: ~U[2026-10-21 12:00:01Z]
}
end
test "key_id is a digest of the key, never the key, and nil for none" do
assert Ledger.key_id(nil) == nil
id = Ledger.key_id("courtyard-secret-key")
assert String.length(id) == 32
refute id =~ "courtyard"
assert id == Ledger.key_id("courtyard-secret-key")
refute id == Ledger.key_id("another")
end
test "the entry carries the examination's fields and neither the database's nor the owner's" do
e = Ledger.entry(record())
assert Map.keys(e) |> Enum.sort() == Enum.sort(Ledger.ledger_fields())
refute Map.has_key?(e, :id)
refute Map.has_key?(e, :key_id)
refute Map.has_key?(e, :inserted_at)
assert e.examined_at == "2026-10-21T12:00:00Z"
end
test "canonical form sorts keys at every level and carries no whitespace" do
a = Ledger.canonical(%{"b" => 1, "a" => %{"z" => [%{"y" => 2, "x" => 1}], "m" => "s"}})
assert a == ~s({"a":{"m":"s","z":[{"x":1,"y":2}]},"b":1})
# the same map with keys given in another order canonicalizes the same
assert a == Ledger.canonical(%{"a" => %{"m" => "s", "z" => [%{"x" => 1, "y" => 2}]}, "b" => 1})
# atom and string keys meet in the same place
assert Ledger.canonical(%{b: 1, a: 2}) == ~s({"a":2,"b":1})
end
test "a signature verifies with the public key and fails on one changed byte" do
bytes = Ledger.canonical(Ledger.entry(record()))
sig = Ledger.sign(bytes, @seed)
pub = Ledger.public_key(@seed)
assert Ledger.verify(bytes, sig, pub)
refute Ledger.verify(bytes <> " ", sig, pub)
refute Ledger.verify(bytes, sig, Ledger.public_key(:crypto.strong_rand_bytes(32)))
refute Ledger.verify(bytes, "not base64!", pub)
end
test "without a seed nothing is signed, and the rendering says so" do
assert Ledger.sign("x", nil) == nil
assert Ledger.public_key(nil) == nil
r = Ledger.render(record())
assert r.entry.bedikah_ref == "bedikah-0123456789abcdef"
assert String.length(r.canonical_sha256) == 64
# the test env has no signing key configured
assert r.signed == false and r.signature == nil
end
test "a rendering with a seed configured is signed and self-consistent" do
Application.put_env(:vav_pipeline, :ledger_signing_key, Base.encode64(@seed))
try do
r = Ledger.render(record())
assert r.signed
bytes = Ledger.canonical(r.entry)
assert :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) == r.canonical_sha256
assert Ledger.verify(bytes, r.signature, Ledger.public_key())
after
Application.delete_env(:vav_pipeline, :ledger_signing_key)
end
end
end
@@ -0,0 +1,27 @@
defmodule VavPipeline.Tablet55EngineRootNameTest do
@moduledoc """
Tablet 55: the Talmudic Engine names roots by letter (bet_resh_aleph,
read from "bet-resh-aleph"). The pipeline was sending it the Hebrew,
which its verse facts never match — roots in, no verses out, and a
"resolved" reading over the emptiness.
"""
use ExUnit.Case, async: true
alias VavPipeline.BereishitCycle
test "from the Shoresh Engine's letter names" do
assert BereishitCycle.engine_root_name(%{shoresh: "ברא", shoresh_letters: ["bet", "resh", "aleph"]}) ==
"bet-resh-aleph"
end
test "from the Hebrew alone (the L1 fallback), finals folded" do
assert BereishitCycle.engine_root_name(%{shoresh: "דבר", shoresh_letters: []}) == "dalet-bet-resh"
assert BereishitCycle.engine_root_name(%{shoresh: "מלך"}) == "mem-lamed-kaf"
end
test "nothing nameable → nil, never a Hebrew string the engine cannot read" do
assert BereishitCycle.engine_root_name(%{shoresh: nil}) == nil
assert BereishitCycle.engine_root_name(%{shoresh: "x1"}) == nil
assert BereishitCycle.engine_root_name(%{}) == nil
end
end