From 671d45625c0ec3465d976060972d05a9536d633e Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Wed, 18 Mar 2026 08:59:08 -0400 Subject: [PATCH] =?UTF-8?q?Protection=20layer:=20Arei=20Miklat,=20Eglah=20?= =?UTF-8?q?Arufah,=20Bikurim=20=E2=80=94=20Steps=2027-29?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takhin lekha haderekh — Devarim 19:3. "Prepare the road" before the one who needs refuge arrives. Protection before production. Arei Miklat (Cities of Refuge — Bamidbar 35, Devarim 19): - GenServer with refuge registry, affected party protection, audit trail - enter_refuge/2 isolates component but protects from reactive destruction - protect_affected/2 blocks processing paths that caused harm - release/2 requires :sanhedrin_deliberation authority — ad mot hakohen hagadol - Chatzotzrot assembly_leaders signal on every refuge entry - 15 tests: entry, double-entry prevention, affected party blocking, unauthorized release rejection, audit trail, chatzotzrot signaling Eglah Arufah (Accountability — Devarim 21:1-9): - GenServer with permanent (append-only) declaration registry - initiate/3: nearest component declares what it processed, skipped, missed - Only for :unknown source — rejects known-source incidents - "Yadeinu lo shafkhu" — accountability for what was NOT provided - 9 tests: declaration recording, source validation, multi-component declarations, chatzotzrot signaling, filtering, counting Bikurim (First Fruits — Devarim 26:1-11): - GenServer with offering registry - offer/4: submit first output with required narrative (mikra bikurim) - Rejects empty narrative ("Arami oved avi" — story is required) - Rejects nil first output (there must be fruit to offer) - Delegates to Specification.evaluate_bikurim/1 when available - approved?/1 gate for deployment readiness - 11 tests: acceptance, narrative validation, nil output rejection, registry, status filtering, approval checking, counting All three added to supervision tree in application.ex. 160 Elixir tests pass, 0 failures. Co-Authored-By: Claude Opus 4.6 --- vav_pipeline/lib/vav_pipeline/application.ex | 6 + vav_pipeline/lib/vav_pipeline/arei_miklat.ex | 346 ++++++++++++++++++ vav_pipeline/lib/vav_pipeline/bikurim.ex | 236 ++++++++++++ vav_pipeline/lib/vav_pipeline/eglah_arufah.ex | 194 ++++++++++ vav_pipeline/test/arei_miklat_test.exs | 172 +++++++++ vav_pipeline/test/bikurim_test.exs | 150 ++++++++ vav_pipeline/test/eglah_arufah_test.exs | 126 +++++++ 7 files changed, 1230 insertions(+) create mode 100644 vav_pipeline/lib/vav_pipeline/arei_miklat.ex create mode 100644 vav_pipeline/lib/vav_pipeline/bikurim.ex create mode 100644 vav_pipeline/lib/vav_pipeline/eglah_arufah.ex create mode 100644 vav_pipeline/test/arei_miklat_test.exs create mode 100644 vav_pipeline/test/bikurim_test.exs create mode 100644 vav_pipeline/test/eglah_arufah_test.exs diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 83a25d21..bafc35e7 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -5,6 +5,9 @@ defmodule VavPipeline.Application do Starts the supervision tree with: - Repo (L3 PostgreSQL — oral tradition persistence) - Chatzotzrot (silver trumpets — signal system) + - AreiMiklat (cities of refuge — protection protocol) + - EglahArufah (accountability for unknown harm) + - Bikurim (first fruits — release protocol) - ShabbatTimer (mandatory cessation monitor) - ShoreshEnginePort (Erlang Port to Haskell engine, if binary found) - TalmudicEngineSocket (Unix socket to Prolog engine) @@ -27,6 +30,9 @@ defmodule VavPipeline.Application do children = [ {VavPipeline.Chatzotzrot, []}, + {VavPipeline.AreiMiklat, []}, + {VavPipeline.EglahArufah, []}, + {VavPipeline.Bikurim, []}, {VavPipeline.ShabbatTimer, []} ] |> maybe_add_repo() diff --git a/vav_pipeline/lib/vav_pipeline/arei_miklat.ex b/vav_pipeline/lib/vav_pipeline/arei_miklat.ex new file mode 100644 index 00000000..ddcb1913 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/arei_miklat.ex @@ -0,0 +1,346 @@ +defmodule VavPipeline.AreiMiklat do + @moduledoc """ + Cities of Refuge — Bamidbar 35:11-28, Devarim 19:1-13. + + Architecture §3.14, Construction §4.8: + Protection protocol for cases where the system's output causes + real harm — not just error. Protects BOTH the affected party + AND the responsible component from reactive destruction while + accountability is determined. + + V'hikritem lakhem arim, arei miklat tihyeinah lakhem, + v'nas shamah rotze'ach, makeh nefesh bishgagah. + — Bamidbar 35:11 + + "You shall designate cities for yourselves, cities of refuge + they shall be for you, and a manslayer shall flee there — + one who strikes a person unintentionally." + + Three principles from Torah: + 1. Takhin lekha haderekh (Devarim 19:3) — prepare the road + BEFORE the one who needs refuge arrives. + 2. Bivli da'at (Devarim 19:4) — refuge is for harm without + intent, without knowledge. Not punishment. Protection. + 3. Ad mot hakohen hagadol (Bamidbar 35:25) — release comes + through an external event (Sanhedrin judgment), not a timer. + Neither the refugee nor the avenger controls release. + + Protocol: + 1. Harm event detected → component enters refuge (isolated, protected) + 2. Affected party protected from further exposure + 3. Chatzotzrot signal alerts kohanim (operators) + 4. Sanhedrin deliberation determines release — not automatic, not arbitrary + 5. All refuge events permanently recorded (append-only audit) + """ + + use GenServer + + require Logger + + alias VavPipeline.Chatzotzrot + + @type refuge_status :: :in_refuge | :released + @type incident_type :: :unintentional_harm | :processing_error | :data_exposure | :integrity_breach + + @type incident :: %{ + id: String.t(), + type: incident_type(), + description: String.t(), + affected_party: String.t() | nil, + processing_path: [String.t()], + detected_at: DateTime.t() + } + + @type refuge_record :: %{ + component_id: String.t(), + incident: incident(), + status: refuge_status(), + entered_at: DateTime.t(), + released_at: DateTime.t() | nil, + release_judgment: map() | nil + } + + # ============================================================ + # Client API + # ============================================================ + + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc """ + Enter refuge — a component that caused unintentional harm. + + Bamidbar 35:11 — v'nas shamah rotze'ach, makeh nefesh bishgagah. + + The component is isolated from normal operation but PROTECTED + from reactive destruction. No one deletes a component under refuge. + + Returns {:ok, refuge_record} or {:error, reason}. + """ + @spec enter_refuge(String.t(), incident()) :: {:ok, refuge_record()} | {:error, atom()} + def enter_refuge(component_id, incident) when is_binary(component_id) and is_map(incident) do + GenServer.call(__MODULE__, {:enter_refuge, component_id, incident}) + end + + @doc """ + Protect an affected party from further exposure to the same processing path. + + Bamidbar 35:12 — v'lo yamut harotze'ach ad amdo lifnei ha'edah lamishpat. + The one who caused harm is protected; the one harmed is also protected. + """ + @spec protect_affected(String.t(), incident()) :: {:ok, map()} | {:error, atom()} + def protect_affected(party_id, incident) when is_binary(party_id) and is_map(incident) do + GenServer.call(__MODULE__, {:protect_affected, party_id, incident}) + end + + @doc """ + Release a component from refuge — requires Sanhedrin judgment. + + Bamidbar 35:25 — ad mot hakohen hagadol. + Release is not a timer. Not a governance board's whim. + It is a judgment that the era of the harm has passed. + + The judgment must include: + - authority: :sanhedrin_deliberation + - reason: what has changed (the "death of the kohen gadol") + - grounding: Torah reference that grounds the judgment + + Returns {:ok, updated_record} or {:error, reason}. + """ + @spec release(String.t(), map()) :: {:ok, refuge_record()} | {:error, atom()} + def release(component_id, judgment) when is_binary(component_id) and is_map(judgment) do + GenServer.call(__MODULE__, {:release, component_id, judgment}) + end + + @doc """ + Check if a component is currently in refuge. + """ + @spec in_refuge?(String.t()) :: boolean() + def in_refuge?(component_id) do + GenServer.call(__MODULE__, {:in_refuge?, component_id}) + end + + @doc """ + Check if a processing path is blocked for a party. + """ + @spec path_blocked?(String.t(), [String.t()]) :: boolean() + def path_blocked?(party_id, processing_path) do + GenServer.call(__MODULE__, {:path_blocked?, party_id, processing_path}) + end + + @doc """ + Get all current refuge records. + """ + @spec list_refugees() :: [refuge_record()] + def list_refugees do + GenServer.call(__MODULE__, :list_refugees) + end + + @doc """ + Get all active protections for affected parties. + """ + @spec list_protections() :: [map()] + def list_protections do + GenServer.call(__MODULE__, :list_protections) + end + + @doc """ + Get the full audit trail — all refuge events ever recorded. + Append-only. Never deleted. + """ + @spec audit_trail() :: [map()] + def audit_trail do + GenServer.call(__MODULE__, :audit_trail) + end + + # ============================================================ + # GenServer callbacks + # ============================================================ + + @impl true + def init(_opts) do + state = %{ + # Active refuge records: %{component_id => refuge_record} + refugees: %{}, + # Active protections: %{party_id => [%{path: [...], incident_id: ...}]} + protections: %{}, + # Permanent audit trail — append-only, never deleted + audit: [] + } + + Logger.info("AreiMiklat: three cities of refuge prepared — takhin lekha haderekh") + {:ok, state} + end + + @impl true + def handle_call({:enter_refuge, component_id, incident}, _from, state) do + if Map.has_key?(state.refugees, component_id) do + {:reply, {:error, :already_in_refuge}, state} + else + now = DateTime.utc_now() + + incident = Map.put_new(incident, :detected_at, now) + + record = %{ + component_id: component_id, + incident: incident, + status: :in_refuge, + entered_at: now, + released_at: nil, + release_judgment: nil + } + + audit_entry = %{ + event: :refuge_entry, + component_id: component_id, + incident: incident, + timestamp: now + } + + # Sound the trumpets — alert the kohanim + Chatzotzrot.signal(:assembly_leaders, + reason: "Refuge entry: #{component_id} — #{Map.get(incident, :description, "unintentional harm")}" + ) + + Logger.warning( + "AreiMiklat: #{component_id} entered refuge — " <> + "#{Map.get(incident, :type, :unknown)} — #{Map.get(incident, :description, "no description")}" + ) + + new_state = %{ + state + | refugees: Map.put(state.refugees, component_id, record), + audit: [audit_entry | state.audit] + } + + {:reply, {:ok, record}, new_state} + end + end + + @impl true + def handle_call({:protect_affected, party_id, incident}, _from, state) do + now = DateTime.utc_now() + path = Map.get(incident, :processing_path, []) + incident_id = Map.get(incident, :id, "unknown") + + protection = %{ + party_id: party_id, + blocked_path: path, + incident_id: incident_id, + protected_at: now + } + + audit_entry = %{ + event: :affected_party_protected, + party_id: party_id, + incident_id: incident_id, + blocked_path: path, + timestamp: now + } + + Logger.info("AreiMiklat: affected party #{party_id} protected from path #{inspect(path)}") + + existing = Map.get(state.protections, party_id, []) + + new_state = %{ + state + | protections: Map.put(state.protections, party_id, [protection | existing]), + audit: [audit_entry | state.audit] + } + + {:reply, {:ok, protection}, new_state} + end + + @impl true + def handle_call({:release, component_id, judgment}, _from, state) do + case Map.get(state.refugees, component_id) do + nil -> + {:reply, {:error, :not_in_refuge}, state} + + record -> + # Only Sanhedrin deliberation can release — ad mot hakohen hagadol + case Map.get(judgment, :authority) do + :sanhedrin_deliberation -> + now = DateTime.utc_now() + + updated = %{ + record + | status: :released, + released_at: now, + release_judgment: judgment + } + + audit_entry = %{ + event: :refuge_release, + component_id: component_id, + judgment: judgment, + timestamp: now + } + + Logger.info( + "AreiMiklat: #{component_id} released from refuge — " <> + "#{Map.get(judgment, :reason, "era ended")}" + ) + + new_state = %{ + state + | refugees: Map.delete(state.refugees, component_id), + audit: [audit_entry | state.audit] + } + + {:reply, {:ok, updated}, new_state} + + other -> + Logger.warning( + "AreiMiklat: unauthorized release attempt for #{component_id} — " <> + "authority #{inspect(other)} is not :sanhedrin_deliberation" + ) + + {:reply, {:error, :unauthorized_release}, state} + end + end + end + + @impl true + def handle_call({:in_refuge?, component_id}, _from, state) do + {:reply, Map.has_key?(state.refugees, component_id), state} + end + + @impl true + def handle_call({:path_blocked?, party_id, path}, _from, state) do + blocked = + case Map.get(state.protections, party_id) do + nil -> + false + + protections -> + Enum.any?(protections, fn p -> + # A path is blocked if the blocked_path is a prefix of the requested path + List.starts_with?(path, p.blocked_path) + end) + end + + {:reply, blocked, state} + end + + @impl true + def handle_call(:list_refugees, _from, state) do + {:reply, Map.values(state.refugees), state} + end + + @impl true + def handle_call(:list_protections, _from, state) do + all = + state.protections + |> Enum.flat_map(fn {_party, protections} -> protections end) + + {:reply, all, state} + end + + @impl true + def handle_call(:audit_trail, _from, state) do + {:reply, state.audit, state} + end +end diff --git a/vav_pipeline/lib/vav_pipeline/bikurim.ex b/vav_pipeline/lib/vav_pipeline/bikurim.ex new file mode 100644 index 00000000..75847ee9 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/bikurim.ex @@ -0,0 +1,236 @@ +defmodule VavPipeline.Bikurim do + @moduledoc """ + First Fruits — Release Protocol. Devarim 26:1-11. + + Architecture §3.16: + First-output review process for all new capabilities. + Before a new feature serves users, its first product is + offered to the specification for evaluation, accompanied + by narrative. + + Arami oved avi — "A wandering Aramean was my father." + — Devarim 26:5 + + The offering includes NARRATIVE. Not just what was produced, + but the story of how it was produced and what lineage it carries. + The farmer does not just bring fruit. He tells the story of + his ancestors' wandering, Egypt, the exodus, and arrival. + + Protocol: + 1. New capability built → first output produced + 2. Builder provides narrative (the mikra bikurim): + - What was built + - What specification authorized it + - What lineage it carries (which existing components it depends on) + - What Torah grounds it + 3. First output evaluated against specification + 4. Accepted → recorded in registry, approved for production + 5. Rejected → recorded with reason, not deployed + + The offering is placed "before Hashem your God" — lifnei Hashem + Elohekha (Devarim 26:10). The specification judges. Not the builder. + Not the operator. The specification. + """ + + use GenServer + + require Logger + + alias VavPipeline.Specification + + @type offering_status :: :accepted | :rejected | :pending + @type offering :: %{ + capability: String.t(), + first_output: any(), + narrative: String.t(), + lineage: [String.t()], + torah_grounding: String.t() | nil, + offered_at: DateTime.t(), + status: offering_status(), + evaluation: map() | nil + } + + # ============================================================ + # Client API + # ============================================================ + + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc """ + Offer first fruit — submit a new capability's first output for review. + + Devarim 26:2 — V'lakachta mereshit kol p'ri ha'adamah... + "You shall take of the first of every fruit of the ground..." + + Parameters: + - capability_name: identifier for the new capability + - first_output: the actual first product + - narrative: the mikra bikurim — the story of how this was built + - opts: keyword list with optional :lineage and :torah_grounding + + Returns {:ok, :approved_for_deployment, offering} or + {:error, :specification_mismatch, reason, offering}. + """ + @spec offer(String.t(), any(), String.t(), keyword()) :: + {:ok, :approved_for_deployment, offering()} + | {:error, :specification_mismatch, String.t(), offering()} + def offer(capability_name, first_output, narrative, opts \\ []) + when is_binary(capability_name) and is_binary(narrative) do + GenServer.call(__MODULE__, {:offer, capability_name, first_output, narrative, opts}) + end + + @doc """ + Get all offerings in the registry. + """ + @spec registry() :: [offering()] + def registry do + GenServer.call(__MODULE__, :registry) + end + + @doc """ + Get offerings by status. + """ + @spec by_status(offering_status()) :: [offering()] + def by_status(status) when status in [:accepted, :rejected, :pending] do + GenServer.call(__MODULE__, {:by_status, status}) + end + + @doc """ + Check if a capability has been accepted (approved for deployment). + """ + @spec approved?(String.t()) :: boolean() + def approved?(capability_name) do + GenServer.call(__MODULE__, {:approved?, capability_name}) + end + + @doc """ + Count offerings by status. + """ + @spec count(offering_status() | :all) :: non_neg_integer() + def count(filter \\ :all) do + GenServer.call(__MODULE__, {:count, filter}) + end + + # ============================================================ + # GenServer callbacks + # ============================================================ + + @impl true + def init(_opts) do + state = %{ + # All offerings — append-only registry + offerings: [] + } + + Logger.info("Bikurim: first fruits protocol ready — reshit kol p'ri ha'adamah") + {:ok, state} + end + + @impl true + def handle_call({:offer, capability_name, first_output, narrative, opts}, _from, state) do + now = DateTime.utc_now() + lineage = Keyword.get(opts, :lineage, []) + torah_grounding = Keyword.get(opts, :torah_grounding) + + offering = %{ + capability: capability_name, + first_output: first_output, + narrative: narrative, + lineage: lineage, + torah_grounding: torah_grounding, + offered_at: now, + status: :pending, + evaluation: nil + } + + # Evaluate against specification — lifnei Hashem Elohekha + case evaluate_against_specification(offering) do + :accepted -> + accepted = %{offering | status: :accepted, evaluation: %{result: :accepted}} + + Logger.info( + "Bikurim: #{capability_name} accepted — first fruit approved for deployment" + ) + + new_state = %{state | offerings: [accepted | state.offerings]} + {:reply, {:ok, :approved_for_deployment, accepted}, new_state} + + {:rejected, reason} -> + rejected = %{ + offering + | status: :rejected, + evaluation: %{result: :rejected, reason: reason} + } + + Logger.warning( + "Bikurim: #{capability_name} rejected — #{reason}" + ) + + new_state = %{state | offerings: [rejected | state.offerings]} + {:reply, {:error, :specification_mismatch, reason, rejected}, new_state} + end + end + + @impl true + def handle_call(:registry, _from, state) do + {:reply, state.offerings, state} + end + + @impl true + def handle_call({:by_status, status}, _from, state) do + matching = Enum.filter(state.offerings, &(&1.status == status)) + {:reply, matching, state} + end + + @impl true + def handle_call({:approved?, capability_name}, _from, state) do + approved = + Enum.any?(state.offerings, fn o -> + o.capability == capability_name and o.status == :accepted + end) + + {:reply, approved, state} + end + + @impl true + def handle_call({:count, :all}, _from, state) do + {:reply, length(state.offerings), state} + end + + @impl true + def handle_call({:count, status}, _from, state) do + count = Enum.count(state.offerings, &(&1.status == status)) + {:reply, count, state} + end + + # ============================================================ + # Specification evaluation + # ============================================================ + + defp evaluate_against_specification(offering) do + # The specification evaluates the offering. Three checks: + # 1. Does the capability have a narrative? (Arami oved avi — story is required) + # 2. Does the first output conform to the specification? + # 3. Is the Torah grounding valid? + + cond do + # No narrative — the bikurim requires the mikra (recitation) + String.trim(offering.narrative) == "" -> + {:rejected, "No narrative provided — Devarim 26:5 requires the mikra bikurim"} + + # Check against specification module if available + Code.ensure_loaded?(Specification) and function_exported?(Specification, :evaluate_bikurim, 1) -> + apply(Specification, :evaluate_bikurim, [offering]) + + # Default: accept if narrative is present and first_output is not nil + is_nil(offering.first_output) -> + {:rejected, "No first output provided — there must be fruit to offer"} + + true -> + :accepted + end + end +end diff --git a/vav_pipeline/lib/vav_pipeline/eglah_arufah.ex b/vav_pipeline/lib/vav_pipeline/eglah_arufah.ex new file mode 100644 index 00000000..2eb1f3a1 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/eglah_arufah.ex @@ -0,0 +1,194 @@ +defmodule VavPipeline.EglahArufah do + @moduledoc """ + Accountability Protocol — Devarim 21:1-9. + + Architecture §3.15: + Formal accountability for harm of UNKNOWN origin. + The nearest component documents what it processed, + what it did not check, and what it could have caught. + + Yadeinu lo shafkhu et hadam hazeh, v'eineinu lo ra'u. + — Devarim 21:7 + + "Our hands did not shed this blood, and our eyes did not see." + The Talmud asks: would we suspect the elders of shedding blood? + Rather, the declaration means: we did not let this person leave + without food (provision) or escort (protection). + Accountability for what was NOT provided. + + Protocol: + 1. Harm of unknown origin detected + 2. Find the nearest component in the processing chain + 3. That component makes a formal declaration: + - What it processed (recent operations) + - What it did not check (skipped validations) + - What it could have caught (missed catches) + 4. Declaration permanently recorded — never deleted + 5. Chatzotzrot signals governance (assembly_leaders) + + The eglah arufah is broken — the heifer's neck is broken in the + nachal eitan (a harsh valley). The system acknowledges that + something was NOT done. The accountability is permanent. + """ + + use GenServer + + require Logger + + alias VavPipeline.Chatzotzrot + + @type harm_incident :: %{ + id: String.t(), + description: String.t(), + approximate_location: String.t(), + detected_at: DateTime.t(), + source: :unknown + } + + @type declaration :: %{ + incident_id: String.t(), + component_id: String.t(), + processed: [String.t()], + not_checked: [String.t()], + could_have_caught: [String.t()], + declaration_text: String.t(), + declared_at: DateTime.t() + } + + # ============================================================ + # Client API + # ============================================================ + + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc """ + Initiate the eglah arufah protocol for harm of unknown origin. + + Devarim 21:1 — Ki yimatze chalal ba'adamah... + "When a corpse is found in the land... and it is not known who struck him." + + The nearest component to the harm must make a formal declaration + of what it did and did not do. This is accountability for failure + to protect, not accusation of causing harm. + + Parameters: + - incident: the harm event (source must be :unknown) + - nearest_component_id: which component is nearest + - report: map with :processed, :not_checked, :could_have_caught lists + + Returns {:ok, declaration} or {:error, reason}. + """ + @spec initiate(harm_incident(), String.t(), map()) :: + {:ok, declaration()} | {:error, atom()} + def initiate(incident, nearest_component_id, report) + when is_map(incident) and is_binary(nearest_component_id) and is_map(report) do + GenServer.call(__MODULE__, {:initiate, incident, nearest_component_id, report}) + end + + @doc """ + Get all declarations — the permanent record. + These are never deleted. The eglah arufah is permanent accountability. + """ + @spec declarations() :: [declaration()] + def declarations do + GenServer.call(__MODULE__, :declarations) + end + + @doc """ + Get declarations for a specific incident. + """ + @spec declarations_for(String.t()) :: [declaration()] + def declarations_for(incident_id) when is_binary(incident_id) do + GenServer.call(__MODULE__, {:declarations_for, incident_id}) + end + + @doc """ + Count total declarations. + """ + @spec count() :: non_neg_integer() + def count do + GenServer.call(__MODULE__, :count) + end + + # ============================================================ + # GenServer callbacks + # ============================================================ + + @impl true + def init(_opts) do + state = %{ + # Permanent declarations — append-only, never deleted + declarations: [] + } + + Logger.info("EglahArufah: accountability protocol ready — nachal eitan prepared") + {:ok, state} + end + + @impl true + def handle_call({:initiate, incident, nearest_component_id, report}, _from, state) do + # Validate source is unknown — eglah arufah is ONLY for unknown harm + case Map.get(incident, :source) do + :unknown -> + now = DateTime.utc_now() + incident = Map.put_new(incident, :detected_at, now) + incident_id = Map.get(incident, :id, "incident_#{:erlang.unique_integer([:positive])}") + + declaration = %{ + incident_id: incident_id, + component_id: nearest_component_id, + processed: Map.get(report, :processed, []), + not_checked: Map.get(report, :not_checked, []), + could_have_caught: Map.get(report, :could_have_caught, []), + declaration_text: + "This component (#{nearest_component_id}) did not cause this harm. " <> + "This component acknowledges proximity to the harm. " <> + "This component reports what it did and did not do.", + declared_at: now + } + + # Sound the trumpets — alert governance + Chatzotzrot.signal(:assembly_leaders, + reason: + "Eglah Arufah: unknown harm near #{nearest_component_id} — #{Map.get(incident, :description, "unknown")}" + ) + + Logger.warning( + "EglahArufah: declaration by #{nearest_component_id} — " <> + "processed #{length(declaration.processed)} operations, " <> + "skipped #{length(declaration.not_checked)} checks, " <> + "could have caught #{length(declaration.could_have_caught)} issues" + ) + + new_state = %{state | declarations: [declaration | state.declarations]} + {:reply, {:ok, declaration}, new_state} + + other -> + Logger.warning( + "EglahArufah: rejected — source is #{inspect(other)}, not :unknown. " <> + "Eglah arufah is only for harm of unknown origin." + ) + + {:reply, {:error, :source_not_unknown}, state} + end + end + + @impl true + def handle_call(:declarations, _from, state) do + {:reply, state.declarations, state} + end + + @impl true + def handle_call({:declarations_for, incident_id}, _from, state) do + matching = Enum.filter(state.declarations, &(&1.incident_id == incident_id)) + {:reply, matching, state} + end + + @impl true + def handle_call(:count, _from, state) do + {:reply, length(state.declarations), state} + end +end diff --git a/vav_pipeline/test/arei_miklat_test.exs b/vav_pipeline/test/arei_miklat_test.exs new file mode 100644 index 00000000..133a334e --- /dev/null +++ b/vav_pipeline/test/arei_miklat_test.exs @@ -0,0 +1,172 @@ +defmodule VavPipeline.AreiMiklatTest do + @moduledoc """ + Tests for the Arei Miklat (Cities of Refuge) protection protocol. + + Bamidbar 35:11 — V'hikritem lakhem arim, arei miklat tihyeinah lakhem. + Devarim 19:3 — Takhin lekha haderekh — prepare the road. + """ + + use ExUnit.Case, async: false + + alias VavPipeline.AreiMiklat + + @test_incident %{ + id: "incident_001", + type: :unintentional_harm, + description: "Korban processor returned malformed output to user", + affected_party: "user_42", + processing_path: ["korban_processor", "sanhedrin", "output_formatter"] + } + + # ============================================================ + # Refuge entry — Bamidbar 35:11 + # ============================================================ + describe "enter_refuge" do + test "component enters refuge and is recorded" do + {:ok, record} = AreiMiklat.enter_refuge("korban_processor", @test_incident) + + assert record.component_id == "korban_processor" + assert record.status == :in_refuge + assert record.incident.type == :unintentional_harm + assert record.entered_at != nil + assert record.released_at == nil + assert record.release_judgment == nil + end + + test "component in refuge is detectable" do + AreiMiklat.enter_refuge("test_component_a", @test_incident) + assert AreiMiklat.in_refuge?("test_component_a") + refute AreiMiklat.in_refuge?("nonexistent_component") + end + + test "cannot enter refuge twice" do + AreiMiklat.enter_refuge("double_entry_test", @test_incident) + assert {:error, :already_in_refuge} = AreiMiklat.enter_refuge("double_entry_test", @test_incident) + end + + test "refuge entry appears in audit trail" do + AreiMiklat.enter_refuge("audit_test_component", @test_incident) + trail = AreiMiklat.audit_trail() + + entry = Enum.find(trail, &(&1.event == :refuge_entry and &1.component_id == "audit_test_component")) + assert entry != nil + assert entry.timestamp != nil + end + + test "chatzotzrot assembly_leaders signal is sent on refuge entry" do + # Subscribe to kohanim group to receive assembly_leaders signals + VavPipeline.Chatzotzrot.subscribe(:kohanim) + + AreiMiklat.enter_refuge("signal_test_component", @test_incident) + + assert_receive {:chatzotzrot, %{signal_type: :assembly_leaders}} + end + + test "refuge entry records detected_at if not provided" do + incident = Map.delete(@test_incident, :detected_at) + {:ok, record} = AreiMiklat.enter_refuge("detected_at_test", incident) + assert record.incident.detected_at != nil + end + end + + # ============================================================ + # Affected party protection — Bamidbar 35:12 + # ============================================================ + describe "protect_affected" do + test "affected party's processing path is blocked" do + AreiMiklat.protect_affected("user_42", @test_incident) + + assert AreiMiklat.path_blocked?( + "user_42", + ["korban_processor", "sanhedrin", "output_formatter"] + ) + end + + test "unrelated path is not blocked" do + AreiMiklat.protect_affected("user_43", @test_incident) + refute AreiMiklat.path_blocked?("user_43", ["shoresh_engine", "talmudic_engine"]) + end + + test "protection appears in audit trail" do + AreiMiklat.protect_affected("user_audit", @test_incident) + trail = AreiMiklat.audit_trail() + + entry = Enum.find(trail, &(&1.event == :affected_party_protected and &1.party_id == "user_audit")) + assert entry != nil + end + + test "protection is listed" do + AreiMiklat.protect_affected("listed_user", @test_incident) + protections = AreiMiklat.list_protections() + assert Enum.any?(protections, &(&1.party_id == "listed_user")) + end + end + + # ============================================================ + # Release — ad mot hakohen hagadol (Bamidbar 35:25) + # ============================================================ + describe "release" do + test "release with sanhedrin_deliberation succeeds" do + AreiMiklat.enter_refuge("release_test", @test_incident) + assert AreiMiklat.in_refuge?("release_test") + + judgment = %{ + authority: :sanhedrin_deliberation, + reason: "Processing path corrected — output validation added", + grounding: "Devarim 19:6 — the road has been prepared" + } + + {:ok, released} = AreiMiklat.release("release_test", judgment) + assert released.status == :released + assert released.released_at != nil + assert released.release_judgment.authority == :sanhedrin_deliberation + + refute AreiMiklat.in_refuge?("release_test") + end + + test "release without sanhedrin authority is rejected" do + AreiMiklat.enter_refuge("unauthorized_release", @test_incident) + + judgment = %{ + authority: :operator_decision, + reason: "Operator wants it back" + } + + assert {:error, :unauthorized_release} = AreiMiklat.release("unauthorized_release", judgment) + assert AreiMiklat.in_refuge?("unauthorized_release") + end + + test "release of non-refugee returns error" do + assert {:error, :not_in_refuge} = + AreiMiklat.release("never_entered", %{authority: :sanhedrin_deliberation, reason: "test"}) + end + + test "release appears in audit trail" do + AreiMiklat.enter_refuge("release_audit", @test_incident) + + AreiMiklat.release("release_audit", %{ + authority: :sanhedrin_deliberation, + reason: "Era ended" + }) + + trail = AreiMiklat.audit_trail() + + release_entry = + Enum.find(trail, &(&1.event == :refuge_release and &1.component_id == "release_audit")) + + assert release_entry != nil + assert release_entry.judgment.reason == "Era ended" + end + end + + # ============================================================ + # Listing + # ============================================================ + describe "listing" do + test "list_refugees returns current refugees" do + AreiMiklat.enter_refuge("listed_refugee", @test_incident) + refugees = AreiMiklat.list_refugees() + assert Enum.any?(refugees, &(&1.component_id == "listed_refugee")) + end + end +end diff --git a/vav_pipeline/test/bikurim_test.exs b/vav_pipeline/test/bikurim_test.exs new file mode 100644 index 00000000..6d588869 --- /dev/null +++ b/vav_pipeline/test/bikurim_test.exs @@ -0,0 +1,150 @@ +defmodule VavPipeline.BikurimTest do + @moduledoc """ + Tests for the Bikurim (First Fruits / Release Protocol). + + Devarim 26:5 — Arami oved avi. + "A wandering Aramean was my father." + The offering requires narrative — not just the fruit, + but the story of how it was produced. + """ + + use ExUnit.Case, async: false + + alias VavPipeline.Bikurim + + # ============================================================ + # Offering — Devarim 26:2 + # ============================================================ + describe "offer" do + test "accepted offering with narrative and output" do + {:ok, :approved_for_deployment, offering} = + Bikurim.offer( + "chatzotzrot_genserver", + %{signal_types: 5, routing: true, zikaron: true}, + "Built as GenServer with BEAM PubSub routing. " <> + "Five signals mapped to five trumpet patterns per Bamidbar 10. " <> + "Signal history (zikaron) records every signal ever sounded.", + lineage: ["chatzotzrot_stub", "application_supervisor"], + torah_grounding: "Bamidbar 10:1-10" + ) + + assert offering.capability == "chatzotzrot_genserver" + assert offering.status == :accepted + assert offering.narrative != "" + assert offering.lineage == ["chatzotzrot_stub", "application_supervisor"] + assert offering.torah_grounding == "Bamidbar 10:1-10" + assert offering.offered_at != nil + end + + test "rejected when no narrative provided — mikra bikurim required" do + {:error, :specification_mismatch, reason, offering} = + Bikurim.offer( + "empty_narrative_test", + %{some: "output"}, + "" + ) + + assert offering.status == :rejected + assert String.contains?(reason, "narrative") + end + + test "rejected when no first output" do + {:error, :specification_mismatch, reason, offering} = + Bikurim.offer( + "nil_output_test", + nil, + "This capability does nothing yet" + ) + + assert offering.status == :rejected + assert String.contains?(reason, "first output") + end + + test "whitespace-only narrative is rejected" do + {:error, :specification_mismatch, _reason, _offering} = + Bikurim.offer( + "whitespace_narrative", + %{output: true}, + " \n\t " + ) + end + end + + # ============================================================ + # Registry + # ============================================================ + describe "registry" do + test "offerings are recorded in registry" do + Bikurim.offer( + "registry_test_cap", + %{works: true}, + "Test capability for registry verification" + ) + + registry = Bikurim.registry() + assert Enum.any?(registry, &(&1.capability == "registry_test_cap")) + end + + test "rejected offerings are also in registry" do + Bikurim.offer("rejected_registry", nil, "Has narrative but no output") + + registry = Bikurim.registry() + rejected = Enum.find(registry, &(&1.capability == "rejected_registry")) + assert rejected != nil + assert rejected.status == :rejected + end + + test "by_status filters correctly" do + Bikurim.offer("status_accepted", %{ok: true}, "Good capability") + Bikurim.offer("status_rejected", nil, "Bad capability") + + accepted = Bikurim.by_status(:accepted) + assert Enum.any?(accepted, &(&1.capability == "status_accepted")) + refute Enum.any?(accepted, &(&1.capability == "status_rejected")) + + rejected = Bikurim.by_status(:rejected) + assert Enum.any?(rejected, &(&1.capability == "status_rejected")) + end + end + + # ============================================================ + # Approval check + # ============================================================ + describe "approved?" do + test "accepted capability is approved" do + Bikurim.offer("approved_test", %{ready: true}, "Fully tested capability") + assert Bikurim.approved?("approved_test") + end + + test "rejected capability is not approved" do + Bikurim.offer("not_approved_test", nil, "Has narrative but no output") + refute Bikurim.approved?("not_approved_test") + end + + test "unknown capability is not approved" do + refute Bikurim.approved?("never_offered_capability") + end + end + + # ============================================================ + # Counting + # ============================================================ + describe "count" do + test "counts all offerings" do + initial = Bikurim.count(:all) + Bikurim.offer("count_all_test", %{x: 1}, "Counting test") + assert Bikurim.count(:all) == initial + 1 + end + + test "counts by status" do + initial_accepted = Bikurim.count(:accepted) + initial_rejected = Bikurim.count(:rejected) + + Bikurim.offer("count_accepted", %{x: 1}, "Accepted offering") + Bikurim.offer("count_rejected", nil, "Rejected offering") + + assert Bikurim.count(:accepted) == initial_accepted + 1 + assert Bikurim.count(:rejected) == initial_rejected + 1 + end + end +end diff --git a/vav_pipeline/test/eglah_arufah_test.exs b/vav_pipeline/test/eglah_arufah_test.exs new file mode 100644 index 00000000..a24ff318 --- /dev/null +++ b/vav_pipeline/test/eglah_arufah_test.exs @@ -0,0 +1,126 @@ +defmodule VavPipeline.EglahArufahTest do + @moduledoc """ + Tests for the Eglah Arufah (Accountability Protocol). + + Devarim 21:7 — Yadeinu lo shafkhu et hadam hazeh, v'eineinu lo ra'u. + "Our hands did not shed this blood, and our eyes did not see." + Accountability for what was NOT provided. Not accusation. + """ + + use ExUnit.Case, async: false + + alias VavPipeline.EglahArufah + + @test_incident %{ + id: "unknown_harm_001", + description: "User received corrupted output — source component unknown", + approximate_location: "output_formatter", + source: :unknown + } + + @test_report %{ + processed: ["korban_042", "korban_043", "korban_044"], + not_checked: ["output_encoding_validation", "character_set_verification"], + could_have_caught: ["malformed_utf8_in_output"] + } + + # ============================================================ + # Initiation — Devarim 21:1-3 + # ============================================================ + describe "initiate" do + test "records a formal declaration for unknown harm" do + {:ok, declaration} = EglahArufah.initiate(@test_incident, "output_formatter", @test_report) + + assert declaration.incident_id == "unknown_harm_001" + assert declaration.component_id == "output_formatter" + assert declaration.processed == ["korban_042", "korban_043", "korban_044"] + assert declaration.not_checked == ["output_encoding_validation", "character_set_verification"] + assert declaration.could_have_caught == ["malformed_utf8_in_output"] + assert declaration.declared_at != nil + assert String.contains?(declaration.declaration_text, "output_formatter") + assert String.contains?(declaration.declaration_text, "did not cause this harm") + end + + test "rejects incident with known source" do + known_incident = Map.put(@test_incident, :source, :korban_processor) + + assert {:error, :source_not_unknown} = + EglahArufah.initiate(known_incident, "output_formatter", @test_report) + end + + test "declaration is permanently recorded" do + EglahArufah.initiate(@test_incident, "permanent_test", @test_report) + + declarations = EglahArufah.declarations() + assert Enum.any?(declarations, &(&1.component_id == "permanent_test")) + end + + test "chatzotzrot assembly_leaders signal on initiation" do + VavPipeline.Chatzotzrot.subscribe(:kohanim) + + EglahArufah.initiate( + %{@test_incident | id: "signal_test_harm"}, + "signal_test_component", + @test_report + ) + + assert_receive {:chatzotzrot, %{signal_type: :assembly_leaders}} + end + + test "multiple declarations can exist for same incident" do + incident = %{@test_incident | id: "multi_declare"} + + EglahArufah.initiate(incident, "component_a", %{ + processed: ["op1"], + not_checked: ["check1"], + could_have_caught: [] + }) + + EglahArufah.initiate(incident, "component_b", %{ + processed: ["op2"], + not_checked: [], + could_have_caught: ["issue1"] + }) + + matching = EglahArufah.declarations_for("multi_declare") + assert length(matching) == 2 + + components = Enum.map(matching, & &1.component_id) + assert "component_a" in components + assert "component_b" in components + end + + test "empty report lists are accepted" do + {:ok, declaration} = + EglahArufah.initiate( + %{@test_incident | id: "empty_report"}, + "minimal_component", + %{processed: [], not_checked: [], could_have_caught: []} + ) + + assert declaration.processed == [] + assert declaration.not_checked == [] + assert declaration.could_have_caught == [] + end + end + + # ============================================================ + # Queries + # ============================================================ + describe "queries" do + test "declarations_for filters by incident_id" do + EglahArufah.initiate(%{@test_incident | id: "filter_test"}, "comp_x", @test_report) + EglahArufah.initiate(%{@test_incident | id: "other_incident"}, "comp_y", @test_report) + + result = EglahArufah.declarations_for("filter_test") + assert length(result) >= 1 + assert Enum.all?(result, &(&1.incident_id == "filter_test")) + end + + test "count returns total declarations" do + initial = EglahArufah.count() + EglahArufah.initiate(%{@test_incident | id: "count_test"}, "count_comp", @test_report) + assert EglahArufah.count() == initial + 1 + end + end +end