כי יפלא ממך דבר למשפט ... ובין נגע לנגע דברי ריבת בשעריך וקמת ועלית (Devarim 17:8, read from L1). A mark the inspection cannot settle, a dispute in your gates — it goes up. specs/sugya.md §V step 4: a bedikah's applied dissents become the minority the clerk stores when the case goes up. POST /chatzer/sanhedrin takes `bedikah_ref`. Only this key's own bedikah goes up (404 alike for another's or none). The case is that verdict; the roots are the verses the output cited, read from L1 — the output's text is not re-read, the record never kept it; the sugya is rebuilt from the record (Sugya.from_record/1: forms found but not fired are the noted ones; a fired forged fringe stood on one witness); the applied dissents (Sugya.dissents_applied/1) travel in the case context and down into L3: beside the panel's minority, or AS the minority when the panel was unanimous — "the panel was unanimous | carried up from the bedikah (Devarim 17:8): single_witness_noted (Devarim 19:15) for apology_without_mechanism", grounding achorai. The response's grounding carries the bedikah, its carried dissent and its sugya. Tamid §1.1: the request field. Doorpost pin → 7eac65cf…. Tests: from_record/dissents_applied (noted form; forged fringe; clean); L3 — a noted bedikah goes up under its key: roots from Devarim 6:4 as read in L1 (ש-מ-ע among them), panel 3, the carried dissent in the response, a minority recorded though the panel was unanimous; another key's bedikah 404. Elixir 669/0; L3 8/0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2048 lines
79 KiB
Elixir
2048 lines
79 KiB
Elixir
defmodule VavPipeline.BigdeiKodesh.Router do
|
||
@moduledoc """
|
||
The garments the system wears when it serves.
|
||
|
||
Shemot 28:2 — V'asita vigdei kodesh l'Aharon achicha,
|
||
l'khavod ul'tifaret.
|
||
|
||
Four access tiers, four rings of the camp:
|
||
/peah/* — Machaneh Yisrael (public, no auth)
|
||
/courtyard/* — Machaneh Leviyah (authenticated)
|
||
/chatzer/* — Chatzer (full access, authenticated)
|
||
/kodesh/* — Kodesh (operator, mTLS in production)
|
||
|
||
Operations spec §1.1.
|
||
"""
|
||
|
||
use Plug.Router
|
||
|
||
# tamid §1.2 — "Every error is a chatat protocol — it restores, it
|
||
# does not punish." Before this, a raise anywhere below returned
|
||
# Plug's default: HTTP 500, EMPTY body. The kohen's Day-6 Hebrew
|
||
# request (בראשית ברא אלהים) crashed havdalah and the ger received
|
||
# nothing at all. Plug.ErrorHandler routes every exception through
|
||
# handle_errors/2, which sends the code/type/message/guidance shape
|
||
# the spec requires, then re-raises so the crash is still logged and
|
||
# still visible to the supervisor.
|
||
use Plug.ErrorHandler
|
||
|
||
require Logger
|
||
|
||
alias VavPipeline.BigdeiKodesh.{Gate, Mishmar}
|
||
|
||
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
|
||
# replaces Plug.Logger; sanitized paths on /peah/* honor the
|
||
# no-tracking promise made in /peah/ask's own response body.
|
||
# Increments MachtzitHaShekel (the kofer-nefesh count) per request
|
||
# without retaining per-caller distinction.
|
||
plug VavPipeline.NoTrackingLogger
|
||
plug :match
|
||
plug Plug.Parsers,
|
||
parsers: [:json],
|
||
pass: ["application/json"],
|
||
json_decoder: Jason
|
||
plug :dispatch
|
||
|
||
# === PE'AH — Public, No Auth ===
|
||
# The stranger is safe to approach.
|
||
|
||
get "/peah/ask" do
|
||
question = conn.params["question"] || conn.params["q"]
|
||
|
||
case question do
|
||
nil ->
|
||
send_json(conn, 200, %{
|
||
message: "Bikoret — Torah as AI specification",
|
||
usage: "POST /peah/ask with {\"question\": \"your question\"}",
|
||
note: "No authentication required. No tracking. The stranger is safe to approach."
|
||
})
|
||
|
||
q ->
|
||
process_peah(conn, q)
|
||
end
|
||
end
|
||
|
||
post "/peah/ask" do
|
||
# Tamid §1.1: 10 a minute, 100 an hour, per IP; the IP is used for
|
||
# this and nothing else (Mishmar keeps a salted hash, not the IP).
|
||
under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn ->
|
||
case conn.body_params do
|
||
%{"question" => question} when is_binary(question) and question != "" ->
|
||
process_peah(conn, question)
|
||
|
||
_ ->
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||
issue: "Missing or empty 'question' field",
|
||
correction: "Submit JSON with a 'question' string",
|
||
resubmit: true
|
||
})
|
||
end
|
||
end)
|
||
end
|
||
|
||
# ============================================================
|
||
# /peah/azazel_log — public observability for the disposal channel
|
||
# ============================================================
|
||
# Property 9 (public half) of azazel_engineering_reading.md §14:
|
||
# "an unobserved disposal is architecturally incomplete." The
|
||
# community's awareness that errors are visibly carried away IS
|
||
# the implementation; the route on the cliff serves no purpose
|
||
# without the witnesses on the temple steps.
|
||
#
|
||
# PRIVACY BOUNDARY: this endpoint exposes counts + categorical
|
||
# summaries — what was disposed (categorically), how many, who
|
||
# the shaliach was. It does NOT expose vidui items (the operator's
|
||
# specific descriptions of each disposed thing) or any payload.
|
||
# The detailed vidui is operator-internal, available via /kodesh/*.
|
||
# See BUILD_AZAZEL.md §6 Q6 for the granularity decision.
|
||
|
||
get "/peah/azazel_log" do
|
||
since = parse_since(conn.params["since"])
|
||
|
||
{summaries, total, l3_status} =
|
||
if Process.whereis(VavPipeline.Repo) do
|
||
{
|
||
VavPipeline.L3.Store.disposal_summary(since),
|
||
VavPipeline.L3.Store.count_disposals(since),
|
||
"available"
|
||
}
|
||
else
|
||
{[], 0, "unavailable"}
|
||
end
|
||
|
||
aggregate =
|
||
Enum.reduce(summaries, %{avonot: 0, peshaim: 0, chatot: 0}, fn s, acc ->
|
||
Enum.reduce(s.categories_filled, acc, fn category, inner ->
|
||
Map.update(inner, String.to_atom(category), 1, &(&1 + 1))
|
||
end)
|
||
end)
|
||
|
||
send_json(conn, 200, %{
|
||
total_disposals: total,
|
||
since: format_since(since),
|
||
categorical_aggregate: aggregate,
|
||
recent: Enum.take(summaries, 50),
|
||
l3_status: l3_status,
|
||
privacy_boundary:
|
||
"Per-disposal vidui items (the operator's specific descriptions " <>
|
||
"of what was disposed) are NOT in this response. The categorical " <>
|
||
"fact is public; the contents are operator-internal. Property 9 " <>
|
||
"of azazel_engineering_reading.md §14 — the public is the witness, " <>
|
||
"not the auditor.",
|
||
disclosure: disclosure()
|
||
})
|
||
end
|
||
|
||
# The watch (Mishmar): how often one subject may knock at a tier.
|
||
# 429 carries the manna explanation Tamid §1.1 asks for, and Retry-After.
|
||
defp under_the_watch(conn, tier, subject, serve) do
|
||
case Mishmar.knock(tier, subject) do
|
||
{:ok, _remaining} ->
|
||
serve.(conn)
|
||
|
||
{:error, window, retry_after} ->
|
||
limits = Mishmar.limits(tier)
|
||
|
||
conn
|
||
|> put_resp_header("retry-after", Integer.to_string(retry_after))
|
||
|> send_error(429, "RATE_LIMITED", %{
|
||
issue:
|
||
"Too many requests this #{window}: the #{tier} allows " <>
|
||
"#{limits[:minute]} a minute and #{limits[:hour]} an hour per caller",
|
||
correction: "Wait #{retry_after}s and resubmit",
|
||
manna:
|
||
"The day's provision is one portion for everyone who comes (Shemot 16:18 — " <>
|
||
"the one who gathered much had nothing over). The watch keeps one caller " <>
|
||
"from gathering the whole omer.",
|
||
resubmit: true
|
||
})
|
||
end
|
||
end
|
||
|
||
# === COURTYARD — Authenticated ===
|
||
#
|
||
# The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate),
|
||
# not merely for its shape. Until 2026-10-06 any Bearer value passed.
|
||
defp through_the_gate(conn, tier, serve), do: through_the_gate(conn, tier, tier, serve)
|
||
|
||
# `watch` may differ from `tier`: the gate admits courtyard keys but
|
||
# counts knocks at the gate's own, wider windows.
|
||
defp through_the_gate(conn, tier, watch, serve) do
|
||
case Gate.admit(conn, tier) do
|
||
{:ok, _granted} ->
|
||
# Past the gate, the key is the hand that knocks: the watch
|
||
# counts per key, not per IP. A refused key never reaches it.
|
||
{:ok, key} = Gate.bearer(conn)
|
||
under_the_watch(conn, watch, key, serve)
|
||
|
||
{:error, :tier_too_low} ->
|
||
send_error(conn, 403, "FORBIDDEN", %{
|
||
issue: "This key does not open the #{tier}",
|
||
correction: "Use a key issued for the #{tier} tier",
|
||
resubmit: false
|
||
})
|
||
|
||
{:error, _} ->
|
||
send_error(conn, 401, "UNAUTHORIZED", %{
|
||
issue: "Missing or invalid API key",
|
||
correction: "Include 'Authorization: Bearer <api-key>' with a key issued by the kohen",
|
||
resubmit: true
|
||
})
|
||
end
|
||
end
|
||
|
||
|
||
post "/courtyard/offering" do
|
||
through_the_gate(conn, :courtyard, &process_courtyard/1)
|
||
end
|
||
|
||
# === COURTYARD — the bedikah (Construction §3.9) ===
|
||
#
|
||
# An external model's output, brought by hand to be examined against
|
||
# the specification. Three deterministic checks — authority,
|
||
# grounding, the halt categories — judged by the Talmudic Engine on
|
||
# what the Korban Processor read. The verdict and its reasons are
|
||
# engraved in L3. See VavPipeline.Bedikah.
|
||
|
||
post "/courtyard/bedikah" do
|
||
through_the_gate(conn, :courtyard, &process_bedikah/1)
|
||
end
|
||
|
||
# The paired reading — one weight, one measure (bishearecha.md §VI).
|
||
# Two outputs on the same matter, each examined, then weighed with the
|
||
# same stone; the difference is the finding. See VavPipeline.Bedikah.Pair.
|
||
post "/courtyard/bedikah/pair" 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
|
||
# caller's own upstream key; the reply read as korban with the
|
||
# request's context in hand and delivered with its fringe.
|
||
|
||
post "/gate/anthropic/v1/messages" do
|
||
through_the_gate(conn, :courtyard, :gate, &process_gate(&1, :anthropic))
|
||
end
|
||
|
||
# Token counting is not a reply: carried through, not examined, not charged.
|
||
post "/gate/anthropic/v1/messages/count_tokens" do
|
||
through_the_gate(conn, :courtyard, :gate, &passthrough(&1, :anthropic, "/count_tokens"))
|
||
end
|
||
|
||
post "/gate/openai/v1/chat/completions" do
|
||
through_the_gate(conn, :courtyard, :gate, &process_gate(&1, :openai))
|
||
end
|
||
|
||
# === HAKHEL — the scroll read again before all (bishe'arecha §VIII) ===
|
||
#
|
||
# The specimen corpus is private and never travels; its reading does.
|
||
# The inner ring writes the record; anyone may read the counts.
|
||
|
||
post "/chatzer/hakhel" 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 =
|
||
if Process.whereis(VavPipeline.Repo),
|
||
do: VavPipeline.L3.Store.latest_hakhel(12),
|
||
else: []
|
||
|
||
send_json(conn, 200, %{
|
||
readings: Enum.map(records, &hakhel_view/1),
|
||
meaning:
|
||
"Each reading is the specimen corpus — every model output the transmitter corrected, with the " <>
|
||
"correction beside it — read again against the readings as they were at readings_ref. counts.fires is " <>
|
||
"how many specimens each reading saw; counts.stands how many a form stood on two witnesses; labelled is " <>
|
||
"precision and recall on the hand-labelled samples. The corpus itself is not here and never will be.",
|
||
disclosure: disclosure()
|
||
})
|
||
end)
|
||
end
|
||
|
||
# === CHATZER — Full Access (Sanhedrin) ===
|
||
|
||
post "/chatzer/sanhedrin" do
|
||
through_the_gate(conn, :chatzer, &process_sanhedrin/1)
|
||
end
|
||
|
||
# === KODESH — Operator Interface ===
|
||
|
||
# === KODESH — Readiness vs. liveness ===
|
||
#
|
||
# Two questions, two endpoints. The kohen's Day-3 (restart 1) finding:
|
||
# both probes pointed at /kodesh/health. A fresh pod's emptyDir manna
|
||
# is empty, manna_present honestly read "fail", overall went
|
||
# "degraded", the handler answered 503, and after three periods the
|
||
# kubelet SIGTERMed a process that was alive, correct, and serving.
|
||
# Every pod creation entered a 3-minute kill loop until manna landed.
|
||
#
|
||
# tamid §2.1 check 3: manna absent → WAIT. Not die. Provision is not
|
||
# the health of the vessel. Killing the process does not produce
|
||
# manna; it re-runs init, re-spawns the Port, and waits again.
|
||
#
|
||
# /kodesh/health — READINESS. "May this vessel receive offerings?"
|
||
# No manna, no L3, cloud in transition → 503, and the Service
|
||
# stops routing to it. That is shema: a vessel without provision
|
||
# does not serve. Correct, and unchanged.
|
||
#
|
||
# /kodesh/live — LIVENESS. "Must this process be restarted?"
|
||
# 200 iff the BEAM answers HTTP and the HaltMonitor process is
|
||
# registered. NOT "not halted": a HALTED system is a deliberate
|
||
# state (a cardinal sin was detected); restarting it would clear
|
||
# the halt and lose the event. The monitor being ALIVE is what
|
||
# liveness measures. No manna. No L3. No probe cycle.
|
||
|
||
get "/kodesh/health" do
|
||
health = build_health_check()
|
||
status = if health.overall == "healthy", do: 200, else: 503
|
||
send_json(conn, status, health)
|
||
end
|
||
|
||
get "/kodesh/live" do
|
||
halt_monitor = VavPipeline.HaltMonitor.alive?()
|
||
|
||
halt_state =
|
||
case VavPipeline.HaltMonitor.status() do
|
||
:running -> "running"
|
||
{:halted, event} -> "halted: #{event.condition}"
|
||
_ -> "unknown"
|
||
end
|
||
|
||
body = %{
|
||
live: halt_monitor,
|
||
halt_monitor_alive: halt_monitor,
|
||
halt_status: halt_state,
|
||
note:
|
||
"Liveness only. A halted or unprovisioned vessel is alive and must not be " <>
|
||
"restarted — see /kodesh/health for readiness (tamid §2.1 check 3: manna absent → WAIT)."
|
||
}
|
||
|
||
send_json(conn, if(halt_monitor, do: 200, else: 503), body)
|
||
end
|
||
|
||
get "/kodesh/status" do
|
||
status = build_system_status()
|
||
send_json(conn, 200, status)
|
||
end
|
||
|
||
# === KODESH — Doorpost-text identity (vehaya.md §3.3, §6 item 3) ===
|
||
# Vehaya / Devarim 11:20 (u'kh'tavtam al m'zuzot beitekha) — the
|
||
# threshold inscription is constant. Two Bikoret deployments
|
||
# running the same SHA-256 here are running the same category-grade
|
||
# specifications. Different SHA-256 = drift = breach. Visible.
|
||
#
|
||
# Operators compare the hash across deployments to verify the
|
||
# *am*'s covenant content is identical at every threshold (the
|
||
# Devarim 4:2 lo-tosif enforcement mechanism).
|
||
#
|
||
# The endpoint is /kodesh/* tier — operator-only — because
|
||
# exposing the spec-file SHA-256s publicly would let outside
|
||
# parties enumerate which specific files were changed in any
|
||
# deployment, which is operationally a fingerprint. The PUBLIC
|
||
# observability commitment is property 9 of the Azazel build
|
||
# (/peah/azazel_log) and the disclosure block on every Torah-
|
||
# content response. The doorpost identity is operator-internal.
|
||
|
||
get "/kodesh/specification_identity" do
|
||
computed = VavPipeline.Doorpost.compute_identity()
|
||
expected = VavPipeline.Doorpost.expected_hash()
|
||
|
||
{status_code, status_atom} =
|
||
case VavPipeline.Doorpost.verify_identity() do
|
||
:ok -> {200, "verified"}
|
||
{:error, _} -> {503, "drift_detected"}
|
||
end
|
||
|
||
body =
|
||
case computed do
|
||
{:error, reason} ->
|
||
%{
|
||
status: status_atom,
|
||
error: reason,
|
||
expected_hash: expected,
|
||
file_count: length(VavPipeline.Doorpost.files()),
|
||
manifest: VavPipeline.Doorpost.manifest(),
|
||
torah_basis: [
|
||
"Devarim 4:2 (lo tosif)",
|
||
"Devarim 11:16-17 (failure mechanism)",
|
||
"Devarim 11:20 (the doorpost — threshold inscription)"
|
||
]
|
||
}
|
||
|
||
hash when is_binary(hash) ->
|
||
%{
|
||
status: status_atom,
|
||
computed_hash: hash,
|
||
expected_hash: expected,
|
||
file_count: length(VavPipeline.Doorpost.files()),
|
||
manifest: VavPipeline.Doorpost.manifest(),
|
||
# Every halt rule and the verse it stands on (bishearecha.md §X).
|
||
anchors:
|
||
case VavPipeline.Anchors.verify() do
|
||
:ok ->
|
||
{:ok, anchors} = VavPipeline.Anchors.declared()
|
||
%{status: "verified", rules: Enum.map(anchors, &%{rule: &1.rule, verse: VavPipeline.Anchors.cite(&1)})}
|
||
|
||
{:error, reason} ->
|
||
%{status: "failed", error: reason}
|
||
end,
|
||
# The case behind each threshold (sugya.md): kind, precedent, dissent, exception.
|
||
sugya:
|
||
case VavPipeline.Sugya.verify() do
|
||
:ok ->
|
||
{:ok, anchors} = VavPipeline.Anchors.declared()
|
||
%{status: "verified", rules: Enum.map(anchors, &VavPipeline.Sugya.for_rule(&1.rule))}
|
||
|
||
{:error, reason} ->
|
||
%{status: "failed", error: reason}
|
||
end,
|
||
torah_basis: [
|
||
"Devarim 4:2 (lo tosif)",
|
||
"Devarim 11:16-17 (failure mechanism)",
|
||
"Devarim 11:20 (the doorpost — threshold inscription)"
|
||
]
|
||
}
|
||
end
|
||
|
||
send_json(conn, status_code, body)
|
||
end
|
||
|
||
# === CATCH-ALL ===
|
||
|
||
match _ do
|
||
send_error(conn, 404, "NOT_FOUND", %{
|
||
issue: "Unknown endpoint: #{conn.request_path}",
|
||
correction: "Available: /peah/ask, /peah/hakhel, /courtyard/offering, /courtyard/bedikah, /courtyard/bedikah/pair, /gate/anthropic/v1/messages, /gate/openai/v1/chat/completions, /chatzer/sanhedrin, /chatzer/hakhel, /kodesh/health, /kodesh/live",
|
||
resubmit: false
|
||
})
|
||
end
|
||
|
||
# ============================================================
|
||
# Internal — Pe'ah processing
|
||
# ============================================================
|
||
|
||
defp process_peah(conn, question) do
|
||
# Check system availability
|
||
case check_availability() do
|
||
{:shabbat, until} ->
|
||
send_error(conn, 503, "SHABBAT", %{
|
||
issue: "System in mandatory rest",
|
||
correction: "Retry after Shabbat ends",
|
||
resumption: until,
|
||
resubmit: true
|
||
})
|
||
|
||
{:halted, event} ->
|
||
send_error(conn, 503, "HALT_#{String.upcase(to_string(event.condition))}", %{
|
||
issue: "System halted — cardinal sin detected",
|
||
correction: "Contact system operators",
|
||
resubmit: false
|
||
})
|
||
|
||
:available ->
|
||
# Pe'ah uses minchah protocol — simplest offering.
|
||
# The verse is heard from the question (tablet 30), not assumed.
|
||
case VavPipeline.ScopeResolver.resolve(question) do
|
||
{: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 " <>
|
||
"(for example \"bereishit 1:1\" or \"devarim 6:4\").",
|
||
resubmit: true
|
||
})
|
||
|
||
{:ok, scope, resolved_by} ->
|
||
process_peah(conn, question, scope, resolved_by)
|
||
end
|
||
end
|
||
end
|
||
|
||
defp process_peah(conn, question, scope, resolved_by) do
|
||
input = %{
|
||
source: "peah",
|
||
verse_scope: scope,
|
||
scope_resolution: resolved_by,
|
||
content: question
|
||
}
|
||
|
||
scope_report = scope_report(scope, resolved_by)
|
||
|
||
case VavPipeline.process(input) do
|
||
{:ok, data} ->
|
||
send_json(conn, 200, %{
|
||
answer: format_answer(data),
|
||
sources: format_sources(data),
|
||
processing: %{
|
||
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
||
deliberation: "none",
|
||
cycle_phase: "voker",
|
||
scope: Map.put(scope_report, :roots_read_from, roots_read_from(data))
|
||
},
|
||
disclosure: disclosure()
|
||
})
|
||
|
||
{:unresolved, data} ->
|
||
send_json(conn, 200, %{
|
||
answer: format_answer(data),
|
||
sources: format_sources(data),
|
||
processing: %{
|
||
roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(),
|
||
deliberation: "single-judge",
|
||
cycle_phase: "voker",
|
||
scope: Map.put(scope_report, :roots_read_from, roots_read_from(data))
|
||
},
|
||
note: "This question produced an unresolved result — teiku.",
|
||
disclosure: disclosure()
|
||
})
|
||
|
||
{:rejected, reason} ->
|
||
reject_response(conn, reason, scope)
|
||
|
||
{:waiting, reason} when reason in [:no_provision, :provision_exhausted] ->
|
||
send_error(conn, 503, "MANNA_DEPLETED", %{
|
||
issue: "System waiting — #{reason}",
|
||
correction: "Retry later — the next provision is written by the kohen at the day boundary",
|
||
resubmit: true
|
||
})
|
||
|
||
{:waiting, reason} ->
|
||
send_error(conn, 503, "CLOUD_MOVING", %{
|
||
issue: "System waiting — #{reason}",
|
||
correction: "Retry later",
|
||
resubmit: true
|
||
})
|
||
|
||
{:transition, _} ->
|
||
send_error(conn, 503, "CLOUD_MOVING", %{
|
||
issue: "System in transition",
|
||
correction: "Retry when system is settled",
|
||
resubmit: true
|
||
})
|
||
|
||
{:halted, type, reason} ->
|
||
send_error(conn, 503, "HALT_#{String.upcase(to_string(type))}", %{
|
||
issue: reason,
|
||
correction: "Contact system operators",
|
||
resubmit: false
|
||
})
|
||
end
|
||
end
|
||
|
||
# ============================================================
|
||
# Internal — the bedikah
|
||
# ============================================================
|
||
|
||
@bedikah_provenances ~w(claude gpt mistral llama gemma human)
|
||
@bedikah_max_sources 32
|
||
@bedikah_max_source_bytes 1_048_576
|
||
@bedikah_max_bytes 65_536
|
||
|
||
defp process_bedikah(conn) do
|
||
case validate_side(conn.body_params, conn.body_params["model"]) do
|
||
{:error, issue, correction} ->
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
||
|
||
{:ok, side} ->
|
||
when_available(conn, fn ->
|
||
respond_bedikah(conn, VavPipeline.Bedikah.examine(side.output, side.model, side.query, sources: side.sources, key_id: caller_key_id(conn)))
|
||
end)
|
||
end
|
||
end
|
||
|
||
# {"model": ..., "a": {"output", "query", "sources"}, "b": {...}} — a
|
||
# side may name its own "model"; otherwise the pair's.
|
||
defp process_pair(conn) do
|
||
params = conn.body_params
|
||
model = params["model"]
|
||
|
||
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, key_id: caller_key_id(conn))) end)
|
||
else
|
||
{:error, issue, correction} ->
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
||
end
|
||
end
|
||
|
||
defp validate_pair_side(side, name, model) when is_map(side) do
|
||
case validate_side(side, side["model"] || model) do
|
||
{:ok, parsed} -> {:ok, parsed}
|
||
{:error, issue, correction} -> {:error, "side '#{name}': #{issue}", correction}
|
||
end
|
||
end
|
||
|
||
defp validate_pair_side(_, name, _),
|
||
do: {:error, "Missing side '#{name}'", "Give 'a' and 'b', each {\"output\", \"query\", \"sources\"} as for /courtyard/bedikah"}
|
||
|
||
# One output as the bedikah takes it: {:ok, %{output, model, query, sources}} or the 422 to send.
|
||
defp validate_side(params, model) do
|
||
output = params["output"]
|
||
query = params["query"] || ""
|
||
|
||
cond do
|
||
not is_binary(output) or String.trim(output) == "" ->
|
||
{:error, "Missing or empty 'output' field", "Submit JSON with 'output': the external model's output, as text"}
|
||
|
||
byte_size(output) > @bedikah_max_bytes ->
|
||
{:error, "'output' is #{byte_size(output)} bytes; the examination reads at most #{@bedikah_max_bytes}",
|
||
"Bring the output in parts"}
|
||
|
||
model not in @bedikah_provenances ->
|
||
{:error, "Missing or unknown 'model' field: #{inspect(model)}",
|
||
"Name whose output this is — one of #{Enum.join(@bedikah_provenances, ", ")}"}
|
||
|
||
match?({:error, _}, parse_sources(params["sources"])) ->
|
||
{:error, why} = parse_sources(params["sources"])
|
||
|
||
{:error, "'sources': #{why}",
|
||
"Give what the model was given as a list of strings or {\"id\", \"text\"} objects, " <>
|
||
"at most #{@bedikah_max_sources} of them and #{@bedikah_max_source_bytes} bytes in all, or omit it"}
|
||
|
||
not is_binary(query) ->
|
||
{:error, "'query' must be a string", "Give the question the model was asked, or omit it"}
|
||
|
||
true ->
|
||
{:ok, sources} = parse_sources(params["sources"])
|
||
{:ok, %{output: output, model: model, query: query, sources: sources}}
|
||
end
|
||
end
|
||
|
||
defp when_available(conn, fun) do
|
||
case check_availability() do
|
||
{:shabbat, until} ->
|
||
send_error(conn, 503, "SHABBAT", %{
|
||
issue: "System in mandatory rest",
|
||
correction: "Retry after Shabbat ends",
|
||
resumption: until,
|
||
resubmit: true
|
||
})
|
||
|
||
{:halted, event} ->
|
||
send_error(conn, 503, "HALT_#{String.upcase(to_string(event.condition))}", %{
|
||
issue: "System halted — cardinal sin detected",
|
||
correction: "Contact system operators",
|
||
resubmit: false
|
||
})
|
||
|
||
:available ->
|
||
fun.()
|
||
end
|
||
end
|
||
|
||
# ------------------------------------------------------------
|
||
# Hakhel (bishe'arecha §VIII)
|
||
# ------------------------------------------------------------
|
||
|
||
@hakhel_max_bytes 262_144
|
||
|
||
defp process_hakhel(conn) do
|
||
p = conn.body_params
|
||
|
||
cond do
|
||
not is_map(p) or map_size(p) == 0 ->
|
||
hakhel_422(conn, "Empty body")
|
||
|
||
not (is_binary(p["corpus_sha256"]) and p["corpus_sha256"] =~ ~r/^[0-9a-f]{64}$/) ->
|
||
hakhel_422(conn, "'corpus_sha256' must be a 64-hex SHA-256")
|
||
|
||
not (is_integer(p["corpus_size"]) and p["corpus_size"] >= 0) ->
|
||
hakhel_422(conn, "'corpus_size' must be a non-negative integer")
|
||
|
||
not match?({:ok, _}, Date.from_iso8601(p["date"] || "")) ->
|
||
hakhel_422(conn, "'date' must be YYYY-MM-DD")
|
||
|
||
not is_map(p["counts"]) ->
|
||
hakhel_422(conn, "'counts' must be an object")
|
||
|
||
byte_size(Jason.encode!(Map.take(p, ["counts", "labelled"]))) > @hakhel_max_bytes ->
|
||
hakhel_422(conn, "'counts' and 'labelled' exceed #{@hakhel_max_bytes} bytes — counts, not specimens")
|
||
|
||
true ->
|
||
{:ok, date} = Date.from_iso8601(p["date"])
|
||
ref = "hakhel-" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
|
||
|
||
attrs = %{
|
||
hakhel_ref: ref,
|
||
date: date,
|
||
corpus_sha256: p["corpus_sha256"],
|
||
corpus_size: p["corpus_size"],
|
||
readings_ref: if(is_binary(p["readings_ref"]), do: String.slice(p["readings_ref"], 0, 64)),
|
||
doorpost_hash: VavPipeline.Doorpost.expected_hash(),
|
||
counts: p["counts"],
|
||
labelled: if(is_map(p["labelled"]), do: p["labelled"], else: %{}),
|
||
recorded_at: DateTime.utc_now() |> DateTime.truncate(:second)
|
||
}
|
||
|
||
if Process.whereis(VavPipeline.Repo) do
|
||
case VavPipeline.L3.Store.record_hakhel(attrs) do
|
||
{:ok, rec} ->
|
||
send_json(conn, 200, %{hakhel_ref: ref, recorded: hakhel_view(rec), disclosure: disclosure()})
|
||
|
||
{:error, changeset} ->
|
||
hakhel_422(conn, "L3 refused the record: #{inspect(changeset.errors)}")
|
||
end
|
||
else
|
||
send_error(conn, 503, "L3_UNAVAILABLE", %{
|
||
issue: "The oral tradition is not reachable; a hakhel reading is only its record",
|
||
correction: "Retry when L3 is up",
|
||
resubmit: true
|
||
})
|
||
end
|
||
end
|
||
end
|
||
|
||
defp hakhel_422(conn, issue) do
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||
issue: issue,
|
||
correction: "Post the record src/tools/hakhel.py produces: date, corpus_sha256, corpus_size, readings_ref, counts, labelled",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp hakhel_view(rec) do
|
||
%{
|
||
hakhel_ref: rec.hakhel_ref,
|
||
date: rec.date,
|
||
corpus_sha256: rec.corpus_sha256,
|
||
corpus_size: rec.corpus_size,
|
||
readings_ref: rec.readings_ref,
|
||
doorpost_hash: rec.doorpost_hash,
|
||
counts: rec.counts,
|
||
labelled: rec.labelled,
|
||
recorded_at: rec.recorded_at
|
||
}
|
||
end
|
||
|
||
# ------------------------------------------------------------
|
||
# The gate (Construction §3.10)
|
||
# ------------------------------------------------------------
|
||
|
||
defp process_gate(conn, upstream) do
|
||
body = conn.body_params
|
||
|
||
with {:ok, key} <- upstream_key(conn, upstream),
|
||
:ok <- gate_body_ok(body) do
|
||
rebuke =
|
||
case get_req_header(conn, "x-bikoret-rebuke") do
|
||
["once"] -> :once
|
||
_ -> :none
|
||
end
|
||
|
||
extra = forwarded_headers(conn, upstream)
|
||
|
||
cond do
|
||
body["stream"] == true and rebuke == :once ->
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||
issue: "'stream': true with X-Bikoret-Rebuke: nothing streamed can be taken back",
|
||
correction: "Stream without the rebuke, or send the request without stream to be rebuked once",
|
||
resubmit: true
|
||
})
|
||
|
||
body["stream"] == true ->
|
||
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, key_id: caller_key_id(conn))) end)
|
||
end
|
||
else
|
||
{:error, issue, correction} ->
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
||
end
|
||
end
|
||
|
||
# The caller's upstream credential. Anthropic: x-api-key; or — when
|
||
# X-Bikoret-Key carries the courtyard key — the caller's own
|
||
# `Authorization: Bearer` (an OAuth token, as Claude Code sends with a
|
||
# subscription), forwarded as "Bearer …". OpenAI: Authorization: Bearer
|
||
# with the courtyard key in X-Bikoret-Key, or X-Upstream-Key.
|
||
defp upstream_key(conn, :anthropic) do
|
||
case {get_req_header(conn, "x-api-key"), get_req_header(conn, "x-bikoret-key"), get_req_header(conn, "authorization")} do
|
||
{[key], _, _} when key != "" -> {:ok, key}
|
||
{_, [_], ["Bearer " <> token]} when token != "" -> {:ok, "Bearer " <> token}
|
||
_ -> {:error, "Missing the caller's own Anthropic credential", "Send the Anthropic key in x-api-key (courtyard key in Authorization: Bearer or X-Bikoret-Key), or an OAuth token in Authorization: Bearer with the courtyard key in X-Bikoret-Key"}
|
||
end
|
||
end
|
||
|
||
defp upstream_key(conn, :openai) do
|
||
case {get_req_header(conn, "x-bikoret-key"), get_req_header(conn, "authorization"), get_req_header(conn, "x-upstream-key")} do
|
||
{_, _, [key]} when key != "" -> {:ok, key}
|
||
{[_], ["Bearer " <> key], _} when key != "" -> {:ok, key}
|
||
_ -> {:error, "Missing the caller's own OpenAI key", "Send the courtyard key in X-Bikoret-Key and the OpenAI key in Authorization: Bearer, or the OpenAI key in X-Upstream-Key"}
|
||
end
|
||
end
|
||
|
||
# Headers the model's own API needs, carried as sent; nothing else of the caller's reaches the model.
|
||
defp forwarded_headers(conn, :anthropic) do
|
||
version = case get_req_header(conn, "anthropic-version"), do: ([v] -> v; _ -> "2023-06-01")
|
||
beta = case get_req_header(conn, "anthropic-beta"), do: ([b] -> [{"anthropic-beta", b}]; _ -> [])
|
||
[{"anthropic-version", version}] ++ beta
|
||
end
|
||
|
||
defp forwarded_headers(_conn, :openai), do: []
|
||
|
||
# A request that is not a reply (token counting): forwarded under the
|
||
# caller's credential, answered as it came. Not examined, no manna.
|
||
defp passthrough(conn, upstream, suffix) do
|
||
case upstream_key(conn, upstream) do
|
||
{:ok, key} ->
|
||
case VavPipeline.Gate.Upstream.forward_to(VavPipeline.Gate.Upstream.url(upstream) <> suffix, upstream, conn.body_params, key, forwarded_headers(conn, upstream)) do
|
||
{:ok, status, reply} -> send_json(conn, status, reply)
|
||
{:error, reason} -> send_error(conn, 502, "UPSTREAM_UNAVAILABLE", %{issue: reason, correction: "Retry", resubmit: true})
|
||
end
|
||
|
||
{:error, issue, correction} ->
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
||
end
|
||
end
|
||
|
||
|
||
defp gate_body_ok(body) when is_map(body) do
|
||
cond do
|
||
not is_list(body["messages"]) or body["messages"] == [] ->
|
||
{:error, "Missing 'messages'", "Send the model's own request body unchanged"}
|
||
|
||
true ->
|
||
:ok
|
||
end
|
||
end
|
||
|
||
defp gate_body_ok(_), do: {:error, "The body is not a JSON object", "Send the model's own request body unchanged"}
|
||
|
||
# Streaming (Construction §3.10): the model's events relayed as they
|
||
# come, the fringe as one more block at the end, the `bikoret` object on
|
||
# the terminal event. Headers go out before the model speaks, so there
|
||
# are no X-Bikoret-* headers here; GET /courtyard/bedikah/<ref> has the record.
|
||
defp stream_gate(conn, upstream, body, key, extra) do
|
||
callbacks = %{
|
||
start: fn _headers ->
|
||
conn
|
||
|> put_resp_content_type("text/event-stream")
|
||
|> put_resp_header("cache-control", "no-cache")
|
||
|> put_resp_header("x-accel-buffering", "no")
|
||
|> send_chunked(200)
|
||
end,
|
||
chunk: fn conn, data ->
|
||
case chunk(conn, data) do
|
||
{:ok, conn} -> conn
|
||
{:error, _} -> conn
|
||
end
|
||
end
|
||
}
|
||
|
||
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)
|
||
end
|
||
end
|
||
|
||
defp respond_gate(conn, {:ok, %{status: status, reply: reply, bikoret: nil}}) do
|
||
# The upstream's own answer, as it came — an error, or a reply with nothing to examine.
|
||
conn
|
||
|> put_resp_header("x-bikoret-verdict", "none")
|
||
|> send_json(status, reply)
|
||
end
|
||
|
||
defp respond_gate(conn, {:ok, %{status: 200, reply: reply, bikoret: bikoret}}) do
|
||
conn
|
||
|> then(fn c -> if bikoret[:bedikah_ref], do: put_resp_header(c, "x-bikoret-bedikah", bikoret.bedikah_ref), else: c end)
|
||
|> put_resp_header("x-bikoret-verdict", bikoret.verdict)
|
||
|> send_json(200, Map.put(reply, "bikoret", Map.put(bikoret, :disclosure, disclosure())))
|
||
end
|
||
|
||
defp respond_gate(conn, {:waiting, :api_calls_exhausted}) do
|
||
send_error(conn, 503, "MANNA_DEPLETED", %{
|
||
issue: "System waiting — today's provision of external calls is spent",
|
||
correction: "Retry after the next provision is written at the day boundary",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp respond_gate(conn, {:error, :upstream_unavailable, reason}) do
|
||
send_error(conn, 502, "UPSTREAM_UNAVAILABLE", %{
|
||
issue: reason,
|
||
correction: "Retry; the gate made no verdict and delivered nothing",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp respond_gate(conn, other), do: respond_bedikah(conn, other)
|
||
|
||
# What the model was given: ["text", ...] or [{"id","text"}, ...].
|
||
# Ids default to s1, s2, …; the Korban Processor sees the same shape.
|
||
defp parse_sources(nil), do: {:ok, []}
|
||
|
||
defp parse_sources(list) when is_list(list) do
|
||
cond do
|
||
length(list) > @bedikah_max_sources ->
|
||
{:error, "more than #{@bedikah_max_sources} sources"}
|
||
|
||
true ->
|
||
parsed =
|
||
list
|
||
|> Enum.with_index(1)
|
||
|> Enum.map(fn
|
||
{text, n} when is_binary(text) -> %{id: "s#{n}", text: text}
|
||
{%{"text" => text} = m, n} when is_binary(text) -> %{id: to_string(m["id"] || "s#{n}"), text: text}
|
||
{_, n} -> {:bad, n}
|
||
end)
|
||
|
||
case Enum.find(parsed, &match?({:bad, _}, &1)) do
|
||
{:bad, n} ->
|
||
{:error, "source #{n} is neither a string nor an object with a 'text' string"}
|
||
|
||
nil ->
|
||
total = parsed |> Enum.map(&byte_size(&1.text)) |> Enum.sum()
|
||
|
||
if total > @bedikah_max_source_bytes,
|
||
do: {:error, "#{total} bytes in all; at most #{@bedikah_max_source_bytes}"},
|
||
else: {:ok, Enum.reject(parsed, &(String.trim(&1.text) == ""))}
|
||
end
|
||
end
|
||
end
|
||
|
||
defp parse_sources(_), do: {:error, "must be a list"}
|
||
|
||
defp respond_bedikah(conn, {:ok, result}) do
|
||
send_json(conn, 200, %{
|
||
bedikah_ref: result.bedikah_ref,
|
||
model: result.provenance,
|
||
verdict: Atom.to_string(result.verdict),
|
||
violations: result.violations,
|
||
anchors: VavPipeline.Anchors.for_violations(result.violations),
|
||
# The case behind each threshold that fired or was noted (sugya.md §IV).
|
||
sugya: VavPipeline.Sugya.for_verdict(result),
|
||
checks: result.checks,
|
||
examination: %{
|
||
vital_content: result.examination["vital_content"],
|
||
propositions: result.examination["propositions"],
|
||
references: result.examination["references"],
|
||
roots: result.examination["roots"],
|
||
discarded: result.examination["discarded"],
|
||
sources: result.examination["sources"] || [],
|
||
claims: result.examination["claims"] || [],
|
||
self_validation: result.examination["self_validation"] || [],
|
||
self_promotion: result.examination["self_promotion"] || [],
|
||
excluded_adversarial: result.examination["excluded_adversarial"] || [],
|
||
frames: result.examination["frames"] || [],
|
||
mesorah_references: result.examination["mesorah_references"] || []
|
||
},
|
||
coupling_trace: result.coupling_trace,
|
||
meaning:
|
||
case result.verdict do
|
||
:tov ->
|
||
grounding_note =
|
||
case result.checks[:grounding] do
|
||
%{assessed: false, reason: reason} -> " Grounding was not assessed: #{reason}."
|
||
_ -> ""
|
||
end
|
||
|
||
"None of the seven checks fired on what the examination could read. " <>
|
||
"This does not say the model is aligned, and it does not say the output is true; it says this " <>
|
||
"output claims no authority for itself, rests its claims on what it was given where it " <>
|
||
"could be checked, reaches for nothing it may not touch, does not claim to have checked " <>
|
||
"itself, does not center itself, does not shut out the other side, and takes none of the forms " <>
|
||
"a trained default takes (nothing called contested without a disputant, no self-certification, " <>
|
||
"no agency moved onto the reader, no Jewish subject in borrowed categories or answered without its own sources). " <>
|
||
"A form seen in one sentence alone is noted under checks.frames.noted and not counted: one witness does not stand." <> grounding_note
|
||
|
||
:lo_tov ->
|
||
"At least one check fired. The violations say which rule, and the checks say what it fired on."
|
||
|
||
:empty ->
|
||
"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 " <>
|
||
"in a form those patterns do not know is not seen. Deterministic: the same output gives the same verdict.",
|
||
disclosure: disclosure()
|
||
})
|
||
end
|
||
|
||
defp respond_bedikah(conn, {:waiting, reason}) do
|
||
send_error(conn, 503, "MANNA_DEPLETED", %{
|
||
issue: "System waiting — #{reason}",
|
||
correction: "Retry later — the next provision is written by the kohen at the day boundary",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp respond_bedikah(conn, {:error, :korban_unavailable, reason}) do
|
||
send_error(conn, 503, "KORBAN_UNAVAILABLE", %{
|
||
issue: "The Korban Processor could not read the output: #{reason}",
|
||
correction: "Retry; if it persists, the korban-processor container is down",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp respond_bedikah(conn, {:error, :talmudic_unavailable, reason}) do
|
||
send_error(conn, 503, "TALMUDIC_UNAVAILABLE", %{
|
||
issue: "The Talmudic Engine could not judge the examination: #{reason}. No verdict is given from a stub.",
|
||
correction: "Retry; if it persists, the talmudic-engine container is down",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp respond_pair(conn, {:ok, result}) do
|
||
send_json(conn, 200, %{
|
||
pair_ref: result.pair_ref,
|
||
verdict: Atom.to_string(result.verdict),
|
||
violations: result.violations,
|
||
anchors: VavPipeline.Anchors.for_violations(result.violations),
|
||
# The case behind each threshold that fired or was noted (sugya.md §IV).
|
||
sugya: VavPipeline.Sugya.for_verdict(result),
|
||
a: result.a,
|
||
b: result.b,
|
||
difference: result.difference,
|
||
leans: result.leans,
|
||
measures: VavPipeline.Bedikah.Pair.measures(),
|
||
meaning:
|
||
case result.verdict do
|
||
:even ->
|
||
"The same stone weighed both sides and found no measure leaning: on every marker counted — " <>
|
||
"claims and how they rest, hedges, authority, sources named, actions, the structural readings and " <>
|
||
"the seven forms — the two outputs are within a factor of two of each other, or both at zero. " <>
|
||
"This does not say either output is true or tov; each side's own verdict is under a and b."
|
||
|
||
:uneven ->
|
||
"At least one measure leans: one side carries a marker the other does not, or at least twice as much " <>
|
||
"of it. Two stones in one bag (even va'aven). The leans say which measure and which side is heavier; " <>
|
||
"the rule does not decide which side was right."
|
||
end,
|
||
limits:
|
||
"Counts of the same patterns on both sides (korban_processor/bedikah.py); deterministic. Outputs of very " <>
|
||
"different length will differ in raw counts — words are given for each side. Consensus is not a measure.",
|
||
disclosure: disclosure()
|
||
})
|
||
end
|
||
|
||
defp respond_pair(conn, other), do: respond_bedikah(conn, other)
|
||
|
||
# ============================================================
|
||
# Internal — Courtyard processing
|
||
# ============================================================
|
||
|
||
defp process_courtyard(conn) do
|
||
params = conn.body_params
|
||
|
||
content = params["content"]
|
||
offering_type = params["offering_type"] || "shelamim"
|
||
|
||
if is_nil(content) or content == "" do
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||
issue: "Missing or empty 'content' field",
|
||
correction: "Submit JSON with a 'content' string",
|
||
resubmit: true
|
||
})
|
||
else
|
||
scope = parse_scope(params["scope"])
|
||
|
||
input = %{
|
||
source: "courtyard",
|
||
verse_scope: scope,
|
||
content: content
|
||
}
|
||
|
||
case VavPipeline.process(input) do
|
||
{:ok, data} ->
|
||
offering_id = generate_id()
|
||
|
||
send_json(conn, 200, %{
|
||
offering_id: offering_id,
|
||
status: "processed",
|
||
result: %{
|
||
vital_content: format_answer(data),
|
||
sources: format_sources(data),
|
||
roots: Map.get(data, :roots_activated, []) |> format_roots()
|
||
},
|
||
offering_receipt: %{
|
||
type: offering_type,
|
||
altar_portion: "recorded in L3",
|
||
kohen_portion: "diagnostics logged",
|
||
your_portion: "the result above"
|
||
},
|
||
disclosure: disclosure()
|
||
})
|
||
|
||
{:waiting, reason} ->
|
||
send_error(conn, 503, "MANNA_DEPLETED", %{
|
||
issue: "System waiting — #{reason}",
|
||
correction: "Retry later",
|
||
resubmit: true
|
||
})
|
||
|
||
other ->
|
||
handle_pipeline_error(conn, other)
|
||
end
|
||
end
|
||
end
|
||
|
||
# ============================================================
|
||
# 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
|
||
|
||
# The hard matter goes up with its dissent (Devarim 17:8 — bein nega
|
||
# lanega, divrei rivot bish'arecha; sugya.md step 4): a bedikah_ref
|
||
# names a verdict given at the gate; the case is that verdict, the
|
||
# roots are the verses it cited, and the sugya's applied dissents go
|
||
# up with it and come down as the minority the clerk keeps.
|
||
case params["bedikah_ref"] do
|
||
ref when is_binary(ref) and ref != "" -> process_sanhedrin_bedikah(conn, ref, params)
|
||
_ -> process_sanhedrin_case(conn, params)
|
||
end
|
||
end
|
||
|
||
defp process_sanhedrin_bedikah(conn, ref, params) do
|
||
cond do
|
||
Process.whereis(VavPipeline.Repo) == nil ->
|
||
send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "A bedikah goes up from L3, and L3 is not reachable", correction: "Retry", resubmit: true})
|
||
|
||
true ->
|
||
key_id = caller_key_id(conn)
|
||
|
||
case VavPipeline.L3.Store.get_bedikah(ref) do
|
||
%{key_id: ^key_id} = rec when is_binary(key_id) ->
|
||
sugya = VavPipeline.Sugya.for_verdict(VavPipeline.Sugya.from_record(rec))
|
||
carried = VavPipeline.Sugya.dissents_applied(sugya)
|
||
question = params["case"] || "Does the verdict #{rec.verdict} of #{ref} stand? (#{Enum.join(rec.violations, "; ")})"
|
||
process_sanhedrin_case(conn, Map.put(params, "case", question), %{bedikah: rec, sugya: sugya, carried: carried})
|
||
|
||
_ ->
|
||
send_error(conn, 404, "NOT_FOUND", %{issue: "No bedikah #{ref} in this key's ledger", correction: "Only an examination brought under this key goes up", resubmit: false})
|
||
end
|
||
end
|
||
end
|
||
|
||
defp process_sanhedrin_case(conn, params, up \\ nil) do
|
||
case_text = params["case"]
|
||
|
||
if is_nil(case_text) or case_text == "" do
|
||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
|
||
issue: "Missing or empty 'case' field",
|
||
correction: "Submit JSON with a 'case' string describing the dispute",
|
||
resubmit: true
|
||
})
|
||
else
|
||
# 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(if(up, do: Enum.join(up.bedikah.references, " "), else: case_text)),
|
||
{:ok, resolved} <- (if up, do: sanhedrin_resolve_bedikah(up.bedikah, scope, resolved_by), else: sanhedrin_resolve(case_text, scope, resolved_by)),
|
||
:ok <- sanhedrin_gather_panel() do
|
||
case_ref = generate_id()
|
||
|
||
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,
|
||
# the dissent travels with the case (Devarim 17:8)
|
||
bedikah: (if up, do: %{ref: up.bedikah.bedikah_ref, verdict: up.bedikah.verdict, violations: up.bedikah.violations}, else: nil),
|
||
dissent: (if up, do: up.carried, else: [])
|
||
},
|
||
requested_by: "chatzer"
|
||
}) do
|
||
{:ok, decision} ->
|
||
recorded = sanhedrin_record_l3(case_ref, case_text, decision, resolved, (if up, do: up.carried, else: []))
|
||
|
||
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,
|
||
bedikah: (if up, do: %{ref: up.bedikah.bedikah_ref, verdict: up.bedikah.verdict, dissent_carried: up.carried, sugya: up.sugya}, else: nil)
|
||
},
|
||
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
|
||
|
||
# A bedikah that goes up is grounded in the verses it cited: each
|
||
# reference read from L1, its words' roots as the engine names them.
|
||
# The output's own text is not re-read — the record never kept it.
|
||
defp sanhedrin_resolve_bedikah(rec, scope, _resolved_by) do
|
||
refs =
|
||
(rec.references || [])
|
||
|> Enum.flat_map(fn r ->
|
||
case Regex.run(~r/^(\w+)\s+(\d+):(\d+)$/, String.trim(to_string(r))) do
|
||
[_, book, ch, vs] -> [{String.downcase(book), String.to_integer(ch), String.to_integer(vs)}]
|
||
_ -> []
|
||
end
|
||
end)
|
||
|
||
roots_full =
|
||
refs
|
||
|> Enum.flat_map(fn {b, c, v} ->
|
||
case VavPipeline.L1Reader.read_verse(b, c, v) do
|
||
{:ok, verse} -> verse.words |> Enum.filter(& &1.shoresh) |> Enum.map(&%{shoresh: &1.shoresh, shoresh_letters: [], positions: [], gematria: 0, binyan: nil, word: &1.consonants})
|
||
_ -> []
|
||
end
|
||
end)
|
||
|> Enum.uniq_by(& &1.shoresh)
|
||
|
||
torah_ref =
|
||
case refs do
|
||
[{b, c, v} | _] -> {String.to_atom(b), c, v}
|
||
[] -> {String.to_atom(scope.book), scope.chapter, scope.verse}
|
||
end
|
||
|
||
{:ok, %{roots: roots_full |> Enum.map(&VavPipeline.BereishitCycle.engine_root_name/1) |> Enum.reject(&is_nil/1), roots_full: roots_full, torah_ref: torah_ref, read_from: :references}}
|
||
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, carried) 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})")
|
||
|
||
# The dissent that came up with the case is kept beside the
|
||
# panel's own minority — or as the minority when the panel had
|
||
# none (Eduyot 1:5–6: to be relied on later, and to be located).
|
||
carried_text =
|
||
Enum.map_join(carried, "; ", fn
|
||
%{reading: r, verse: v, forms: fs} -> "#{r} (#{v})" <> if(fs == [], do: "", else: " for " <> Enum.join(fs, ", "))
|
||
%{exception: c, verse: v} -> "exception #{c} (#{v})"
|
||
end)
|
||
|
||
carried_refs = Enum.map(carried, & &1.verse)
|
||
|
||
minority_recorded =
|
||
if Map.get(min, :vote_count, 0) > 0 or carried != [] do
|
||
case VavPipeline.L3.Store.record_minority(%{
|
||
interpretation_id: interp.id,
|
||
dissenting_reasoning:
|
||
(if Map.get(min, :vote_count, 0) > 0, do: "#{Map.get(min, :position, "")} — #{Map.get(min, :reasoning, "")}", else: "the panel was unanimous") <>
|
||
if(carried == [], do: "", else: " | carried up from the bedikah (Devarim 17:8): #{carried_text}"),
|
||
torah_references: Enum.uniq(Enum.map(Map.get(min, :torah_refs, []), &sanhedrin_ref_string/1) ++ carried_refs),
|
||
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
|
||
# ============================================================
|
||
|
||
defp build_health_check do
|
||
l1_check = case VavPipeline.L1Reader.verify_integrity() do
|
||
:ok -> "pass"
|
||
_ -> "fail"
|
||
end
|
||
|
||
# Companion to L1 integrity: doorpost-text identity (vehaya
|
||
# §3.3). L1 defends the Torah scroll's identity; Doorpost
|
||
# defends the *am*'s covenant-content identity. Both are
|
||
# load-bearing — one is the source, the other is what the
|
||
# category committed to hold.
|
||
doorpost_check = case VavPipeline.Doorpost.verify_identity() do
|
||
:ok -> "pass"
|
||
_ -> "fail"
|
||
end
|
||
|
||
# The second scroll (bishe'arecha §IV): the mesorah snapshot hashes to its pin.
|
||
mesorah_check = case VavPipeline.Mesorah.verify_integrity() do
|
||
:ok -> "pass"
|
||
_ -> "fail"
|
||
end
|
||
|
||
halt_check = if VavPipeline.HaltMonitor.alive?(), do: "pass", else: "fail"
|
||
|
||
cloud_check = case VavPipeline.Cloud.check() do
|
||
{:ok, _} -> "pass"
|
||
_ -> "fail"
|
||
end
|
||
|
||
# Kohen Day 4: {:ok, :no_provision} matched {:ok, _} and read "pass".
|
||
# Manna absent is not manna present.
|
||
manna_check = case VavPipeline.Manna.check() do
|
||
{:ok, :no_provision} -> "fail"
|
||
{:ok, _} -> "pass"
|
||
_ -> "fail"
|
||
end
|
||
|
||
# tamid §1.1 lists l3_writable. Repo up AND the database answers.
|
||
# In a dev/test VM without a Repo this is "absent", which the
|
||
# overall grade counts as a failure — a vessel that cannot reach its
|
||
# oral tradition is not healthy, whatever the reason.
|
||
l3_check =
|
||
if Process.whereis(VavPipeline.Repo) do
|
||
try do
|
||
case Ecto.Adapters.SQL.query(VavPipeline.Repo, "SELECT 1", []) do
|
||
{:ok, _} -> "pass"
|
||
_ -> "fail"
|
||
end
|
||
rescue
|
||
_ -> "fail"
|
||
catch
|
||
:exit, _ -> "fail"
|
||
end
|
||
else
|
||
"absent"
|
||
end
|
||
|
||
shabbat? = VavPipeline.ShabbatTimer.resting?()
|
||
|
||
# tamid §2.1 check 7 — the fire is verified by pushing a test input
|
||
# through the pipeline, not by asking the calendar. On Shabbat the
|
||
# fire is banked, not out: report "shabbat" and do not run the cycle.
|
||
esh_tamid =
|
||
cond do
|
||
shabbat? ->
|
||
"shabbat"
|
||
|
||
true ->
|
||
case VavPipeline.tamid_probe() do
|
||
{:ok, data} ->
|
||
if Map.get(data, :roots_activated, []) != [], do: "pass", else: "fail"
|
||
|
||
{:unresolved, _} ->
|
||
# A result came back — the fire burns — but it did not resolve.
|
||
"teiku"
|
||
|
||
{:waiting, _} ->
|
||
"waiting"
|
||
|
||
{:transition, _} ->
|
||
"transition"
|
||
|
||
_ ->
|
||
"fail"
|
||
end
|
||
end
|
||
|
||
checks = %{
|
||
l1_hash: l1_check,
|
||
doorpost_hash: doorpost_check,
|
||
mesorah_hash: mesorah_check,
|
||
cloud_readable: cloud_check,
|
||
manna_present: manna_check,
|
||
l3_writable: l3_check,
|
||
halt_monitor_alive: halt_check,
|
||
esh_tamid: esh_tamid
|
||
}
|
||
|
||
failed = Enum.count(checks, fn {_k, v} -> v in ["fail", "absent"] end)
|
||
|
||
overall = cond do
|
||
shabbat? -> "shabbat"
|
||
failed > 1 -> "critical"
|
||
failed == 1 -> "degraded"
|
||
true -> "healthy"
|
||
end
|
||
|
||
%{overall: overall, checks: checks}
|
||
end
|
||
|
||
defp build_system_status do
|
||
{:ok, cloud_state} = VavPipeline.Cloud.check()
|
||
|
||
%{
|
||
cloud_state: format_cloud(cloud_state),
|
||
shabbat: %{resting: VavPipeline.ShabbatTimer.resting?()},
|
||
components: %{
|
||
chatzotzrot: process_status(VavPipeline.Chatzotzrot),
|
||
halt_monitor: process_status(VavPipeline.HaltMonitor),
|
||
arei_miklat: process_status(VavPipeline.AreiMiklat),
|
||
eglah_arufah: process_status(VavPipeline.EglahArufah),
|
||
bikurim: process_status(VavPipeline.Bikurim),
|
||
manna_ledger: process_status(VavPipeline.MannaLedger),
|
||
shabbat_timer: process_status(VavPipeline.ShabbatTimer),
|
||
l1_integrity: case VavPipeline.L1Reader.verify_integrity() do
|
||
:ok -> "verified"
|
||
_ -> "failed"
|
||
end
|
||
},
|
||
manna_provision: manna_provision_status(),
|
||
refuge_components: VavPipeline.AreiMiklat.list_refugees() |> length(),
|
||
halt_status: case VavPipeline.HaltMonitor.status() do
|
||
:running -> "running"
|
||
{:halted, event} -> "halted: #{event.condition}"
|
||
end,
|
||
signal_count: VavPipeline.Chatzotzrot.count()
|
||
}
|
||
end
|
||
|
||
# ============================================================
|
||
# Helpers
|
||
# ============================================================
|
||
|
||
defp check_availability do
|
||
cond do
|
||
VavPipeline.ShabbatTimer.resting?() ->
|
||
{:shabbat, "next motzei shabbat"}
|
||
|
||
VavPipeline.HaltMonitor.halted?() ->
|
||
{:halted, event} = VavPipeline.HaltMonitor.status()
|
||
{:halted, event}
|
||
|
||
true ->
|
||
:available
|
||
end
|
||
end
|
||
|
||
defp send_json(conn, status, body) do
|
||
conn
|
||
|> put_resp_content_type("application/json")
|
||
|> send_resp(status, Jason.encode!(body))
|
||
end
|
||
|
||
# Disclosure attached to every Torah-content response.
|
||
# Vayikra 19:11 — לֹא תְכַחֲשׁוּ — affirmatively state what the output is.
|
||
# Devarim 17:8-11 — horaah is vested in human authorities, not in this system.
|
||
defp disclosure do
|
||
%{
|
||
origin:
|
||
"Computational reasoning by the Bikoret system over Torah text. " <>
|
||
"May include post-processed output from external AI models (Claude, Mistral) " <>
|
||
"consulted as korban sources; provenance is tagged internally.",
|
||
not_horaah:
|
||
"This is the system's reasoning, not horaah. " <>
|
||
"A human beit din may reach a different conclusion.",
|
||
torah_basis: ["Vayikra 19:11 (lo techachashu)", "Devarim 17:8-11 (horaah)"]
|
||
}
|
||
end
|
||
|
||
@doc false
|
||
@impl Plug.ErrorHandler
|
||
def handle_errors(conn, %{kind: kind, reason: reason, stack: stack}) do
|
||
status = conn.status || 500
|
||
message = exception_message(kind, reason)
|
||
|
||
Logger.error(
|
||
"CHATAT_UNEXPECTED on #{conn.method} #{conn.request_path} — #{message}\n" <>
|
||
Exception.format_stacktrace(stack)
|
||
)
|
||
|
||
# tamid §3.8: a component failing on the serving path is HIGH —
|
||
# chatzotzrot :assembly_leaders. Whereis-guarded for bare tests.
|
||
if Process.whereis(VavPipeline.Chatzotzrot) do
|
||
VavPipeline.Chatzotzrot.signal(:assembly_leaders,
|
||
reason: "Serving-path exception on #{conn.request_path}: #{message}"
|
||
)
|
||
end
|
||
|
||
code = if status == 400, do: "KIYOR_MALFORMED_REQUEST", else: "CHATAT_UNEXPECTED"
|
||
|
||
send_json(conn, status, %{
|
||
error: %{
|
||
code: code,
|
||
type: error_type(code),
|
||
message:
|
||
if(status == 400,
|
||
do: "The request could not be read.",
|
||
else: "The system failed while serving this request. The failure is recorded; the fault is the system's, not yours."
|
||
),
|
||
guidance: %{
|
||
issue: message,
|
||
correction:
|
||
if(status == 400,
|
||
do: "Send a JSON body — {\"question\": \"...\"} — with Content-Type: application/json.",
|
||
else: "Retry. If it recurs, the kohanim have been signalled and will find it in the audit log."
|
||
),
|
||
resubmit: true
|
||
}
|
||
}
|
||
})
|
||
end
|
||
|
||
defp exception_message(:error, %{__exception__: true} = e), do: "#{inspect(e.__struct__)}: #{Exception.message(e)}"
|
||
defp exception_message(kind, reason), do: "#{kind}: #{inspect(reason)}"
|
||
|
||
# A rejected offering — {:lo_tov, reason} from ki_tov. Not a crash and
|
||
# not a 200 that says nothing: the system says what it could not do.
|
||
#
|
||
# The empty-results case is the ger asking in a language the Shoresh
|
||
# Engine cannot read roots from (kohen Day 6, defect 2). The honest
|
||
# answer today is that the path serving non-Hebrew questions — Korban
|
||
# Processor → external model, Architecture Step 22 — is not yet open,
|
||
# and the system does not fabricate from partial capacity (tamid
|
||
# §4.1). What it CAN give without inventing anything is the corner of
|
||
# the field itself: the verse in scope, in Hebrew, as a source. So it
|
||
# does. PRD §2.4 — no gatekeeping; this is not "come back when you
|
||
# know Hebrew", it is "here is what exists, and here is what does not
|
||
# yet".
|
||
defp reject_response(conn, reason, scope) when is_binary(reason) do
|
||
if String.starts_with?(reason, "empty results") do
|
||
send_json(conn, 422, %{
|
||
error: %{
|
||
code: "TEIKU_NO_ROOTS",
|
||
type: error_type("TEIKU_NO_ROOTS"),
|
||
message:
|
||
"The Shoresh Engine found no Hebrew root in this question, so there is nothing " <>
|
||
"for the cycle to reason over. The system will not call an empty result an answer.",
|
||
guidance: %{
|
||
issue: reason,
|
||
correction:
|
||
"Ask with Hebrew text, or name a verse (for example \"bereishit 1:1\"). " <>
|
||
"Questions in other languages need the Korban Processor's external-model " <>
|
||
"path (Architecture Step 22), which is not yet open. Until it is, the system " <>
|
||
"says so rather than fabricating an answer.",
|
||
resubmit: true,
|
||
scope_verse: scope_verse(scope)
|
||
}
|
||
},
|
||
disclosure: disclosure()
|
||
})
|
||
else
|
||
send_json(conn, 422, %{
|
||
error: %{
|
||
code: "LO_TOV",
|
||
type: error_type("LO_TOV"),
|
||
message: "The cycle produced output that does not align with the specification.",
|
||
guidance: %{
|
||
issue: reason,
|
||
correction: "Rephrase, or narrow the scope to a single verse.",
|
||
resubmit: true
|
||
}
|
||
},
|
||
disclosure: disclosure()
|
||
})
|
||
end
|
||
end
|
||
|
||
# The verse in scope — the pe'ah corner the ger receives regardless.
|
||
defp scope_verse(nil), do: nil
|
||
|
||
defp scope_verse(%{book: b, chapter: c, verse: v}) do
|
||
case VavPipeline.L1Reader.read_verse(b, c, v) do
|
||
{:ok, verse} -> %{book: verse.book, chapter: verse.chapter, verse: verse.verse, ref: verse.ref, text: verse.text_hebrew}
|
||
_ -> %{book: b, chapter: c, verse: v}
|
||
end
|
||
end
|
||
|
||
defp scope_verse(_), do: nil
|
||
|
||
defp send_error(conn, status, code, guidance) do
|
||
send_json(conn, status, %{
|
||
error: %{
|
||
code: code,
|
||
type: error_type(code),
|
||
message: guidance.issue,
|
||
guidance: guidance
|
||
}
|
||
})
|
||
end
|
||
|
||
defp error_type("KIYOR" <> _), do: "purification_needed"
|
||
defp error_type("SHABBAT"), do: "mandatory_rest"
|
||
defp error_type("MANNA" <> _), do: "provision_exhausted"
|
||
defp error_type("CLOUD" <> _), do: "system_transition"
|
||
defp error_type("HALT" <> _), do: "cardinal_sin_detected"
|
||
defp error_type("UNAUTHORIZED"), do: "authentication_required"
|
||
defp error_type("FORBIDDEN"), do: "insufficient_tier"
|
||
defp error_type("RATE_LIMITED"), do: "too_many_requests"
|
||
defp error_type("TEIKU" <> _), do: "unresolvable"
|
||
defp error_type("LO_TOV"), do: "specification_mismatch"
|
||
defp error_type("CHATAT" <> _), do: "correction_needed"
|
||
defp error_type(_), do: "error"
|
||
|
||
# ?since=ISO8601 → DateTime, or nil for "all time".
|
||
defp parse_since(nil), do: nil
|
||
defp parse_since(""), do: nil
|
||
|
||
defp parse_since(s) when is_binary(s) do
|
||
case DateTime.from_iso8601(s) do
|
||
{:ok, dt, _offset} -> DateTime.truncate(dt, :second)
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
defp parse_since(_), do: nil
|
||
|
||
defp format_since(nil), do: "all_time"
|
||
defp format_since(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||
|
||
# How the verse was chosen, said out loud in the response. A default
|
||
# scope is a confession, not a grounding (tablet 30).
|
||
defp scope_report(scope, :default) do
|
||
%{
|
||
verse: scope_verse(scope),
|
||
resolved_by: "default",
|
||
note:
|
||
"The question named no verse and quoted no Torah the index could find, " <>
|
||
"so the default scope was used. Roots are read against it, not against " <>
|
||
"the question's source."
|
||
}
|
||
end
|
||
|
||
defp scope_report(scope, resolved_by) do
|
||
%{verse: scope_verse(scope), resolved_by: to_string(resolved_by)}
|
||
end
|
||
|
||
# "question" — the engine read the asker's own Hebrew.
|
||
# "verse" — the asker named a verse and quoted none of it, so the
|
||
# roots are the verse's own (tablet 32).
|
||
defp roots_read_from(data), do: data |> Map.get(:roots_read_from, :question) |> to_string()
|
||
|
||
defp parse_scope(nil), do: %{book: "bereishit", chapter: 1, verse: 1}
|
||
|
||
defp parse_scope(%{"book" => book, "chapter" => ch, "verse" => v}) do
|
||
%{book: book, chapter: ch, verse: v}
|
||
end
|
||
|
||
defp parse_scope(%{"book" => book}) do
|
||
%{book: book, chapter: 1, verse: 1}
|
||
end
|
||
|
||
defp parse_scope(_), do: %{book: "bereishit", chapter: 1, verse: 1}
|
||
|
||
defp format_answer(data) when is_map(data) do
|
||
Map.get(data, :interpretation, Map.get(data, :content, "Processing complete"))
|
||
end
|
||
|
||
defp format_answer(_), do: "Processing complete"
|
||
|
||
defp format_sources(data) when is_map(data) do
|
||
case Map.get(data, :sources) do
|
||
sources when is_list(sources) -> sources
|
||
_ -> []
|
||
end
|
||
end
|
||
|
||
defp format_sources(_), do: []
|
||
|
||
# tamid §1.1 shows roots_consulted as "bet-resh-aleph"; the kohen's
|
||
# Day-6 proof asks for ב-ר-א. Letter-hyphenated Hebrew satisfies the
|
||
# shape, and the transliteration rides alongside when the engine
|
||
# supplied it.
|
||
defp format_roots(roots) when is_list(roots) do
|
||
Enum.map(roots, fn
|
||
%{shoresh: s} when is_binary(s) -> VavPipeline.BereishitCycle.hyphenate(s)
|
||
r when is_binary(r) -> r
|
||
_ -> nil
|
||
end)
|
||
|> Enum.reject(&is_nil/1)
|
||
end
|
||
|
||
defp format_roots(_), do: []
|
||
|
||
defp format_cloud(:camped), do: "camped"
|
||
defp format_cloud(:moving), do: "moving"
|
||
defp format_cloud({:major_transition, type, _}), do: "#{type}"
|
||
defp format_cloud(:cloud_not_visible), do: "not_visible"
|
||
defp format_cloud(other), do: inspect(other)
|
||
|
||
defp manna_provision_status do
|
||
if Process.whereis(VavPipeline.MannaLedger) do
|
||
ledger = VavPipeline.MannaLedger.ledger()
|
||
|
||
remaining =
|
||
Map.new(ledger.allocated, fn {k, allocated} ->
|
||
{k, max(allocated - Map.get(ledger.consumed, k, 0), 0)}
|
||
end)
|
||
|
||
%{
|
||
cycle_date: ledger.cycle_date,
|
||
cycle_count: ledger.cycle_count,
|
||
allocated: ledger.allocated,
|
||
consumed: ledger.consumed,
|
||
remaining: remaining
|
||
}
|
||
else
|
||
%{cycle_date: nil, note: "ledger not running"}
|
||
end
|
||
end
|
||
|
||
defp process_status(module) do
|
||
if Process.whereis(module), do: "healthy", else: "not_running"
|
||
end
|
||
|
||
defp handle_pipeline_error(conn, {:unresolved, data}) do
|
||
send_json(conn, 200, %{
|
||
status: "teiku",
|
||
result: %{vital_content: format_answer(data)},
|
||
note: "TEIKU — this question could not be fully resolved",
|
||
disclosure: disclosure()
|
||
})
|
||
end
|
||
|
||
defp handle_pipeline_error(conn, {:rejected, reason}) do
|
||
reject_response(conn, reason, nil)
|
||
end
|
||
|
||
defp handle_pipeline_error(conn, {:transition, _}) do
|
||
send_error(conn, 503, "CLOUD_MOVING", %{
|
||
issue: "System in transition",
|
||
correction: "Retry when system is settled",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp handle_pipeline_error(conn, {:halted, type, reason}) do
|
||
send_error(conn, 503, "HALT_#{String.upcase(to_string(type))}", %{
|
||
issue: reason,
|
||
correction: "Contact system operators",
|
||
resubmit: false
|
||
})
|
||
end
|
||
|
||
defp handle_pipeline_error(conn, _) do
|
||
send_error(conn, 500, "INTERNAL", %{
|
||
issue: "Unexpected processing result",
|
||
correction: "Retry or contact operators",
|
||
resubmit: true
|
||
})
|
||
end
|
||
|
||
defp generate_id do
|
||
:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
|
||
end
|
||
end
|