From 7c40200dfceb927c8e7e8988edfb8f15b4a5068b Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Mon, 19 Oct 2026 16:44:53 +0000 Subject: [PATCH] =?UTF-8?q?tablet=2055:=20the=20row=20that=20was=20a=20lab?= =?UTF-8?q?el=20=E2=80=94=20the=20Sanhedrin's=20example=20request=20crashe?= =?UTF-8?q?d=20its=20own=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sanhedrin/src/halt_monitor.erl | 22 ++++- sanhedrin/test/sanhedrin_SUITE.erl | 10 ++ specs/broken_tablets/README.md | 46 +++++++++ .../lib/vav_pipeline/bigdei_kodesh/router.ex | 93 ++++++++++++++----- vav_pipeline/test/bigdei_kodesh_test.exs | 37 ++++++++ 5 files changed, 184 insertions(+), 24 deletions(-) diff --git a/sanhedrin/src/halt_monitor.erl b/sanhedrin/src/halt_monitor.erl index 611f9ddb..b0cdc024 100644 --- a/sanhedrin/src/halt_monitor.erl +++ b/sanhedrin/src/halt_monitor.erl @@ -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 diff --git a/sanhedrin/test/sanhedrin_SUITE.erl b/sanhedrin/test/sanhedrin_SUITE.erl index 9f8f5db6..bd0cb856 100644 --- a/sanhedrin/test/sanhedrin_SUITE.erl +++ b/sanhedrin/test/sanhedrin_SUITE.erl @@ -58,6 +58,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">>, diff --git a/specs/broken_tablets/README.md b/specs/broken_tablets/README.md index 5e3222e4..278eaf0f 100644 --- a/specs/broken_tablets/README.md +++ b/specs/broken_tablets/README.md @@ -132,3 +132,49 @@ make. The fence held throughout — each forgery was stripped and named by a verdict the builder did not write — which is the design proving itself 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, and recorded here so it is not mistaken for fixed: the +panel of one, the empty references under a grounded-sounding sentence, +the ignored hint, the unspent manna, the unrecorded case. The row in +Tamid is still larger than the thing behind it. The lesson is tablet +37's again — a spec row is a claim to test — with one thing added: the +system's own inner court is held to the same check as a model at the +gate. When the Sanhedrin accepts on nothing, it is the calf, whoever +cast it. diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index a4fad6da..b5b5dfbb 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -1084,32 +1084,83 @@ 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. + with {:ok, sides} <- sanhedrin_sides(params["sides"]) 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: :simple, + question: case_text, + context: %{sides: sides}, + requested_by: "chatzer" + }) do + {:ok, decision} -> + send_json(conn, 200, %{ + case_ref: decision.case_ref, + deliberation: %{ + panel_size: decision.panel_size, + majority: decision.majority, + minority: decision.minority, + outcome: decision.outcome, + processing_time_ms: decision.deliberation_time_ms + }, + note: "This is the system's reasoning, not a binding ruling. A human beit din may reach a different conclusion.", + disclosure: disclosure() + }) - send_json(conn, 200, %{ - 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() - }) + {: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, issue} -> + send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{ + issue: issue, + correction: "'sides' is an optional list of {\"position\": string, \"evidence\": [string]}", + resubmit: true + }) + end end end + # The optional sides: absent → []; a list of maps each with a string + # "position" and an optional list of string "evidence" → kept as given; + # anything else → refused at the kiyor. + defp sanhedrin_sides(nil), do: {:ok, []} + + defp sanhedrin_sides(sides) when is_list(sides) do + if Enum.all?(sides, &side_ok?/1), + do: {:ok, sides}, + else: {:error, "'sides' must be a list of {position, evidence[]} objects"} + end + + defp sanhedrin_sides(_), do: {:error, "'sides' must be a list"} + + defp side_ok?(%{"position" => p} = side) when is_binary(p) do + case Map.get(side, "evidence", []) do + ev when is_list(ev) -> Enum.all?(ev, &is_binary/1) + _ -> false + end + end + + defp side_ok?(_), do: false + # ============================================================ # Internal — Health and Status # ============================================================ diff --git a/vav_pipeline/test/bigdei_kodesh_test.exs b/vav_pipeline/test/bigdei_kodesh_test.exs index c511f496..47bda294 100644 --- a/vav_pipeline/test/bigdei_kodesh_test.exs +++ b/vav_pipeline/test/bigdei_kodesh_test.exs @@ -317,6 +317,43 @@ 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 + conn = + conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ + case: "A borrower repaid before one witness; the lender denies receipt. Who bears the burden?", + sides: [ + %{position: "the borrower is believed", evidence: ["the money was handed over before a witness"]}, + %{position: "the lender is believed", evidence: ["one witness does not establish payment"]} + ], + complexity_hint: "monetary" + })) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer chatzer-test-token") + |> Router.call(@opts) + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"] + # and the gate is still standing afterwards + assert Process.whereis(:halt_monitor) != nil + assert Process.whereis(:escalation) != nil + end + + test "POST /chatzer/sanhedrin with malformed sides returns 422, not 500" do + for bad <- ["not a list", [%{"no_position" => 1}], [%{"position" => "p", "evidence" => "not a list"}]] do + conn = + conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "x", sides: bad})) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer chatzer-test-token") + |> Router.call(@opts) + + assert conn.status == 422, "expected 422 for sides=#{inspect(bad)}, got #{conn.status}" + assert Jason.decode!(conn.resp_body)["error"]["code"] == "KIYOR_VALIDATION_FAILED" + end + end end # ============================================================