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>
This commit is contained in:
1 parent
6eb555a5cf
commit
2932fb7447
6 files changed
+997
-2
No files matched your search
@@ -11,7 +11,7 @@ defmodule VavPipeline.L3.StoreTest do
|
||||
@moduletag :l3
|
||||
|
||||
alias VavPipeline.L3.Store
|
||||
alias VavPipeline.L3.{Interpretation, MinorityOpinion, TeikuRecord}
|
||||
alias VavPipeline.L3.{AzazelRecord, Interpretation, MinorityOpinion, TeikuRecord}
|
||||
|
||||
setup do
|
||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
|
||||
@@ -293,6 +293,122 @@ defmodule VavPipeline.L3.StoreTest do
|
||||
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} ->
|
||||
|
||||
Reference in new issue
Block a user