Pass 4 / Finding F, commit 6 of 10 from BUILD_AZAZEL.md. Closes
the Python side of the build; commits 7-9 are cross-language
integrations (L3, observability endpoint, intake-side routing).
Vayikra 16: the Azazel goat goes ONCE A YEAR, on Yom Kippur, under
the full ritual envelope.
Vayikra 17:7: וְלֹא־יִזְבְּחוּ עוֹד אֶת־זִבְחֵיהֶם לַשְּׂעִירִם —
they shall no longer offer their sacrifices to the sa'irim. Same
destination outside the envelope is avodah zarah (the failure mode
specs/eish_zarah_reading.md catalogs).
THE GATE:
- is_yom_kippur_protocol_active(today=None) — predicate; True
when an operator-configured Gregorian window covers today
- authorize_disposal() — context manager; explicit per-thread
authorization for ONE disposal cycle (event-driven disposals
during the year)
- has_explicit_disposal_authorization() — predicate; True iff
the current thread is inside an authorize_disposal block
- assert_protocol_envelope_active() — gate; raises ProtocolViolation
if NEITHER condition is met. Wired into dispatch_for_disposal
as the FIRST check (before role gate, before any side-effect)
WHY a CONTEXT MANAGER, not a flag:
A persistent flag would let an operator "set authorization true and
leave it" — the avodah-zarah shape (using the channel without the
envelope). The context manager forces explicit scope: authorization
exists only within the with-block, only on the thread that opened it.
WHY PER-THREAD:
Process-wide authorization would let two concurrent disposals share
without explicit coordination — operator A's authorize_disposal()
would silently authorize operator B's dispatch_for_disposal(). Per-
thread isolation forces each disposal to carry its own authorization
explicitly.
WHY OPERATOR-CONFIGURED Gregorian DATES, not Hebrew calendar:
The Hebrew-calendar conversion lives in vav_pipeline (Elixir) — we
do not bring a Hebrew-calendar library into azazel_processor's
dependency surface. Operators set the Gregorian range each year (or
delegate it to a wrapper script). For now, this module is purely
data-shape; commit 9 may wire vav_pipeline's Hebrew calendar to set
the window automatically.
CREATED:
src/azazel_processor/protocol.py ~155 lines
ProtocolViolation — raised on out-of-envelope
invocation; error message
names BOTH legitimate paths
(Yom Kippur window, authorize
context) so the operator
knows their options
set_yom_kippur_window(start, end) — operator-configured;
inclusive Gregorian dates;
inverted range clears
clear_yom_kippur_window() — disable
is_yom_kippur_protocol_active(today=None)
— date-range membership
authorize_disposal() — context manager; reentrant
(nested entries each
increment a counter, each
exit decrements; cleared
via try/finally even on
inner exception)
has_explicit_disposal_authorization() — per-thread, scoped
assert_protocol_envelope_active() — the gate
CHANGED:
src/azazel_processor/azazel.py
dispatch_for_disposal now calls assert_protocol_envelope_active()
as the FIRST check. Outside the envelope: ProtocolViolation
raised before any side-effect (no shaliach activation, no payload
held, no checkpoint persisted).
test/test_azazel.py
Added an autouse `_envelope` fixture that wraps every existing
orchestrator test in `with authorize_disposal():`. Protocol
restriction is verified separately in test_protocol_restriction.py.
CREATED:
test/test_protocol_restriction.py 16 tests covering:
- default state (no window, no authorization) refuses dispatch
with a ProtocolViolation that names both legitimate paths
- assert_protocol_envelope_active raises in default state
- both predicates False in default state
- Yom Kippur window: today inside → active; outside → inactive;
inverted range clears; inclusive at boundaries; clear disables
- authorize_disposal: inside block → active; nested blocks
decrement correctly; exception inside still clears (try/finally);
per-thread isolation (sibling thread sees False even when parent
thread is inside an authorize_disposal block)
- dispatch proceeds inside authorize_disposal context
- dispatch proceeds inside Yom Kippur window
- outside envelope: NO side-effect (no in-flight file written, no
payload held by shaliach)
- protocol gate fires BEFORE role gate (inactive handler outside
envelope raises ProtocolViolation, not ShaliachError)
Per-test fixture clears Yom Kippur state on entry and exit so
module-level state doesn't bleed between tests.
PYTHON SIDE NOW FEATURE-COMPLETE:
Properties defended end-to-end via Python code:
2 goral
3 vidui
4 shaliach + tevilah
5 checkpoint persistence
7 authorized routing
8 protocol envelope
10 hard termination
11 classification discipline
12 unmanaged destination
Properties remaining (cross-language, in commits 7-9):
1 parity provisioning — commit 9 (Korban→Azazel intake)
6 out-of-band acknowledgment — commit 9 (chatzotzrot wiring)
9 observable disposal — commit 7 (L3) + commit 8 (endpoint)
Tests: azazel_processor 125 passed (was 109, +16). korban_processor
unchanged at 203 passed, 1 skipped. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
442 lines
15 KiB
Python
442 lines
15 KiB
Python
"""
|
|
Tests for the Azazel orchestrator (commit 5).
|
|
|
|
Property coverage:
|
|
- Property 5 (provisioned infrastructure / sukkot): checkpoints
|
|
are persisted at every step; in-flight files exist
|
|
during the route and are shredded after RECORDED.
|
|
- Property 7 (authorized routing): AzazelRecord carries
|
|
authorized_by + shaliach + routing=ERETZ_GEZERAH.
|
|
- Property 10 (hard termination): AzazelRecord does NOT carry the
|
|
disposed payload; payload references in the shaliach
|
|
are cleared at the DESTROYED checkpoint.
|
|
|
|
The structural tests in test_azazel_no_payload_field_in_record and
|
|
test_azazel_no_recover_disposed_function are the load-bearing CI
|
|
invariants — they catch future contributors who weaken property 10.
|
|
"""
|
|
|
|
import json
|
|
import pathlib
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
from azazel_processor.azazel import (
|
|
AzazelInput,
|
|
dispatch_for_disposal,
|
|
list_in_flight,
|
|
_persist_checkpoint,
|
|
)
|
|
from azazel_processor.azazel_record import AzazelRecord
|
|
from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah
|
|
from azazel_processor.protocol import authorize_disposal
|
|
from azazel_processor.shaliach import IshItti, ShaliachError
|
|
from azazel_processor.stages import AzazelStage
|
|
from azazel_processor.vidui import confess_empty_vital
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_in_flight():
|
|
"""Per-test in-flight directory so tests don't share checkpoint state."""
|
|
with tempfile.TemporaryDirectory(prefix="azazel-test-") as d:
|
|
yield pathlib.Path(d)
|
|
|
|
|
|
@pytest.fixture
|
|
def active_handler():
|
|
h = IshItti("aharon-the-shaliach")
|
|
h.activate()
|
|
yield h
|
|
if h.is_active():
|
|
h.tevilah()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _envelope():
|
|
"""Open the protocol envelope around every test in this module.
|
|
The protocol restriction (property 8) is verified separately in
|
|
test_protocol_restriction.py; the orchestrator tests assume the
|
|
envelope is open so they can exercise the rest of the state
|
|
machine without re-opening per dispatch."""
|
|
with authorize_disposal():
|
|
yield
|
|
|
|
|
|
def _make_input(case_ref="case-001", payload=None):
|
|
return AzazelInput(
|
|
case_ref=case_ref,
|
|
payload=payload if payload is not None else {"data": "would-be-disposed"},
|
|
classification="empty_vital",
|
|
triggering_call="external_offering.consult_external_model",
|
|
)
|
|
|
|
|
|
def _vidui():
|
|
return confess_empty_vital(
|
|
"Claude response with no extractable Torah", confessor="kohen-gadol"
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Happy path — full state machine
|
|
# ============================================================
|
|
|
|
|
|
def test_dispatch_returns_an_azazel_record(active_handler, tmp_in_flight):
|
|
record = dispatch_for_disposal(
|
|
_make_input(),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert isinstance(record, AzazelRecord)
|
|
|
|
|
|
def test_record_carries_authorization_and_shaliach_attribution(
|
|
active_handler, tmp_in_flight
|
|
):
|
|
"""Property 7: routing decision is OWNED."""
|
|
record = dispatch_for_disposal(
|
|
_make_input(),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol-aharon",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert record.authorized_by == "kohen-gadol-aharon"
|
|
assert record.shaliach == "aharon-the-shaliach"
|
|
|
|
|
|
def test_record_routes_to_eretz_gezerah(active_handler, tmp_in_flight):
|
|
"""Property 7 + 12: destination is the canonical sentinel."""
|
|
record = dispatch_for_disposal(
|
|
_make_input(),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert is_eretz_gezerah(record.routing)
|
|
assert record.routing is ERETZ_GEZERAH
|
|
|
|
|
|
def test_record_carries_the_full_vidui(active_handler, tmp_in_flight):
|
|
v = _vidui()
|
|
record = dispatch_for_disposal(
|
|
_make_input(),
|
|
handler=active_handler,
|
|
confess_with=v,
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert record.vidui is v
|
|
assert record.vidui.chatot == ("empty-vital offering: Claude response with no extractable Torah",)
|
|
|
|
|
|
# ============================================================
|
|
# Property 10: hard termination — no payload in the record
|
|
# ============================================================
|
|
|
|
|
|
def test_azazel_record_does_not_carry_payload(active_handler, tmp_in_flight):
|
|
"""Property 10: the disposed thing is destroyed; only the vidui
|
|
remains. The AzazelRecord has no `payload` attribute and would-be
|
|
payloads are not findable via vars()."""
|
|
secret_payload = {"sensitive": "this-must-be-disposed-not-stored"}
|
|
record = dispatch_for_disposal(
|
|
_make_input(payload=secret_payload),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
|
|
# The record must not have a payload field, by name
|
|
assert not hasattr(record, "payload")
|
|
# The record's serialized form must not contain the payload's
|
|
# marker string
|
|
serialized = json.dumps(record.to_storage_dict())
|
|
assert "this-must-be-disposed-not-stored" not in serialized
|
|
|
|
|
|
def test_no_payload_field_in_azazel_record_dataclass():
|
|
"""STRUCTURAL CI INVARIANT: AzazelRecord's field set must NOT
|
|
include `payload` (or any obvious alias). A future contributor
|
|
who adds a payload-bearing field violates property 10 and this
|
|
test fires before the regression ships."""
|
|
fields = set(AzazelRecord.__dataclass_fields__.keys())
|
|
forbidden = {"payload", "raw_payload", "disposed_payload", "data", "content"}
|
|
overlap = fields & forbidden
|
|
assert overlap == set(), (
|
|
f"AzazelRecord has payload-shaped field(s): {overlap}. Property "
|
|
f"10 (hard termination): the disposed payload MUST NOT be "
|
|
f"persisted on the record. Remove the field or reconsider."
|
|
)
|
|
|
|
|
|
def test_no_recover_disposed_function_in_module():
|
|
"""STRUCTURAL CI INVARIANT: there must be no recover_disposed,
|
|
undispose, restore_payload, or similar function in
|
|
azazel_processor's public surface. Property 10: no return path.
|
|
Eretz gezerah."""
|
|
import azazel_processor
|
|
|
|
public = [n for n in dir(azazel_processor) if not n.startswith("_")]
|
|
forbidden_substrings = ("recover", "undispose", "restore", "rehydrate", "retrieve")
|
|
|
|
offenders = [
|
|
name
|
|
for name in public
|
|
for sub in forbidden_substrings
|
|
if sub in name.lower()
|
|
]
|
|
|
|
assert offenders == [], (
|
|
f"azazel_processor exposes recovery-shaped names: {offenders}. "
|
|
f"Property 10: there is no return path. Eretz gezerah."
|
|
)
|
|
|
|
|
|
def test_payload_references_cleared_at_destroyed_checkpoint(
|
|
active_handler, tmp_in_flight
|
|
):
|
|
"""The shaliach's payload-refs list must be empty after dispatch
|
|
completes. Property 10 — the handler does not retain the disposed
|
|
payload."""
|
|
dispatch_for_disposal(
|
|
_make_input(payload={"big": "payload"}),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert active_handler.held_payload_count() == 0
|
|
|
|
|
|
# ============================================================
|
|
# Property 5: provisioned infrastructure — sukkot persistence
|
|
# ============================================================
|
|
|
|
|
|
def test_in_flight_file_is_shredded_after_recorded_checkpoint(
|
|
active_handler, tmp_in_flight
|
|
):
|
|
"""After the disposal completes (RECORDED done), the in-flight
|
|
file is unlinked. The disposal is permanent in L3 (commit 7);
|
|
the in-flight artifact must not linger."""
|
|
dispatch_for_disposal(
|
|
_make_input(case_ref="case-shred-001"),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert list_in_flight(tmp_in_flight) == []
|
|
|
|
|
|
def test_checkpoint_is_persisted_at_each_stage(active_handler, tmp_in_flight):
|
|
"""Verify state is persisted incrementally: capture the file at
|
|
each checkpoint via custom recorder/acknowledger callbacks."""
|
|
seen_at = {}
|
|
|
|
def at_acknowledged(record):
|
|
# By ACKNOWLEDGED, the in-flight file must contain
|
|
# last_completed = ACKNOWLEDGED's PRIOR step (DESTROYED).
|
|
# We've persisted ACKNOWLEDGED before invoking ack? No — we
|
|
# invoke ack AT this checkpoint, then persist. So when this
|
|
# callback fires, the file shows last_completed = DESTROYED.
|
|
# Actually, looking at the orchestrator: we invoke
|
|
# acknowledger() first, then persist ACKNOWLEDGED.
|
|
body = json.loads((tmp_in_flight / "case-cp-001.json").read_text())
|
|
seen_at["ack"] = body["last_completed"]
|
|
|
|
def at_recorded(record):
|
|
body = json.loads((tmp_in_flight / "case-cp-001.json").read_text())
|
|
seen_at["rec"] = body["last_completed"]
|
|
|
|
dispatch_for_disposal(
|
|
_make_input(case_ref="case-cp-001"),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
acknowledger=at_acknowledged,
|
|
recorder=at_recorded,
|
|
)
|
|
|
|
# When acknowledger fired, last_completed should be DESTROYED
|
|
# (the ack callback runs BEFORE we persist the ACKNOWLEDGED
|
|
# checkpoint).
|
|
assert seen_at["ack"] == AzazelStage.DESTROYED.value
|
|
# When recorder fired, last_completed should be ACKNOWLEDGED.
|
|
assert seen_at["rec"] == AzazelStage.ACKNOWLEDGED.value
|
|
|
|
|
|
def test_list_in_flight_returns_partial_disposals(tmp_in_flight):
|
|
"""If we manually persist a partial state (simulating a crash),
|
|
list_in_flight surfaces it for resume."""
|
|
# Manually create an in-flight file at the CONFESSED checkpoint.
|
|
_persist_checkpoint(
|
|
tmp_in_flight,
|
|
"case-partial-001",
|
|
AzazelStage.CONFESSED,
|
|
{"shaliach": "aharon"},
|
|
)
|
|
inflight = list_in_flight(tmp_in_flight)
|
|
assert len(inflight) == 1
|
|
assert inflight[0]["case_ref"] == "case-partial-001"
|
|
assert inflight[0]["last_completed"] == "confessed"
|
|
|
|
|
|
def test_list_in_flight_empty_when_no_disposals(tmp_in_flight):
|
|
assert list_in_flight(tmp_in_flight) == []
|
|
|
|
|
|
def test_checkpoint_persistence_is_atomic(active_handler, tmp_in_flight):
|
|
"""The persist path uses write-then-rename so a crash mid-write
|
|
leaves the previous checkpoint intact rather than a half-written
|
|
file. We can't easily simulate a crash, but we CAN verify the
|
|
final file is valid JSON at every checkpoint visit."""
|
|
files_seen = []
|
|
|
|
def at_recorded(record):
|
|
# Inspect the current file
|
|
if (tmp_in_flight / "case-atom-001.json").exists():
|
|
text = (tmp_in_flight / "case-atom-001.json").read_text()
|
|
files_seen.append(json.loads(text)) # raises if invalid
|
|
|
|
dispatch_for_disposal(
|
|
_make_input(case_ref="case-atom-001"),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
recorder=at_recorded,
|
|
)
|
|
assert len(files_seen) == 1
|
|
assert files_seen[0]["last_completed"] == "acknowledged"
|
|
|
|
|
|
# ============================================================
|
|
# Role gate — property 4 still enforced
|
|
# ============================================================
|
|
|
|
|
|
def test_dispatch_refuses_inactive_handler(tmp_in_flight):
|
|
h = IshItti("aharon")
|
|
# Not activated
|
|
with pytest.raises(ShaliachError):
|
|
dispatch_for_disposal(
|
|
_make_input(),
|
|
handler=h,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
|
|
|
|
def test_dispatch_refuses_non_ish_itti(tmp_in_flight):
|
|
with pytest.raises(ShaliachError):
|
|
dispatch_for_disposal(
|
|
_make_input(),
|
|
handler="not-a-handler", # type: ignore[arg-type]
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Filename safety
|
|
# ============================================================
|
|
|
|
|
|
def test_dispatch_rejects_case_ref_with_slash(active_handler, tmp_in_flight):
|
|
bad = AzazelInput(
|
|
case_ref="../escape/attempt",
|
|
payload={},
|
|
classification="empty_vital",
|
|
)
|
|
with pytest.raises(ValueError):
|
|
dispatch_for_disposal(
|
|
bad,
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
|
|
|
|
def test_dispatch_rejects_case_ref_with_nul(active_handler, tmp_in_flight):
|
|
bad = AzazelInput(
|
|
case_ref="case\0nul",
|
|
payload={},
|
|
classification="empty_vital",
|
|
)
|
|
with pytest.raises(ValueError):
|
|
dispatch_for_disposal(
|
|
bad,
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# AzazelRecord validation
|
|
# ============================================================
|
|
|
|
|
|
def test_record_requires_authorized_by():
|
|
with pytest.raises(ValueError):
|
|
AzazelRecord(
|
|
case_ref="x",
|
|
vidui=_vidui(),
|
|
routing=ERETZ_GEZERAH,
|
|
authorized_by="",
|
|
shaliach="aharon",
|
|
)
|
|
|
|
|
|
def test_record_requires_shaliach():
|
|
with pytest.raises(ValueError):
|
|
AzazelRecord(
|
|
case_ref="x",
|
|
vidui=_vidui(),
|
|
routing=ERETZ_GEZERAH,
|
|
authorized_by="kohen-gadol",
|
|
shaliach="",
|
|
)
|
|
|
|
|
|
def test_record_to_storage_dict_omits_payload():
|
|
"""The serialization for L3 must not contain a payload field."""
|
|
record = AzazelRecord(
|
|
case_ref="case-storage-001",
|
|
vidui=_vidui(),
|
|
routing=ERETZ_GEZERAH,
|
|
authorized_by="kohen-gadol",
|
|
shaliach="aharon",
|
|
)
|
|
body = record.to_storage_dict()
|
|
assert "payload" not in body
|
|
assert "raw_payload" not in body
|
|
assert body["routing"] == "eretz_gezerah"
|
|
assert body["authorized_by"] == "kohen-gadol"
|
|
assert body["shaliach"] == "aharon"
|
|
|
|
|
|
def test_record_is_frozen():
|
|
record = AzazelRecord(
|
|
case_ref="case-frozen-001",
|
|
vidui=_vidui(),
|
|
routing=ERETZ_GEZERAH,
|
|
authorized_by="kohen-gadol",
|
|
shaliach="aharon",
|
|
)
|
|
with pytest.raises((AttributeError, Exception)):
|
|
record.shaliach = "different" # type: ignore[misc]
|