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>
414 lines
13 KiB
Elixir
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
|