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:
Scott KarpandClaude Opus 4.7 committed 2026-04-28 09:36:16 -04:00
1 parent f493b7df65
commit 1a27a283ae
4 files changed
+431 -7

No files matched your search

+43 -1
View File
@@ -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:
# {
+77
View File
@@ -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)
# ============================================================