Files
bikoret/vav_pipeline/test/l3_store_test.exs
T
Scott KarpandClaude Opus 4.7 2932fb7447 vav_pipeline: commit 7 — L3 azazel_records + cross-language ingest GenServer
Pass 4 / Finding F, commit 7 of 10 from BUILD_AZAZEL.md. The
operator-internal half of property 9 (observable disposal). Crosses
the Python/Elixir boundary: the Python orchestrator drops completed
disposal records as JSON files; this commit's GenServer reads them
and persists to L3.

The public half (a /peah/azazel_log read endpoint) lands in commit 8;
the routing-from-Korban + Chatzotzrot wiring lands in commit 9. After
those, the Azazel Processor is end-to-end functional.

CREATED:

  vav_pipeline/priv/repo/migrations/20260428000001_create_azazel_records.exs
    azazel_records table — append-only, parallel to interpretations.
    Columns: case_ref, vidui (jsonb), routing, authorized_by,
    shaliach, triggering_call, goral_outcome_id, disposed_at.
    Indexes on case_ref, disposed_at, shaliach.

    Property-12-defending CHECK constraint:
      routing_must_be_eretz_gezerah CHECK (routing = 'eretz_gezerah').
    A future migration that wants to add a destination handle will
    fail this check; the operator must explicitly justify removing
    the constraint, which is the right friction.

    Append-only enforcement matches interpretations: REVOKE
    UPDATE/DELETE on the shorashim_app role.

  vav_pipeline/lib/vav_pipeline/l3/azazel_record.ex
    Ecto schema mirroring the Python AzazelRecord.to_storage_dict()
    shape. Changeset validates:
      - all required fields present
      - routing == "eretz_gezerah" (mirrors the DB CHECK at app layer)
      - vidui is a map with avonot/peshaim/chatot/confessor/
        confessed_at slots present
      - vidui's three categories not all empty (mirrors the Python
        Vidui.__post_init__ EmptyConfessionError)

  vav_pipeline/lib/vav_pipeline/azazel_ingest.ex      ~190 lines
    GenServer that polls a configurable pending-l3 directory
    (default /tmp/shorashim/azazel/pending_l3). For each *.json file:
      1. Read + JSON-parse
      2. Translate Python serialization → Ecto attrs
      3. Call recorder (default L3.Store.record_disposal/1)
      4. On success, shred the source file (property-10 follow-through)
      5. On failure, leave file in place for retry; log warning

    Recorder is injectable for tests — mock recorder lets us verify
    the pipeline without PostgreSQL.

    Poll interval default 5s, configurable. Polling rather than
    file_system events to keep the dependency surface minimal; the
    Azazel cadence is exceptional content, not high-throughput.

    Not added to the supervision tree in this commit — the producer
    side (Python orchestrator dropping files) is wired in commit 9.
    Adding to the supervisor before there's a producer means a
    poller doing nothing useful. Operator can start manually for
    early testing: VavPipeline.AzazelIngest.start_link(...).

CHANGED:

  vav_pipeline/lib/vav_pipeline/l3/store.ex
    + record_disposal/1
        Inserts an AzazelRecord. Returns {:ok, record} or
        {:error, changeset}.

    + count_disposals/1
        Total or since-a-timestamp count. Used by the public
        observability endpoint (commit 8).

    + disposal_summary/1
        Returns categorical info per disposal — case_ref, shaliach,
        triggering_call, disposed_at, categories_filled (which of
        avonot/peshaim/chatot had any items, NOT the items
        themselves). PROPERTY-9 PRIVACY BOUNDARY at the public
        side: items are operator-internal only, accessed via
        future /kodesh/* endpoints.

TESTS:

  vav_pipeline/test/azazel_ingest_test.exs           15 tests, no DB
    Happy path:
      - poll_now ingests valid file and shreds it
      - multiple files processed in sorted order
      - vidui preserved as map (jsonb shape)
    Retry semantics:
      - recorder error → file left in place + state.errors++
      - malformed JSON → file left in place
      - missing disposed_at → file left in place
    State accounting:
      - polls_completed, records_ingested, errors all tracked
      - state survives multiple polls
      - non-json files ignored
    Changeset (no DB):
      - valid attrs
      - rejects routing != eretz_gezerah
      - rejects vidui missing slots
      - rejects vidui all-empty (untagged disposal)
      - rejects missing required fields
      - accepts optional triggering_call + goral_outcome_id

  vav_pipeline/test/l3_store_test.exs                +9 :l3-tagged
    record_disposal:
      - inserts valid disposal
      - rejects routing != eretz_gezerah
      - rejects all-empty vidui
      - rejects missing required fields
    Counts:
      - count_disposals == 0 initially
      - count_disposals after several disposals
      - count_disposals(since) filters by timestamp
    Summary:
      - disposal_summary returns categorical info but NOT items
        (privacy boundary verification)
      - categories_filled accurately reflects vidui shape

Tests: vav_pipeline 353 passed, 0 failures, 33 excluded (was
329/24; +15 visible azazel_ingest + 9 :l3-tagged disposal store).
korban_processor + azazel_processor unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:16:21 -04:00

421 lines
15 KiB
Elixir

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