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>
This commit is contained in:
1 parent
f493b7df65
commit
1a27a283ae
4 files changed
+431
-7
No files matched your search
@@ -45,7 +45,7 @@ defmodule VavPipeline.AzazelIngest do
|
||||
|
||||
require Logger
|
||||
|
||||
alias VavPipeline.L3.Store
|
||||
alias VavPipeline.{Chatzotzrot, L3.Store}
|
||||
|
||||
@default_poll_interval_ms 5_000
|
||||
@default_pending_dir "/tmp/shorashim/azazel/pending_l3"
|
||||
@@ -163,6 +163,20 @@ defmodule VavPipeline.AzazelIngest do
|
||||
{:ok, raw} <- Jason.decode(body),
|
||||
{:ok, attrs} <- normalize_record(raw),
|
||||
{:ok, _record} <- recorder.(attrs) do
|
||||
# Property 6 (out-of-band acknowledgment) — fire the Chatzotzrot
|
||||
# signal AFTER the L3 write succeeds. The chatzotzrot is the
|
||||
# side channel; the disposal flow itself does not return
|
||||
# acknowledgment to the caller (the caller already knows they
|
||||
# handed the input to disposal).
|
||||
#
|
||||
# :moadim is the right signal type — Yom Kippur completion
|
||||
# semantics. This disposal cycle is closed; the public fact
|
||||
# (property 9) is now witnessable via /peah/azazel_log.
|
||||
#
|
||||
# Process.whereis-guarded so this works in tests that don't
|
||||
# bring up the full supervision tree.
|
||||
fire_chatzotzrot(attrs)
|
||||
|
||||
# Property 10 follow-through: shred the source file after
|
||||
# successful L3 write. The disposal is now permanent oral
|
||||
# tradition; the pending artifact must not linger.
|
||||
@@ -179,6 +193,34 @@ defmodule VavPipeline.AzazelIngest do
|
||||
end
|
||||
end
|
||||
|
||||
defp fire_chatzotzrot(attrs) do
|
||||
if Process.whereis(Chatzotzrot) do
|
||||
summary = chatzotzrot_summary(attrs)
|
||||
|
||||
Chatzotzrot.signal(:moadim,
|
||||
reason: "Azazel disposal completed: #{summary}"
|
||||
)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp chatzotzrot_summary(attrs) do
|
||||
case_ref = Map.get(attrs, :case_ref) || "unknown"
|
||||
shaliach = Map.get(attrs, :shaliach) || "unknown"
|
||||
vidui = Map.get(attrs, :vidui) || %{}
|
||||
|
||||
categories =
|
||||
Enum.filter(["avonot", "peshaim", "chatot"], fn slot ->
|
||||
case Map.get(vidui, slot) do
|
||||
list when is_list(list) and list != [] -> true
|
||||
_ -> false
|
||||
end
|
||||
end)
|
||||
|
||||
"case=#{case_ref} shaliach=#{shaliach} categories=#{Enum.join(categories, ",")}"
|
||||
end
|
||||
|
||||
# Translate the Python-side JSON shape into Ecto-friendly attrs.
|
||||
# The Python AzazelRecord.to_storage_dict() shape is:
|
||||
# {
|
||||
|
||||
@@ -226,6 +226,83 @@ defmodule VavPipeline.AzazelIngestTest do
|
||||
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)
|
||||
# ============================================================
|
||||
|
||||
Reference in new issue
Block a user