Files
bikoret/azazel_processor/test/test_azazel.py
Scott KarpandClaude Opus 4.7 6eb555a5cf azazel_processor: commit 6 — protocol envelope (property 8)
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>
2026-04-28 08:06:27 -04:00

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]