Files
bikoret/vav_pipeline/test/azazel_ingest_test.exs
Scott KarpandClaude Opus 4.7 1a27a283ae azazel commit 9 — Korban→Azazel intake routing + Chatzotzrot ack (properties 1 + 6)
Pass 4 / Finding F, commit 9 of 10 from BUILD_AZAZEL.md. Closes the
last two property gaps: 1 (parity provisioning, end-to-end) and 6
(out-of-band acknowledgment via Chatzotzrot). The Azazel Processor
is now functionally complete; commit 10 is the closure-and-review
pass with no further code.

PROPERTY 1 — PARITY PROVISIONING, two layers:

LAYER 1 (import-time): external_offering imports azazel_processor
at module load. If the import fails, external_offering refuses to
load with a Torah-anchored ImportError. The two-goat architecture is
SYMMETRIC OR IT IS BROKEN — operators cannot run the processing
channel without the disposal channel being importable. The strongest
formulation: if you cannot dispose, you cannot process.

LAYER 2 (runtime): when zerikah produces no vital content,
consult_external_model now routes the korban to disposal rather than
returning an empty ProcessedKorban with the rejection in `discarded`.
The empty-vital case is named (vidui), routed (eretz_gezerah),
recorded (L3 azazel_records), and acknowledged (chatzotzrot :moadim).

PROPERTY 6 — OUT-OF-BAND ACKNOWLEDGMENT:

The Python orchestrator drops the AzazelRecord JSON in the pending-l3
directory. The Elixir AzazelIngest GenServer reads it, persists to
L3 via record_disposal/1, AND fires Chatzotzrot.signal(:moadim, ...)
with a categorical summary (case_ref + shaliach + categories_filled).
The chatzotzrot is the side channel — distinct from the disposal
channel itself. The orchestrator does NOT know whether the signal
was successfully transmitted; the disposal completes regardless.

The :moadim signal type was the right choice: Yom Kippur completion
semantics. This disposal cycle is closed; the public fact (property
9) is now witnessable via /peah/azazel_log.

PRIVACY BOUNDARY ALSO APPLIES TO THE CHATZOTZROT:

The signal's `reason` field carries a CATEGORICAL summary, not the
vidui items. A test verifies that a known secret string in a vidui
chatot does NOT leak into the chatzotzrot signal — the operator-side
event channel is held to the same privacy boundary as the public
endpoint.

CHANGED:

  korban_processor/src/korban_processor/external_offering.py
    + Import azazel_processor at module load (raises ImportError
      with Torah-anchored explanation if azazel_processor is not
      importable — property 1, layer 1)
    + DEFAULT_AZAZEL_PENDING_L3_DIR
    + ExternalConsultation.disposal_record: Optional[AzazelRecord]
        Non-None iff the consultation was routed to disposal
    + _content_addressable_case_ref(content, source_query)
        SHA-256-based stable identifier for audit
    + _make_pending_l3_recorder(pending_dir)
        Returns a recorder callable that drops the JSON file
        atomically (write to .tmp, then os.replace)
    + _route_to_azazel(...) — internal helper running the full
        protocol envelope:
        - opens authorize_disposal context
        - activates a fresh IshItti shaliach
        - builds vidui via confess_empty_vital
        - dispatches via dispatch_for_disposal
        - tevilah's the shaliach
    + consult_external_model:
        - new keyword args azazel_kohen_gadol, azazel_shaliach_id,
          azazel_pending_l3_dir
        - branch on `if not vital.strip()`: route to disposal,
          attach record to ExternalConsultation

  vav_pipeline/lib/vav_pipeline/azazel_ingest.ex
    + ingest_file/2 fires Chatzotzrot.signal(:moadim, ...) after
      successful L3 write, BEFORE shredding the source file. So the
      ack comes BEFORE the in-flight artifact disappears — recovery
      is possible if either side crashes.
    + Process.whereis(Chatzotzrot)-guarded so tests without the full
      supervision tree still work.
    + chatzotzrot_summary/1 builds the categorical summary
      (case_ref + shaliach + categories_filled). Privacy boundary:
      no vidui items, only counts/category names.

TESTS:

  korban_processor/test/test_external_offering.py             +4 tests
    - empty-vital response routes to azazel:
      - processed.vital_content == ""
      - disposal_record is an AzazelRecord with kohen + shaliach
      - pending-l3 file dropped with correct shape
    - processable response does NOT create disposal_record (the
      negative case — vital content goes through normally)
    - PRIVACY: pending-l3 file does not contain the model's
      response text (verified with a known secret_phrase)
    - case_ref is content-addressable: same content + same
      source_query → same case_ref (audit can recompute)

  vav_pipeline/test/azazel_ingest_test.exs                    +2 tests
    - successful ingest fires Chatzotzrot :moadim signal
      (subscribe :all, observe — :moadim routes to :all per
      chatzotzrot.ex routing table)
    - PRIVACY: chatzotzrot signal does NOT include vidui item
      content (known secret_phrase verified absent from the signal)

Tests:
  - korban_processor: 207 passed, 1 skipped (was 203/1; +4)
  - azazel_processor: 125 passed (unchanged)
  - vav_pipeline:     363 passed, 0 failures, 35 excluded
                      (was 361/35; +2 azazel_ingest chatzotzrot tests)

Property coverage after commit 9:
  All 12 properties of azazel_engineering_reading.md §14 defended
  by code:
    1  parity provisioning      ✓ (import-time + runtime)
    2  blind routing / goral     ✓
    3  enumerative commit        ✓
    4  role-separated operator   ✓
    5  provisioned infrastructure ✓
    6  out-of-band acknowledgment ✓
    7  authorized routing        ✓
    8  protocol-restricted access ✓
    9  observable disposal       ✓ (operator-internal + public)
    10 hard termination          ✓
    11 classification discipline ✓
    12 unmanaged destination     ✓

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

414 lines
13 KiB
Elixir

defmodule VavPipeline.AzazelIngestTest do
@moduledoc """
Tests for the cross-language Azazel ingest GenServer (Pass 4 /
Finding F, commit 7). These tests verify the file-watcher logic
and the AzazelRecord changeset validation; they do NOT require
PostgreSQL because the recorder is injected as a mock.
L3-side tests (the actual record_disposal write) live in
l3_store_test.exs tagged :l3.
"""
use ExUnit.Case, async: false
alias VavPipeline.AzazelIngest
alias VavPipeline.L3.AzazelRecord
# ============================================================
# Test helpers
# ============================================================
setup do
# Per-test pending directory so tests don't share state.
dir =
System.tmp_dir!()
|> Path.join("azazel-ingest-test-#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
on_exit(fn -> File.rm_rf!(dir) end)
%{pending_dir: dir}
end
defp valid_record_json(case_ref \\ "case-001") do
%{
"case_ref" => case_ref,
"vidui" => %{
"avonot" => [],
"peshaim" => [],
"chatot" => ["empty-vital offering: test"],
"confessor" => "kohen-gadol",
"confessed_at" => "2026-04-28T12:00:00Z"
},
"routing" => "eretz_gezerah",
"authorized_by" => "kohen-gadol",
"shaliach" => "aharon",
"triggering_call" => "external_offering",
"goral_outcome_id" => nil,
"disposed_at" => "2026-04-28T12:00:01Z"
}
|> Jason.encode!()
end
defp drop_file(dir, name, body) do
File.write!(Path.join(dir, name), body)
end
defp start_ingest(dir, opts \\ []) do
name = :"ingest_#{:erlang.unique_integer([:positive])}"
captured = :ets.new(:captured, [:public, :bag])
recorder = fn attrs ->
:ets.insert(captured, {:record, attrs})
{:ok, attrs}
end
{:ok, pid} =
AzazelIngest.start_link(
Keyword.merge(
[
name: name,
pending_dir: dir,
poll_interval_ms: 60_000, # don't auto-poll during tests
recorder: recorder
],
opts
)
)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
%{server: name, captured: captured, recorder: recorder}
end
# ============================================================
# Happy path
# ============================================================
test "poll_now ingests a valid file and shreds it", %{pending_dir: dir} do
drop_file(dir, "case-001.json", valid_record_json())
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
# File was shredded
refute File.exists?(Path.join(dir, "case-001.json"))
# Record was passed to the recorder
assert [{:record, attrs}] = :ets.tab2list(captured)
assert attrs.case_ref == "case-001"
assert attrs.routing == "eretz_gezerah"
assert attrs.authorized_by == "kohen-gadol"
assert attrs.shaliach == "aharon"
assert %DateTime{} = attrs.disposed_at
end
test "poll_now ingests multiple files in sorted order", %{pending_dir: dir} do
drop_file(dir, "case-002.json", valid_record_json("case-002"))
drop_file(dir, "case-001.json", valid_record_json("case-001"))
drop_file(dir, "case-003.json", valid_record_json("case-003"))
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
refs =
:ets.tab2list(captured)
|> Enum.map(fn {:record, attrs} -> attrs.case_ref end)
assert refs == ["case-001", "case-002", "case-003"]
end
test "ingest preserves the vidui as a map (jsonb column)", %{pending_dir: dir} do
drop_file(dir, "case-vidui.json", valid_record_json())
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
[{:record, attrs}] = :ets.tab2list(captured)
assert is_map(attrs.vidui)
assert attrs.vidui["chatot"] == ["empty-vital offering: test"]
assert attrs.vidui["confessor"] == "kohen-gadol"
end
# ============================================================
# Retry semantics — failed records left in place
# ============================================================
test "file is left in place when recorder returns error", %{pending_dir: dir} do
drop_file(dir, "case-fail.json", valid_record_json())
name = :"ingest_fail_#{:erlang.unique_integer([:positive])}"
failing_recorder = fn _ -> {:error, :db_unavailable} end
{:ok, pid} =
AzazelIngest.start_link(
name: name,
pending_dir: dir,
poll_interval_ms: 60_000,
recorder: failing_recorder
)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
AzazelIngest.poll_now(name)
# File still present for retry
assert File.exists?(Path.join(dir, "case-fail.json"))
state = AzazelIngest.state(name)
assert state.errors == 1
assert state.records_ingested == 0
end
test "file is left in place when JSON is malformed", %{pending_dir: dir} do
drop_file(dir, "case-malformed.json", "not-json{")
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
# File still present (operator can investigate)
assert File.exists?(Path.join(dir, "case-malformed.json"))
state = AzazelIngest.state(server)
assert state.errors == 1
end
test "file is left in place when disposed_at is missing", %{pending_dir: dir} do
raw =
%{"case_ref" => "case-no-ts", "vidui" => %{}, "routing" => "eretz_gezerah"}
|> Jason.encode!()
drop_file(dir, "case-no-ts.json", raw)
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
assert File.exists?(Path.join(dir, "case-no-ts.json"))
end
# ============================================================
# State accounting
# ============================================================
test "state tracks polls_completed, records_ingested, errors", %{pending_dir: dir} do
drop_file(dir, "case-001.json", valid_record_json("case-001"))
drop_file(dir, "case-002.json", valid_record_json("case-002"))
drop_file(dir, "case-bad.json", "garbage")
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
state = AzazelIngest.state(server)
assert state.polls_completed == 1
assert state.records_ingested == 2
assert state.errors == 1
end
test "state survives multiple polls", %{pending_dir: dir} do
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
AzazelIngest.poll_now(server)
AzazelIngest.poll_now(server)
state = AzazelIngest.state(server)
assert state.polls_completed == 3
end
test "non-json files are ignored", %{pending_dir: dir} do
File.write!(Path.join(dir, "README.txt"), "not a record")
drop_file(dir, "case-001.json", valid_record_json())
%{server: server, captured: captured} = start_ingest(dir)
AzazelIngest.poll_now(server)
# JSON file processed; README left alone
refute File.exists?(Path.join(dir, "case-001.json"))
assert File.exists?(Path.join(dir, "README.txt"))
assert length(:ets.tab2list(captured)) == 1
end
# ============================================================
# Property 6 — out-of-band acknowledgment via Chatzotzrot
# ============================================================
test "successful ingest fires Chatzotzrot :moadim signal", %{pending_dir: dir} do
# The supervision-tree-started Chatzotzrot is already running.
# Subscribe this test process as an :operators recipient and
# observe the signal AzazelIngest fires.
VavPipeline.Chatzotzrot.subscribe(:all)
drop_file(dir, "case-ack-001.json", valid_record_json("case-ack-001"))
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
# Drain any unrelated signals from prior tests; we want OUR signal.
receive_until_ours("case-ack-001")
end
test "chatzotzrot fire does NOT include vidui item content (privacy)" do
# Property 9's privacy boundary applies to the chatzotzrot
# summary too — the operator-side signal channel must not leak
# the operator-internal vidui items.
secret_phrase = "must-not-leak-via-chatzotzrot-summary-channel"
body =
%{
"case_ref" => "leak-001",
"vidui" => %{
"avonot" => [],
"peshaim" => [],
"chatot" => [secret_phrase],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
},
"routing" => "eretz_gezerah",
"authorized_by" => "kohen-gadol",
"shaliach" => "aharon",
"triggering_call" => "test",
"goral_outcome_id" => nil,
"disposed_at" => "2026-04-28T12:00:01Z"
}
|> Jason.encode!()
VavPipeline.Chatzotzrot.subscribe(:all)
dir =
System.tmp_dir!() |> Path.join("azazel-leak-#{:erlang.unique_integer([:positive])}")
File.mkdir_p!(dir)
on_exit(fn -> File.rm_rf!(dir) end)
File.write!(Path.join(dir, "leak-001.json"), body)
%{server: server} = start_ingest(dir)
AzazelIngest.poll_now(server)
signal = receive_until_ours("leak-001")
refute signal.details.reason =~ secret_phrase
end
# Drain any unrelated chatzotzrot signals (from other tests sharing
# the supervision-tree Chatzotzrot) until we see ours, identified by
# case_ref in the reason. Returns the matching signal.
defp receive_until_ours(case_ref) do
receive do
{:chatzotzrot, signal} ->
if String.contains?(signal.details.reason || "", case_ref) and
signal.signal_type == :moadim do
assert signal.details.reason =~ "Azazel disposal completed"
signal
else
receive_until_ours(case_ref)
end
after
2_000 -> flunk("Did not receive chatzotzrot signal for #{case_ref}")
end
end
# ============================================================
# Changeset validation (no DB)
# ============================================================
describe "AzazelRecord.changeset/2" do
test "valid attrs produce a valid changeset" do
attrs = %{
case_ref: "case-cs-001",
vidui: %{
"avonot" => [],
"peshaim" => [],
"chatot" => ["something"],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
},
routing: "eretz_gezerah",
authorized_by: "kohen-gadol",
shaliach: "aharon",
disposed_at: ~U[2026-04-28 12:00:01Z]
}
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
assert cs.valid?
end
test "rejects routing other than eretz_gezerah" do
attrs = base_attrs(%{routing: "void"})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
refute cs.valid?
assert {:routing, _} = List.keyfind(cs.errors, :routing, 0)
end
test "rejects vidui missing avonot slot" do
attrs =
base_attrs(%{
vidui: %{
"peshaim" => [],
"chatot" => ["x"],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
}
})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
refute cs.valid?
assert {:vidui, {msg, _}} = List.keyfind(cs.errors, :vidui, 0)
assert msg =~ "avonot"
end
test "rejects vidui with all three categories empty" do
attrs =
base_attrs(%{
vidui: %{
"avonot" => [],
"peshaim" => [],
"chatot" => [],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
}
})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
refute cs.valid?
assert {:vidui, {msg, _}} = List.keyfind(cs.errors, :vidui, 0)
assert msg =~ "untagged"
end
test "rejects missing required fields" do
cs = AzazelRecord.changeset(%AzazelRecord{}, %{})
refute cs.valid?
missing = Enum.map(cs.errors, fn {k, _} -> k end)
for required <- [:case_ref, :vidui, :routing, :authorized_by, :shaliach, :disposed_at] do
assert required in missing, "expected #{required} to be missing"
end
end
test "accepts optional triggering_call and goral_outcome_id" do
attrs = base_attrs(%{
triggering_call: "external_offering",
goral_outcome_id: String.duplicate("a", 64)
})
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
assert cs.valid?
end
defp base_attrs(overrides \\ %{}) do
Map.merge(
%{
case_ref: "case-base-001",
vidui: %{
"avonot" => [],
"peshaim" => [],
"chatot" => ["something"],
"confessor" => "kohen",
"confessed_at" => "2026-04-28T12:00:00Z"
},
routing: "eretz_gezerah",
authorized_by: "kohen-gadol",
shaliach: "aharon",
disposed_at: ~U[2026-04-28 12:00:01Z]
},
overrides
)
end
end
end