defmodule VavPipeline.L3.StoreTest do @moduledoc """ Tests for L3 Oral Tradition persistence. Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE. Luchot v'shivrei luchot munachim ba'aron. """ use ExUnit.Case, async: true @moduletag :l3 alias VavPipeline.L3.Store alias VavPipeline.L3.{AzazelRecord, Interpretation, MinorityOpinion, TeikuRecord} setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo) :ok end # === INTERPRETATION RECORDING === @valid_interpretation %{ case_ref: "shabbat_cooking_case_001", interpretation: "Cooking on Shabbat is forbidden under melacha prohibition", status: "accepted", reasoning: "Shemot 35:3 explicitly prohibits kindling fire on Shabbat; cooking requires fire", torah_references: ["Shemot 35:3", "Shemot 20:10"], roots_involved: ["בער", "אש", "מלאכ"], panel_size: 3, vote_count_for: 2, vote_count_against: 1, coupling_trace: ["coupled(root(בער), verse(shemot, 35, 3))"] } test "record_interpretation inserts a valid interpretation" do assert {:ok, %Interpretation{} = interp} = Store.record_interpretation(@valid_interpretation) assert interp.id != nil assert interp.case_ref == "shabbat_cooking_case_001" assert interp.status == "accepted" assert interp.panel_size == 3 assert interp.vote_count_for == 2 assert interp.vote_count_against == 1 assert interp.coupling_trace == ["coupled(root(בער), verse(shemot, 35, 3))"] assert interp.inserted_at != nil end test "record_interpretation rejects missing required fields" do assert {:error, changeset} = Store.record_interpretation(%{case_ref: "incomplete"}) refute changeset.valid? end test "record_interpretation rejects invalid status" do attrs = Map.put(@valid_interpretation, :status, "maybe") assert {:error, changeset} = Store.record_interpretation(attrs) assert %{status: _} = errors_on(changeset) end test "record_interpretation rejects non-positive panel_size" do attrs = Map.put(@valid_interpretation, :panel_size, 0) assert {:error, changeset} = Store.record_interpretation(attrs) assert %{panel_size: _} = errors_on(changeset) end test "record_interpretation accepts rejected status" do attrs = Map.put(@valid_interpretation, :status, "rejected") assert {:ok, %Interpretation{status: "rejected"}} = Store.record_interpretation(attrs) end test "record_interpretation accepts teiku status" do attrs = Map.put(@valid_interpretation, :status, "teiku") assert {:ok, %Interpretation{status: "teiku"}} = Store.record_interpretation(attrs) end # === COUPLING_TRACE GATE (Pass 2 finding L, Elixir half) === # Mirrors the Prolog Form 4 self_grounding halt at the L3 write # boundary. L3 is append-only — once a trace is written it is # permanent. Validation MUST fire before Repo.insert so a trace whose # anchor is not L1-grounded never becomes oral tradition. test "record_interpretation refuses ungrounded coupling trace (prior_decision)" do attrs = Map.put(@valid_interpretation, :coupling_trace, [ "coupled(self, prior_decision(d1))" ]) assert {:error, {:ungrounded_coupling, "coupled(self, prior_decision(d1))"}} = Store.record_interpretation(attrs) end test "record_interpretation refuses mixed trace (one valid + one ungrounded)" do attrs = Map.put(@valid_interpretation, :coupling_trace, [ "coupled(root(brk), verse(bereishit, 1, 1))", "coupled(consensus, system_view(s1))" ]) assert {:error, {:ungrounded_coupling, "coupled(consensus, system_view(s1))"}} = Store.record_interpretation(attrs) end test "record_interpretation accepts empty coupling trace (golden_calf's domain, not Form 4)" do attrs = Map.put(@valid_interpretation, :coupling_trace, []) assert {:ok, %Interpretation{coupling_trace: []}} = Store.record_interpretation(attrs) end test "record_interpretation accepts mitzvah-anchored trace (Rambam-derived)" do attrs = Map.put(@valid_interpretation, :coupling_trace, [ "coupled(domain(creation), mitzvah(5))" ]) assert {:ok, %Interpretation{coupling_trace: ["coupled(domain(creation), mitzvah(5))"]}} = Store.record_interpretation(attrs) end test "record_interpretation accepts via_verse 3-arg trace" do attrs = Map.put(@valid_interpretation, :coupling_trace, [ "coupled(root(shm), mitzvah(7), via_verse(devarim, 6, 4))" ]) assert {:ok, %Interpretation{}} = Store.record_interpretation(attrs) end # === MINORITY OPINION RECORDING === test "record_minority inserts a dissenting opinion" do {:ok, interp} = Store.record_interpretation(@valid_interpretation) attrs = %{ interpretation_id: interp.id, dissenting_reasoning: "Cooking for pikuach nefesh overrides Shabbat prohibition", torah_references: ["Vayikra 18:5"], grounding_type: "achorai" } assert {:ok, %MinorityOpinion{} = opinion} = Store.record_minority(attrs) assert opinion.id != nil assert opinion.interpretation_id == interp.id assert opinion.grounding_type == "achorai" end test "record_minority accepts panim grounding type" do {:ok, interp} = Store.record_interpretation(@valid_interpretation) attrs = %{ interpretation_id: interp.id, dissenting_reasoning: "Domain-level tension with healing laws", torah_references: ["Devarim 4:9"], grounding_type: "panim" } assert {:ok, %MinorityOpinion{grounding_type: "panim"}} = Store.record_minority(attrs) end test "record_minority rejects missing dissenting_reasoning" do {:ok, interp} = Store.record_interpretation(@valid_interpretation) attrs = %{ interpretation_id: interp.id, torah_references: ["Vayikra 18:5"] } assert {:error, changeset} = Store.record_minority(attrs) refute changeset.valid? end # === TEIKU RECORDING === @valid_teiku %{ case_ref: "shabbat_pikuach_nefesh_tension", tension_description: "Shabbat prohibition vs. pikuach nefesh override", interpretation_a: "Shabbat is absolute — ein docheh Shabbat", interpretation_b: "Pikuach nefesh overrides all — v'chai bahem", torah_refs_a: ["Shemot 35:3", "Shemot 20:10"], torah_refs_b: ["Vayikra 18:5"], challenge_sequence: ["mitzvah_tension", "kal_vachomer_tension"] } test "record_teiku inserts an unresolvable tension" do assert {:ok, %TeikuRecord{} = record} = Store.record_teiku(@valid_teiku) assert record.id != nil assert record.case_ref == "shabbat_pikuach_nefesh_tension" assert record.resolved_at == nil assert record.resolution == nil assert record.challenge_sequence == ["mitzvah_tension", "kal_vachomer_tension"] end test "resolve_teiku updates only resolved_at and resolution" do {:ok, record} = Store.record_teiku(@valid_teiku) assert record.resolved_at == nil assert {:ok, resolved} = Store.resolve_teiku(record.id, "Pikuach nefesh overrides — Yoma 85b") assert resolved.resolved_at != nil assert resolved.resolution == "Pikuach nefesh overrides — Yoma 85b" # Original fields unchanged assert resolved.case_ref == "shabbat_pikuach_nefesh_tension" assert resolved.tension_description == "Shabbat prohibition vs. pikuach nefesh override" end test "resolve_teiku returns error for non-existent id" do assert {:error, :not_found} = Store.resolve_teiku(999_999, "no such record") end # === QUERIES === test "get_interpretations returns interpretations for a case ref" do Store.record_interpretation(@valid_interpretation) Store.record_interpretation(Map.put(@valid_interpretation, :status, "rejected")) results = Store.get_interpretations("shabbat_cooking_case_001") assert length(results) == 2 end test "get_interpretations returns empty list for unknown case" do assert [] == Store.get_interpretations("nonexistent_case") end test "get_interpretation_with_dissent preloads minority opinions" do {:ok, interp} = Store.record_interpretation(@valid_interpretation) Store.record_minority(%{ interpretation_id: interp.id, dissenting_reasoning: "Dissent one", torah_references: ["Vayikra 18:5"] }) Store.record_minority(%{ interpretation_id: interp.id, dissenting_reasoning: "Dissent two", torah_references: ["Devarim 4:9"], grounding_type: "panim" }) loaded = Store.get_interpretation_with_dissent(interp.id) assert length(loaded.minority_opinions) == 2 end test "get_unresolved_teiku returns only unresolved records" do {:ok, t1} = Store.record_teiku(@valid_teiku) {:ok, _t2} = Store.record_teiku(Map.put(@valid_teiku, :case_ref, "another_tension")) Store.resolve_teiku(t1.id, "Resolved by gemara") unresolved = Store.get_unresolved_teiku() assert length(unresolved) == 1 assert hd(unresolved).case_ref == "another_tension" end test "get_teiku returns teiku records for a case ref" do Store.record_teiku(@valid_teiku) results = Store.get_teiku("shabbat_pikuach_nefesh_tension") assert length(results) == 1 end # === COUNTS === test "count functions return correct totals" do assert Store.count_interpretations() == 0 assert Store.count_unresolved_teiku() == 0 assert Store.count_minority_opinions() == 0 {:ok, interp} = Store.record_interpretation(@valid_interpretation) Store.record_minority(%{ interpretation_id: interp.id, dissenting_reasoning: "Counted dissent", torah_references: ["Bereishit 1:1"] }) Store.record_teiku(@valid_teiku) assert Store.count_interpretations() == 1 assert Store.count_minority_opinions() == 1 assert Store.count_unresolved_teiku() == 1 end # === APPEND-ONLY PRINCIPLE === test "multiple interpretations for same case are preserved — never overwritten" do Store.record_interpretation(@valid_interpretation) Store.record_interpretation(Map.merge(@valid_interpretation, %{ interpretation: "Alternative: warming food placed before Shabbat is permitted", status: "rejected", vote_count_for: 1, vote_count_against: 2 })) all = Store.get_interpretations("shabbat_cooking_case_001") assert length(all) == 2 statuses = Enum.map(all, & &1.status) assert "accepted" in statuses assert "rejected" in statuses end # === AZAZEL DISPOSAL RECORDS (Pass 4 / Finding F, commit 7) === # These tests exercise the L3.Store.record_disposal/1 path against # an actual database. Property 9 (operator-internal half) — the # disposal becomes permanent oral tradition. @valid_disposal %{ case_ref: "azazel_case_001", vidui: %{ "avonot" => [], "peshaim" => [], "chatot" => ["empty-vital offering: claude response on bereishit 1:1"], "confessor" => "kohen-gadol-aharon", "confessed_at" => "2026-04-28T12:00:00Z" }, routing: "eretz_gezerah", authorized_by: "kohen-gadol-aharon", shaliach: "ish-itti-001", triggering_call: "external_offering.consult_external_model", disposed_at: ~U[2026-04-28 12:00:01Z] } test "record_disposal inserts a valid disposal" do assert {:ok, %AzazelRecord{} = record} = Store.record_disposal(@valid_disposal) assert record.id != nil assert record.case_ref == "azazel_case_001" assert record.routing == "eretz_gezerah" assert record.shaliach == "ish-itti-001" assert record.authorized_by == "kohen-gadol-aharon" assert is_map(record.vidui) assert record.vidui["confessor"] == "kohen-gadol-aharon" end test "record_disposal rejects routing other than eretz_gezerah" do attrs = Map.put(@valid_disposal, :routing, "void") assert {:error, changeset} = Store.record_disposal(attrs) assert %{routing: _} = errors_on(changeset) end test "record_disposal rejects vidui with all categories empty" do attrs = Map.put(@valid_disposal, :vidui, %{ "avonot" => [], "peshaim" => [], "chatot" => [], "confessor" => "kohen", "confessed_at" => "2026-04-28T12:00:00Z" }) assert {:error, changeset} = Store.record_disposal(attrs) errors = errors_on(changeset) assert errors[:vidui] != nil end test "record_disposal rejects missing required fields" do assert {:error, changeset} = Store.record_disposal(%{routing: "eretz_gezerah"}) refute changeset.valid? end test "count_disposals returns 0 when none recorded" do assert Store.count_disposals() == 0 end test "count_disposals returns total after several disposals" do {:ok, _} = Store.record_disposal(@valid_disposal) {:ok, _} = Store.record_disposal(Map.put(@valid_disposal, :case_ref, "azazel_case_002")) {:ok, _} = Store.record_disposal(Map.put(@valid_disposal, :case_ref, "azazel_case_003")) assert Store.count_disposals() == 3 end test "count_disposals(since) filters by disposed_at" do early = @valid_disposal |> Map.put(:case_ref, "early") |> Map.put(:disposed_at, ~U[2026-04-01 00:00:00Z]) late = @valid_disposal |> Map.put(:case_ref, "late") |> Map.put(:disposed_at, ~U[2026-04-30 00:00:00Z]) {:ok, _} = Store.record_disposal(early) {:ok, _} = Store.record_disposal(late) assert Store.count_disposals(~U[2026-04-15 00:00:00Z]) == 1 end test "disposal_summary returns categorical info but NOT vidui items" do {:ok, _} = Store.record_disposal(@valid_disposal) [summary] = Store.disposal_summary() # Has the expected structural fields assert summary.case_ref == "azazel_case_001" assert summary.shaliach == "ish-itti-001" assert summary.triggering_call == "external_offering.consult_external_model" assert "chatot" in summary.categories_filled # Privacy boundary (property 9): the actual vidui items are NOT # in the summary. The endpoint exposes which categories were # filled, not what was named. assert :items not in Map.keys(summary) refute Enum.any?(Map.values(summary), fn v -> v == "empty-vital offering: claude response on bereishit 1:1" end) end test "disposal_summary categories_filled accurately reflects vidui" do avonot_only = put_in(@valid_disposal, [:vidui, "avonot"], ["halt avodah_zarah: x"]) avonot_only = put_in(avonot_only, [:vidui, "chatot"], []) avonot_only = Map.put(avonot_only, :case_ref, "avonot-only") {:ok, _} = Store.record_disposal(avonot_only) [summary] = Store.disposal_summary() assert summary.categories_filled == ["avonot"] end # Helper to extract error messages from changeset defp errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> Regex.replace(~r"%{(\w+)}", msg, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) end end