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 # ============================================================