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>
337 lines
10 KiB
Elixir
337 lines
10 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
|
|
|
|
# ============================================================
|
|
# 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
|