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

@@ -22,7 +22,11 @@ This module is the only entry point for external model consultation.
No internal component calls an external model directly. No internal component calls an external model directly.
""" """
from dataclasses import dataclass import hashlib
import json
import os
import pathlib
from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from korban_processor.korban import ( from korban_processor.korban import (
@@ -43,21 +47,97 @@ from korban_processor.haktarah import haktarah_staged
from korban_processor.stages import KorbanStage from korban_processor.stages import KorbanStage
from korban_processor.router import RoutingResult, route from korban_processor.router import RoutingResult, route
# Property 1 (parity provisioning): the disposal channel must be
# importable at the same scale as the processing channel. If
# azazel_processor cannot be imported, external_offering refuses to
# load — the two-goat architecture is symmetric or it is broken.
# This is the strongest formulation of "provisioned in advance, from
# the same pool"; the failure manifests as ImportError at module load,
# not as a runtime gap discovered when an empty-vital korban arrives.
try:
from azazel_processor import (
AzazelInput,
AzazelRecord,
IshItti,
authorize_disposal,
confess_empty_vital,
dispatch_for_disposal,
)
except ImportError as e: # pragma: no cover — bootstrap-time check
raise ImportError(
"external_offering requires azazel_processor (the disposal "
"channel) to be importable. Property 1 of "
"azazel_engineering_reading.md §14 — disposal capacity must "
"be provisioned at parity with processing capacity, from the "
"same pool. Without azazel_processor on the import path, the "
"two-goat architecture is broken; refuse to load. "
f"Underlying ImportError: {e}"
)
# Default directory the Python orchestrator drops completed
# AzazelRecords into for the Elixir AzazelIngest GenServer to pick
# up. Mirrors the manna-provision file pattern Shorashim already uses
# for cross-language state transfer.
DEFAULT_AZAZEL_PENDING_L3_DIR = pathlib.Path("/tmp/shorashim/azazel/pending_l3")
@dataclass @dataclass
class ExternalConsultation: class ExternalConsultation:
"""The full record of an external-model consultation. """The full record of an external-model consultation.
All four fields are preserved so the full chain is auditable: Fields are preserved so the full chain is auditable:
- request: what was asked and with what system prompt - request: what was asked and with what system prompt
- raw_response: the unprocessed output from the external model - raw_response: the unprocessed output from the external model
- processed: the korban after the offering protocol - processed: the korban after the offering protocol
- routing: where the processed korban was sent - routing: where the processed korban was sent
- disposal_record: present only if the consultation was routed
to disposal (zerikah produced no vital
content). When non-None, the caller knows
the input was DISPOSED rather than
processed; processed.vital_content will be
empty in this case.
""" """
request: ModelRequest request: ModelRequest
raw_response: ModelResponse raw_response: ModelResponse
processed: ProcessedKorban processed: ProcessedKorban
routing: RoutingResult routing: RoutingResult
disposal_record: Optional[AzazelRecord] = None
def _content_addressable_case_ref(content: str, source_query: str) -> str:
"""Produce a case_ref from the input content. Stable hash so audit
can verify the routing was for THIS input (without retaining the
input itself — property 10)."""
h = hashlib.sha256()
h.update(source_query.encode("utf-8"))
h.update(b"|")
h.update(content.encode("utf-8"))
return f"ext-consult-{h.hexdigest()[:16]}"
def _make_pending_l3_recorder(pending_dir: pathlib.Path):
"""Return a recorder callable that writes the AzazelRecord to a
JSON file in the pending-l3 directory. The Elixir AzazelIngest
GenServer (commit 7) watches this directory and persists each
file to L3.
Cross-language bridge: same pattern as the manna provision file.
Recoverable: if either side crashes mid-write, the file remains
and is re-processed on the next poll.
"""
pending_dir.mkdir(parents=True, exist_ok=True)
def recorder(record: AzazelRecord) -> None:
path = pending_dir / f"{record.case_ref}.json"
# Atomic-ish: write to temp then rename, so a crash mid-write
# leaves either the previous state or nothing — never a
# half-written file.
temp_path = path.with_suffix(".json.tmp")
temp_path.write_text(json.dumps(record.to_storage_dict()))
os.replace(temp_path, path)
return recorder
def consult_external_model( def consult_external_model(
@@ -67,6 +147,10 @@ def consult_external_model(
offering_type: OfferingType = OfferingType.OLAH, offering_type: OfferingType = OfferingType.OLAH,
system_prompt: Optional[str] = None, system_prompt: Optional[str] = None,
caller: Optional[callable] = None, caller: Optional[callable] = None,
*,
azazel_kohen_gadol: str = "kohen-gadol-default",
azazel_shaliach_id: str = "ish-itti-default",
azazel_pending_l3_dir: pathlib.Path = DEFAULT_AZAZEL_PENDING_L3_DIR,
) -> ExternalConsultation: ) -> ExternalConsultation:
"""Submit a query to an external model, then process the response as korban. """Submit a query to an external model, then process the response as korban.
@@ -147,9 +231,99 @@ def consult_external_model(
routing = route(processed, offering_type) routing = route(processed, offering_type)
# Property 1 (parity provisioning) END-TO-END: when zerikah
# produces no vital content, the korban is routed to disposal
# rather than silently dropped. The two-goat architecture is
# provisioned at intake (property 1 — both modules importable)
# AND at runtime (this branch — disposal is invoked when the
# processing channel cannot metabolize the input).
#
# Before this commit, an empty-vital korban was returned as-is
# with empty vital_content and the discarded sentences in
# `discarded` — the caller knew the offering was empty but the
# rejection had no permanent record. After: the empty-vital
# case is named (vidui), routed (eretz_gezerah), recorded
# (L3 azazel_records via the file-watcher bridge), and
# acknowledged (chatzotzrot :moadim by AzazelIngest).
disposal_record = None
if not vital.strip():
disposal_record = _route_to_azazel(
korban=korban,
source_query=source_query,
kohen_gadol=azazel_kohen_gadol,
shaliach_id=azazel_shaliach_id,
pending_l3_dir=azazel_pending_l3_dir,
)
return ExternalConsultation( return ExternalConsultation(
request=request, request=request,
raw_response=raw_response, raw_response=raw_response,
processed=processed, processed=processed,
routing=routing, routing=routing,
disposal_record=disposal_record,
) )
def _route_to_azazel(
*,
korban: Korban,
source_query: str,
kohen_gadol: str,
shaliach_id: str,
pending_l3_dir: pathlib.Path,
) -> AzazelRecord:
"""Run the full Azazel disposal protocol for an empty-vital korban.
Property 1 — parity provisioning at runtime: an empty-vital
korban is not silently discarded; it goes to disposal with the
full protocol envelope.
Each call activates a fresh shaliach, performs the disposal,
then tevilah's the shaliach. The pending-l3 file the recorder
drops is picked up by the Elixir AzazelIngest GenServer (commit
7) which writes the L3 record AND fires the Chatzotzrot
:moadim signal (property 6 — out-of-band acknowledgment).
"""
shaliach = IshItti(shaliach_id)
shaliach.activate()
# Build the vidui — operator-produced enumerative commit. We
# name the case at the call site here; the helper routes it
# into chatot (target-missing).
summary = (
f"{korban.provenance.value} response on "
f"{source_query!r} — zerikah extracted no vital content"
)
vidui = confess_empty_vital(summary, confessor=kohen_gadol)
case_ref = _content_addressable_case_ref(
korban.raw_content, source_query
)
azazel_input = AzazelInput(
case_ref=case_ref,
payload=korban,
classification="empty_vital",
triggering_call="external_offering.consult_external_model",
)
recorder = _make_pending_l3_recorder(pending_l3_dir)
# Property 8 envelope: this is an event-driven disposal during
# the year (not a Yom Kippur batch), so we open authorize_disposal
# explicitly. The scope is exactly this one disposal cycle.
with authorize_disposal():
record = dispatch_for_disposal(
azazel_input,
handler=shaliach,
confess_with=vidui,
authorized_by=kohen_gadol,
recorder=recorder,
)
# Tevilah — the shaliach washes before rejoining the camp
# (Vayikra 16:26). Property 4 follow-through: state must be
# cleared before the next disposal can use this handler.
shaliach.tevilah()
return record
@@ -160,6 +160,137 @@ def test_system_prompt_is_forwarded():
assert result.request.system_prompt == "You are a Torah scholar. Cite verse references." assert result.request.system_prompt == "You are a Torah scholar. Cite verse references."
# ============================================================
# Empty-vital → Azazel disposal routing (commit 9, properties 1 + 6)
# ============================================================
# Property 1 at runtime: an empty-vital korban is routed to disposal,
# not silently dropped. The disposal record IS the permanent
# acknowledgment that the system received this input and could not
# metabolize it.
import json
import pathlib
import tempfile
from azazel_processor import AzazelRecord
def _empty_vital_response():
"""A model response that contains no extractable Torah content
(no Torah refs, no Hebrew, no insight markers, no roots).
zerikah will return empty vital for this."""
return "ok thanks. let me know if you want me to do anything else."
def test_empty_vital_korban_is_routed_to_azazel():
with tempfile.TemporaryDirectory(prefix="azazel-pending-") as d:
pending = pathlib.Path(d)
result = consult_external_model(
query="anything",
source_query="empty_vital_test_001",
caller=_fake_caller(_empty_vital_response()),
azazel_kohen_gadol="kohen-gadol-test",
azazel_shaliach_id="ish-itti-test",
azazel_pending_l3_dir=pending,
)
# The processed korban is empty (zerikah returned no vital)
assert result.processed.vital_content == ""
# The disposal_record field is now populated
assert result.disposal_record is not None
assert isinstance(result.disposal_record, AzazelRecord)
assert result.disposal_record.shaliach == "ish-itti-test"
assert result.disposal_record.authorized_by == "kohen-gadol-test"
assert result.disposal_record.triggering_call == (
"external_offering.consult_external_model"
)
# A pending-l3 file was dropped for AzazelIngest to pick up
files = list(pending.glob("*.json"))
assert len(files) == 1
body = json.loads(files[0].read_text())
assert body["case_ref"] == result.disposal_record.case_ref
assert body["routing"] == "eretz_gezerah"
assert body["shaliach"] == "ish-itti-test"
# The vidui is on the pending file (operator-internal channel)
assert body["vidui"]["confessor"] == "kohen-gadol-test"
assert body["vidui"]["chatot"] # non-empty
def test_processable_response_does_not_create_disposal_record():
"""The companion to the above: an offering with extractable Torah
content goes through the processing channel and the disposal_record
field stays None."""
with tempfile.TemporaryDirectory(prefix="azazel-pending-") as d:
result = consult_external_model(
query="explain bereishit 1:1",
source_query="processable_test_001",
caller=_fake_caller(
"Bereishit 1:1 — the shoresh ב-ר-א means to create. The verse "
"indicates beginnings."
),
azazel_pending_l3_dir=pathlib.Path(d),
)
assert result.disposal_record is None
assert result.processed.vital_content != ""
# No file was dropped — nothing for AzazelIngest to pick up
assert list(pathlib.Path(d).glob("*.json")) == []
def test_pending_l3_file_does_not_contain_payload():
"""Property 10 follow-through: the file the orchestrator drops
for the Elixir bridge contains the vidui (what was disposed) but
not the disposed thing itself. The model's actual response text
must not appear in the pending-l3 file."""
secret_phrase = "this-string-must-not-leak-to-the-pending-l3-file"
with tempfile.TemporaryDirectory(prefix="azazel-pending-") as d:
pending = pathlib.Path(d)
consult_external_model(
query="any",
source_query="leak_check_001",
caller=_fake_caller(secret_phrase), # response is just the secret
azazel_pending_l3_dir=pending,
)
files = list(pending.glob("*.json"))
assert len(files) == 1
body_text = files[0].read_text()
assert secret_phrase not in body_text
def test_case_ref_is_content_addressable():
"""Audit can verify the routing was for a specific input by
recomputing the case_ref. Property 7's audit-trail half — the
routing decision is owned and traceable."""
response = _empty_vital_response()
with tempfile.TemporaryDirectory(prefix="azazel-pending-") as d:
pending = pathlib.Path(d)
result1 = consult_external_model(
query="any",
source_query="audit_test_001",
caller=_fake_caller(response),
azazel_pending_l3_dir=pending,
)
# Same content + same source_query → same case_ref
with tempfile.TemporaryDirectory(prefix="azazel-pending-") as d:
pending = pathlib.Path(d)
result2 = consult_external_model(
query="different-query", # query is not in the case_ref
source_query="audit_test_001",
caller=_fake_caller(response),
azazel_pending_l3_dir=pending,
)
assert result1.disposal_record.case_ref == result2.disposal_record.case_ref
# ============================================================ # ============================================================
# Live API integration test — opt-in only # Live API integration test — opt-in only
# ============================================================ # ============================================================
+43 -1
View File
@@ -45,7 +45,7 @@ defmodule VavPipeline.AzazelIngest do
require Logger require Logger
alias VavPipeline.L3.Store alias VavPipeline.{Chatzotzrot, L3.Store}
@default_poll_interval_ms 5_000 @default_poll_interval_ms 5_000
@default_pending_dir "/tmp/shorashim/azazel/pending_l3" @default_pending_dir "/tmp/shorashim/azazel/pending_l3"
@@ -163,6 +163,20 @@ defmodule VavPipeline.AzazelIngest do
{:ok, raw} <- Jason.decode(body), {:ok, raw} <- Jason.decode(body),
{:ok, attrs} <- normalize_record(raw), {:ok, attrs} <- normalize_record(raw),
{:ok, _record} <- recorder.(attrs) do {: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 # Property 10 follow-through: shred the source file after
# successful L3 write. The disposal is now permanent oral # successful L3 write. The disposal is now permanent oral
# tradition; the pending artifact must not linger. # tradition; the pending artifact must not linger.
@@ -179,6 +193,34 @@ defmodule VavPipeline.AzazelIngest do
end end
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. # Translate the Python-side JSON shape into Ecto-friendly attrs.
# The Python AzazelRecord.to_storage_dict() shape is: # 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 assert length(:ets.tab2list(captured)) == 1
end 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) # Changeset validation (no DB)
# ============================================================ # ============================================================