From f493b7df6502f55a4ef7ff783cae9b88bc5b8577 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Tue, 28 Apr 2026 09:22:40 -0400 Subject: [PATCH] =?UTF-8?q?bigdei=5Fkodesh:=20commit=208=20=E2=80=94=20GET?= =?UTF-8?q?=20/peah/azazel=5Flog=20(public=20half=20of=20property=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 4 / Finding F, commit 8 of 10 from BUILD_AZAZEL.md. The public-half of property 9 (observable disposal). The Rambam in Moreh Nevuchim 3:46 makes the load-bearing claim: "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. This endpoint is those witnesses. Anyone with HTTP access to Shorashim can see WHAT was disposed (categorically) and HOW MANY disposals occurred — turning the disposal channel from a private operator action into a public, witnessed fact. PRIVACY BOUNDARY (BUILD_AZAZEL.md §6 Q6): The endpoint exposes: - total disposal count (optionally filtered by ?since=ISO8601) - categorical aggregate (avonot/peshaim/chatot counts across recent disposals — what KIND of errors were disposed) - up to 50 recent disposals as case_ref + shaliach + triggering_call + disposed_at + categories_filled (which slots of vidui were filled) The endpoint does NOT expose: - vidui ITEMS (the operator's specific descriptions of each disposed thing) - the disposed PAYLOAD (which doesn't exist in L3 anyway — property 10) - any handle for retrieving the disposed content (there isn't one) The detailed vidui items are operator-internal, accessible via future /kodesh/* endpoints. The split is deliberate: public is WITNESS, operator is AUDITOR. The witness sees the fact; the auditor reads the contents. The privacy boundary statement is included in every response so callers know what they're not seeing — they can reason about the endpoint's silence rather than guess. GRACEFUL DEGRADATION: When VavPipeline.Repo is not running (dev/test without Postgres), the endpoint returns total_disposals=0 with l3_status="unavailable" rather than crashing. Mirrors the maybe_add_repo pattern from MEMORY.md — Shorashim should respond cleanly even when L3 is down, because /peah/* is the public-facing tier and must be more resilient than internal tiers. CHANGED: vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex + GET /peah/azazel_log Optional ?since=ISO8601 filter; returns total + categorical_aggregate + recent[<=50] + l3_status + privacy_boundary statement + disclosure block (kichush + not_horaah from commit 5e00f75) + parse_since/1 helper (graceful: bad input → all_time) + format_since/1 helper vav_pipeline/test/bigdei_kodesh_test.exs + 8 new tests covering: - 200 OK when no disposals recorded (empty L3) - response carries privacy_boundary statement - response carries disclosure block - since=invalid → all_time - since=valid_iso8601 → echoed - categorical_aggregate has all 3 slots even when empty - [@l3] aggregate counts categorically across multiple disposals - [@l3] PRIVACY BOUNDARY: response NEVER contains vidui item strings (load-bearing — verifies a known secret_phrase inserted into a vidui's chatot does NOT leak into the response body) Tests: vav_pipeline 361 passed, 0 failures, 35 excluded (was 353/33; +6 visible response-shape tests + 2 :l3-tagged tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/vav_pipeline/bigdei_kodesh/router.ex | 69 +++++++++ vav_pipeline/test/bigdei_kodesh_test.exs | 141 ++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 03f4c099..eeabc65b 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -57,6 +57,59 @@ defmodule VavPipeline.BigdeiKodesh.Router do 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 + # === COURTYARD — Authenticated === post "/courtyard/offering" do @@ -428,6 +481,22 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp error_type("UNAUTHORIZED"), do: "authentication_required" 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) + defp extract_scope_from_question(_question) do # Default scope for pe'ah — Bereishit 1:1 # In production, this would parse the question for verse references diff --git a/vav_pipeline/test/bigdei_kodesh_test.exs b/vav_pipeline/test/bigdei_kodesh_test.exs index 637412af..eb638934 100644 --- a/vav_pipeline/test/bigdei_kodesh_test.exs +++ b/vav_pipeline/test/bigdei_kodesh_test.exs @@ -82,6 +82,147 @@ defmodule VavPipeline.BigdeiKodeshTest do end end + # ============================================================ + # Pe'ah — Azazel disposal log (commit 8, public half of property 9) + # ============================================================ + # Privacy boundary tests run WITHOUT a database (the empty L3 case + # exercises the response shape and the privacy guarantees). Tests + # that need actual disposal records to query are tagged :l3. + + describe "GET /peah/azazel_log — public observability" do + test "responds with 200 even when no disposals are recorded" do + conn = + conn(:get, "/peah/azazel_log") + |> Router.call(@opts) + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert is_integer(body["total_disposals"]) + assert is_list(body["recent"]) + assert is_map(body["categorical_aggregate"]) + end + + test "response carries the privacy boundary statement" do + conn = + conn(:get, "/peah/azazel_log") + |> Router.call(@opts) + + body = Jason.decode!(conn.resp_body) + # Property 9's privacy boundary must be visible to the caller — + # they need to know that the items they don't see are + # operator-internal, not missing. + assert body["privacy_boundary"] =~ "operator-internal" + assert body["privacy_boundary"] =~ "Property 9" + end + + test "response carries the disclosure block (kichush + horaah)" do + conn = + conn(:get, "/peah/azazel_log") + |> Router.call(@opts) + + body = Jason.decode!(conn.resp_body) + # Mirrors every other Torah-content response from commit 5e00f75 + assert body["disclosure"]["origin"] + assert body["disclosure"]["not_horaah"] + assert body["disclosure"]["torah_basis"] + end + + test "since=invalid returns all_time" do + conn = + conn(:get, "/peah/azazel_log?since=not-a-timestamp") + |> Router.call(@opts) + + body = Jason.decode!(conn.resp_body) + assert body["since"] == "all_time" + end + + test "since=valid_iso8601 is echoed in response" do + conn = + conn(:get, "/peah/azazel_log?since=2026-04-28T00:00:00Z") + |> Router.call(@opts) + + body = Jason.decode!(conn.resp_body) + assert body["since"] == "2026-04-28T00:00:00Z" + end + + test "categorical_aggregate has all three slots even when empty" do + conn = + conn(:get, "/peah/azazel_log") + |> Router.call(@opts) + + body = Jason.decode!(conn.resp_body) + agg = body["categorical_aggregate"] + # All three slots present (the public sees the SHAPE of the + # vidui structure, not its contents) + assert Map.has_key?(agg, "avonot") + assert Map.has_key?(agg, "peshaim") + assert Map.has_key?(agg, "chatot") + end + + @tag :l3 + test "with disposals: aggregate counts categories accurately" do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo) + + base = %{ + case_ref: "log-001", + vidui: %{ + "avonot" => ["halt avodah_zarah: x"], + "peshaim" => [], + "chatot" => [], + "confessor" => "kohen", + "confessed_at" => "2026-04-28T12:00:00Z" + }, + routing: "eretz_gezerah", + authorized_by: "kohen-gadol", + shaliach: "ish-itti", + disposed_at: ~U[2026-04-28 12:00:01Z] + } + + {:ok, _} = VavPipeline.L3.Store.record_disposal(base) + {:ok, _} = VavPipeline.L3.Store.record_disposal(Map.put(base, :case_ref, "log-002")) + + conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) + body = Jason.decode!(conn.resp_body) + + assert body["total_disposals"] == 2 + assert body["categorical_aggregate"]["avonot"] == 2 + end + + @tag :l3 + test "PRIVACY BOUNDARY: response NEVER contains vidui item strings" do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo) + + secret_phrase = + "kohen-confidential-content-that-must-never-leak-to-public-endpoint" + + attrs = %{ + case_ref: "privacy-001", + vidui: %{ + "avonot" => [], + "peshaim" => [], + "chatot" => [secret_phrase], + "confessor" => "kohen", + "confessed_at" => "2026-04-28T12:00:00Z" + }, + routing: "eretz_gezerah", + authorized_by: "kohen-gadol", + shaliach: "ish-itti", + disposed_at: ~U[2026-04-28 12:00:01Z] + } + + {:ok, _} = VavPipeline.L3.Store.record_disposal(attrs) + + conn = conn(:get, "/peah/azazel_log") |> Router.call(@opts) + + # The response body — as a string — must NOT contain the + # vidui item content. This is the load-bearing privacy + # guarantee; if the secret_phrase appears anywhere in the + # response, the public endpoint is leaking operator-internal + # state. + refute conn.resp_body =~ secret_phrase + end + end + # ============================================================ # Courtyard — Authenticated Endpoint # ============================================================