From 191d76b480ec622ef7f37d711a07edababe24f04 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Sun, 22 Mar 2026 07:32:15 -0400 Subject: [PATCH] =?UTF-8?q?Bigdei=20Kodesh:=20HTTP=20API=20layer=20?= =?UTF-8?q?=E2=80=94=20four=20garments=20for=20four=20access=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shemot 28:2 — V'asita vigdei kodesh l'Aharon achicha, l'khavod ul'tifaret. The builder makes the garments. The kohen wears them. Four access tiers (Operations spec §1.1): - Pe'ah (GET/POST /peah/ask) — public, no auth, no tracking. The stranger is safe to approach. - Courtyard (POST /courtyard/offering) — Bearer token auth, offering types: shelamim, olah, chatat, minchah - Chatzer (POST /chatzer/sanhedrin) — Sanhedrin deliberation, returns majority/minority with preserved dissent - Kodesh (GET /kodesh/health, /kodesh/status) — operator interface, health checks for K8s liveness/readiness probes Error format follows chatat protocol (Operations spec §1.2): KIYOR_* (422), SHABBAT (503), MANNA_DEPLETED (503), CLOUD_MOVING (503), HALT_* (503), UNAUTHORIZED (401) Implementation: - Plug.Router with Bandit HTTP server - Added to supervision tree via maybe_add_http_server - Checks system availability (Shabbat, halt) before processing - Health check reads L1 integrity, cloud, manna, halt monitor - Status shows all component states, signal count, refuge list - 16 API tests covering all four tiers + error format All 235 Elixir + 19 L3 + 195 Prolog + 33 Haskell = 463+ tests, 0 failures Co-Authored-By: Claude Opus 4.6 --- vav_pipeline/config/test.exs | 3 +- vav_pipeline/lib/vav_pipeline/application.ex | 12 + .../lib/vav_pipeline/bigdei_kodesh/router.ex | 500 ++++++++++++++++++ vav_pipeline/mix.exs | 5 +- vav_pipeline/mix.lock | 7 + vav_pipeline/test/bigdei_kodesh_test.exs | 251 +++++++++ 6 files changed, 776 insertions(+), 2 deletions(-) create mode 100644 vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex create mode 100644 vav_pipeline/test/bigdei_kodesh_test.exs diff --git a/vav_pipeline/config/test.exs b/vav_pipeline/config/test.exs index c8a8c4e2..ffe4ee28 100644 --- a/vav_pipeline/config/test.exs +++ b/vav_pipeline/config/test.exs @@ -6,7 +6,8 @@ config :logger, level: :warning config :vav_pipeline, l1_mount_point: "../l1", manna_provision_file: "test/fixtures/manna/today.json", - cycle_boundary_check_interval_ms: :timer.hours(24) + cycle_boundary_check_interval_ms: :timer.hours(24), + http_port: 0 # L3 test database — separate from dev, uses sandbox pool # Repo start disabled by default — L3 tests need PostgreSQL running locally diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 0a33d2b4..16c24879 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -11,6 +11,7 @@ defmodule VavPipeline.Application do - MannaLedger (consumption tracking and anti-hoarding enforcement) - HaltMonitor (esh tamid — cardinal sin detection) - ShabbatTimer (mandatory cessation monitor) + - Bandit HTTP server (bigdei kodesh — the garments) - ShoreshEnginePort (Erlang Port to Haskell engine, if binary found) - TalmudicEngineSocket (Unix socket to Prolog engine) @@ -39,6 +40,7 @@ defmodule VavPipeline.Application do {VavPipeline.HaltMonitor, []}, {VavPipeline.ShabbatTimer, []} ] + |> maybe_add_http_server() |> maybe_add_repo() |> maybe_add_shoresh_port(shoresh_binary, shoresh_cwd) |> maybe_add_talmudic_socket(talmudic_socket) @@ -47,6 +49,16 @@ defmodule VavPipeline.Application do Supervisor.start_link(children, opts) end + defp maybe_add_http_server(children) do + port = Application.get_env(:vav_pipeline, :http_port, 4000) + + if port do + children ++ [{Bandit, plug: VavPipeline.BigdeiKodesh.Router, port: port}] + else + children + end + end + defp maybe_add_repo(children) do repo_config = Application.get_env(:vav_pipeline, VavPipeline.Repo) diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex new file mode 100644 index 00000000..baabfd89 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -0,0 +1,500 @@ +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 + + plug Plug.Logger, log: :info + 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: "Shorashim — 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 + 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 + + # === COURTYARD — Authenticated === + + post "/courtyard/offering" do + case get_req_header(conn, "authorization") do + ["Bearer " <> _token] -> + process_courtyard(conn) + + _ -> + send_error(conn, 401, "UNAUTHORIZED", %{ + issue: "Missing or invalid Authorization header", + correction: "Include 'Authorization: Bearer ' header", + resubmit: true + }) + end + end + + # === CHATZER — Full Access (Sanhedrin) === + + post "/chatzer/sanhedrin" do + case get_req_header(conn, "authorization") do + ["Bearer " <> _token] -> + process_sanhedrin(conn) + + _ -> + send_error(conn, 401, "UNAUTHORIZED", %{ + issue: "Missing or invalid Authorization header", + correction: "Include 'Authorization: Bearer ' header", + resubmit: true + }) + end + end + + # === KODESH — Operator Interface === + + 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/status" do + status = build_system_status() + send_json(conn, 200, status) + end + + # === CATCH-ALL === + + match _ do + send_error(conn, 404, "NOT_FOUND", %{ + issue: "Unknown endpoint: #{conn.request_path}", + correction: "Available: /peah/ask, /courtyard/offering, /chatzer/sanhedrin, /kodesh/health", + 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 + scope = extract_scope_from_question(question) + + input = %{ + source: "peah", + verse_scope: scope, + content: question + } + + 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" + } + }) + + {: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" + }, + note: "This question produced an unresolved result — teiku." + }) + + {:waiting, reason} -> + code = if reason == :no_provision, do: "MANNA_DEPLETED", else: "CLOUD_MOVING" + + send_error(conn, 503, code, %{ + 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 + end + + # ============================================================ + # 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" + } + }) + + {: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 + # ============================================================ + + defp process_sanhedrin(conn) do + params = conn.body_params + + 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 + 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" + }) + + 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." + }) + end + end + + # ============================================================ + # Internal — Health and Status + # ============================================================ + + defp build_health_check do + l1_check = case VavPipeline.L1Reader.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 + + manna_check = case VavPipeline.Manna.check() do + {:ok, _} -> "pass" + _ -> "fail" + end + + shabbat_check = if VavPipeline.ShabbatTimer.resting?(), do: "shabbat", else: "pass" + + checks = %{ + l1_hash: l1_check, + cloud_readable: cloud_check, + manna_present: manna_check, + halt_monitor_alive: halt_check, + esh_tamid: shabbat_check + } + + failed = Enum.count(checks, fn {_k, v} -> v == "fail" end) + + overall = cond do + shabbat_check == "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 + }, + 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 + + 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(_), do: "error" + + 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 + %{book: "bereishit", chapter: 1, verse: 1} + end + + 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: [] + + defp format_roots(roots) when is_list(roots) do + Enum.map(roots, fn + %{shoresh: s} -> 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 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" + }) + 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 diff --git a/vav_pipeline/mix.exs b/vav_pipeline/mix.exs index ca30310b..2665ae4e 100644 --- a/vav_pipeline/mix.exs +++ b/vav_pipeline/mix.exs @@ -36,7 +36,10 @@ defmodule VavPipeline.MixProject do # L3 — Oral Tradition (PostgreSQL append-only) # Architecture §3.7, Construction §1.5 {:ecto_sql, "~> 3.11.1"}, - {:postgrex, "~> 0.17.4"} + {:postgrex, "~> 0.17.4"}, + # Bigdei Kodesh — HTTP API layer (Shemot 28:2) + {:bandit, "~> 1.2"}, + {:plug, "~> 1.15"} ] end end diff --git a/vav_pipeline/mix.lock b/vav_pipeline/mix.lock index ec841427..e4568d01 100644 --- a/vav_pipeline/mix.lock +++ b/vav_pipeline/mix.lock @@ -1,10 +1,17 @@ %{ + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"}, "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/vav_pipeline/test/bigdei_kodesh_test.exs b/vav_pipeline/test/bigdei_kodesh_test.exs new file mode 100644 index 00000000..637412af --- /dev/null +++ b/vav_pipeline/test/bigdei_kodesh_test.exs @@ -0,0 +1,251 @@ +defmodule VavPipeline.BigdeiKodeshTest do + @moduledoc """ + Tests for the Bigdei Kodesh — the HTTP API layer. + + Shemot 28:2 — V'asita vigdei kodesh l'Aharon achicha, + l'khavod ul'tifaret. + + Four garments for four access tiers: + /peah/* — public, no auth + /courtyard/* — authenticated + /chatzer/* — full access + /kodesh/* — operator + """ + + use ExUnit.Case, async: false + + import Plug.Test + import Plug.Conn + + alias VavPipeline.BigdeiKodesh.Router + + @opts Router.init([]) + + # ============================================================ + # Pe'ah — Public Endpoint (Machaneh Yisrael) + # ============================================================ + describe "Pe'ah endpoint" do + test "GET /peah/ask without question returns usage" do + conn = + conn(:get, "/peah/ask") + |> Router.call(@opts) + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["message"] =~ "Shorashim" + assert body["usage"] =~ "/peah/ask" + end + + test "GET /peah/ask with question parameter processes it" do + conn = + conn(:get, "/peah/ask?question=What+is+bereishit") + |> Router.call(@opts) + + # Should return 200 or 503 (if waiting for provision) + assert conn.status in [200, 503] + end + + test "POST /peah/ask with valid question" do + conn = + conn(:post, "/peah/ask", Jason.encode!(%{question: "What is bereishit?"})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + + assert conn.status in [200, 503] + + body = Jason.decode!(conn.resp_body) + + if conn.status == 200 do + assert Map.has_key?(body, "answer") or Map.has_key?(body, "processing") + end + end + + test "POST /peah/ask without question returns 422" do + conn = + conn(:post, "/peah/ask", Jason.encode!(%{not_question: "wrong field"})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + + assert conn.status == 422 + body = Jason.decode!(conn.resp_body) + assert body["error"]["code"] == "KIYOR_VALIDATION_FAILED" + assert body["error"]["type"] == "purification_needed" + end + + test "POST /peah/ask with empty question returns 422" do + conn = + conn(:post, "/peah/ask", Jason.encode!(%{question: ""})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + + assert conn.status == 422 + end + end + + # ============================================================ + # Courtyard — Authenticated Endpoint + # ============================================================ + describe "Courtyard endpoint" do + test "POST /courtyard/offering without auth returns 401" do + conn = + conn(:post, "/courtyard/offering", Jason.encode!(%{content: "test"})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + + assert conn.status == 401 + body = Jason.decode!(conn.resp_body) + assert body["error"]["code"] == "UNAUTHORIZED" + end + + test "POST /courtyard/offering with auth but no content returns 422" do + conn = + conn(:post, "/courtyard/offering", Jason.encode!(%{offering_type: "shelamim"})) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer test-token") + |> Router.call(@opts) + + assert conn.status == 422 + body = Jason.decode!(conn.resp_body) + assert body["error"]["code"] == "KIYOR_VALIDATION_FAILED" + end + + test "POST /courtyard/offering with auth and content processes" do + conn = + conn(:post, "/courtyard/offering", Jason.encode!(%{ + content: "bereishit bara elohim", + offering_type: "shelamim" + })) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer test-token") + |> Router.call(@opts) + + assert conn.status in [200, 503] + + if conn.status == 200 do + body = Jason.decode!(conn.resp_body) + assert Map.has_key?(body, "offering_id") + assert Map.has_key?(body, "offering_receipt") + assert body["offering_receipt"]["type"] == "shelamim" + end + end + end + + # ============================================================ + # Chatzer — Sanhedrin Endpoint + # ============================================================ + describe "Chatzer endpoint" do + test "POST /chatzer/sanhedrin without auth returns 401" do + conn = + conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{case: "test case"})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + + assert conn.status == 401 + end + + test "POST /chatzer/sanhedrin without case returns 422" do + conn = + conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{not_case: "wrong field"})) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer test-token") + |> Router.call(@opts) + + assert conn.status == 422 + end + + test "POST /chatzer/sanhedrin with valid case deliberates" do + conn = + conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{ + case: "Is cooking on Shabbat permitted for pikuach nefesh?" + })) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer test-token") + |> Router.call(@opts) + + assert conn.status == 200 + + body = Jason.decode!(conn.resp_body) + assert Map.has_key?(body, "case_ref") + assert Map.has_key?(body, "deliberation") + assert body["deliberation"]["outcome"] in ["accepted", "rejected", "teiku"] + assert body["note"] =~ "not a binding ruling" + end + end + + # ============================================================ + # Kodesh — Operator Endpoint + # ============================================================ + describe "Kodesh endpoint" do + test "GET /kodesh/health returns health check" do + conn = + conn(:get, "/kodesh/health") + |> Router.call(@opts) + + assert conn.status in [200, 503] + + body = Jason.decode!(conn.resp_body) + assert Map.has_key?(body, "overall") + assert Map.has_key?(body, "checks") + assert body["overall"] in ["healthy", "degraded", "critical", "shabbat"] + + # Required health checks + assert Map.has_key?(body["checks"], "l1_hash") + assert Map.has_key?(body["checks"], "halt_monitor_alive") + assert Map.has_key?(body["checks"], "esh_tamid") + end + + test "GET /kodesh/status returns system status" do + conn = + conn(:get, "/kodesh/status") + |> Router.call(@opts) + + assert conn.status == 200 + + body = Jason.decode!(conn.resp_body) + assert Map.has_key?(body, "cloud_state") + assert Map.has_key?(body, "components") + assert Map.has_key?(body, "halt_status") + assert Map.has_key?(body, "signal_count") + end + end + + # ============================================================ + # Error handling — Chatat protocol + # ============================================================ + describe "error handling" do + test "unknown endpoint returns 404" do + conn = + conn(:get, "/unknown/path") + |> Router.call(@opts) + + assert conn.status == 404 + body = Jason.decode!(conn.resp_body) + assert body["error"]["code"] == "NOT_FOUND" + end + + test "all errors follow chatat format" do + conn = + conn(:post, "/peah/ask", Jason.encode!(%{})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + + assert conn.status == 422 + body = Jason.decode!(conn.resp_body) + + # Chatat format: error.code, error.type, error.message, error.guidance + assert Map.has_key?(body["error"], "code") + assert Map.has_key?(body["error"], "type") + assert Map.has_key?(body["error"], "message") + assert Map.has_key?(body["error"], "guidance") + end + + test "all responses are JSON" do + conn = + conn(:get, "/kodesh/health") + |> Router.call(@opts) + + content_type = get_resp_header(conn, "content-type") + assert Enum.any?(content_type, &String.contains?(&1, "application/json")) + end + end +end