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>
263 lines
8.6 KiB
Python
263 lines
8.6 KiB
Python
"""
|
|
Tests for the protocol envelope (property 8) — protocol-restricted
|
|
access. Vayikra 17:7: lo yizbechu od et zivchehem la'sa'irim — the
|
|
same destination outside the Yom Kippur envelope is avodah zarah.
|
|
|
|
These tests deliberately do NOT auto-open the envelope (unlike
|
|
test_azazel.py). They exercise the gate directly: verify dispatch
|
|
refuses outside the envelope, accepts inside the Yom Kippur window,
|
|
accepts inside an authorize_disposal context, and that the
|
|
authorization scope is per-thread and properly nested.
|
|
"""
|
|
|
|
import pathlib
|
|
import tempfile
|
|
import threading
|
|
from datetime import date, timedelta
|
|
|
|
import pytest
|
|
|
|
from azazel_processor.azazel import AzazelInput, dispatch_for_disposal
|
|
from azazel_processor.protocol import (
|
|
ProtocolViolation,
|
|
assert_protocol_envelope_active,
|
|
authorize_disposal,
|
|
clear_yom_kippur_window,
|
|
has_explicit_disposal_authorization,
|
|
is_yom_kippur_protocol_active,
|
|
set_yom_kippur_window,
|
|
)
|
|
from azazel_processor.shaliach import IshItti
|
|
from azazel_processor.vidui import confess_empty_vital
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_protocol_state():
|
|
"""Ensure each test starts with no Yom Kippur window and no
|
|
leftover thread-local authorization. Module-level state in
|
|
protocol.py persists across tests if we don't reset."""
|
|
clear_yom_kippur_window()
|
|
yield
|
|
clear_yom_kippur_window()
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_in_flight():
|
|
with tempfile.TemporaryDirectory(prefix="azazel-protocol-test-") as d:
|
|
yield pathlib.Path(d)
|
|
|
|
|
|
@pytest.fixture
|
|
def active_handler():
|
|
h = IshItti("aharon")
|
|
h.activate()
|
|
yield h
|
|
if h.is_active():
|
|
h.tevilah()
|
|
|
|
|
|
def _input(case_ref="proto-001"):
|
|
return AzazelInput(
|
|
case_ref=case_ref, payload={}, classification="empty_vital"
|
|
)
|
|
|
|
|
|
def _vidui():
|
|
return confess_empty_vital("placeholder", confessor="kohen-gadol")
|
|
|
|
|
|
# ============================================================
|
|
# Default state: refused
|
|
# ============================================================
|
|
|
|
|
|
def test_default_state_refuses_dispatch(active_handler, tmp_in_flight):
|
|
"""No Yom Kippur window configured + no authorization → ProtocolViolation."""
|
|
with pytest.raises(ProtocolViolation) as exc:
|
|
dispatch_for_disposal(
|
|
_input(),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
msg = str(exc.value)
|
|
# Error message names both legitimate paths
|
|
assert "yom kippur" in msg.lower() or "set_yom_kippur_window" in msg
|
|
assert "authorize_disposal" in msg
|
|
|
|
|
|
def test_default_state_assert_raises():
|
|
with pytest.raises(ProtocolViolation):
|
|
assert_protocol_envelope_active()
|
|
|
|
|
|
def test_default_state_neither_predicate_active():
|
|
assert is_yom_kippur_protocol_active() is False
|
|
assert has_explicit_disposal_authorization() is False
|
|
|
|
|
|
# ============================================================
|
|
# Yom Kippur window
|
|
# ============================================================
|
|
|
|
|
|
def test_yom_kippur_window_today_inside_active():
|
|
today = date(2026, 9, 21)
|
|
set_yom_kippur_window(today - timedelta(days=3), today + timedelta(days=3))
|
|
assert is_yom_kippur_protocol_active(today=today) is True
|
|
|
|
|
|
def test_yom_kippur_window_today_outside_inactive():
|
|
set_yom_kippur_window(date(2026, 9, 1), date(2026, 9, 5))
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 12, 1)) is False
|
|
|
|
|
|
def test_yom_kippur_window_inverted_clears():
|
|
"""Setting a window where start > end clears it (no valid window)."""
|
|
set_yom_kippur_window(date(2026, 9, 5), date(2026, 9, 1))
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 9, 3)) is False
|
|
|
|
|
|
def test_yom_kippur_window_inclusive_at_boundaries():
|
|
set_yom_kippur_window(date(2026, 9, 1), date(2026, 9, 5))
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 9, 1)) is True
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 9, 5)) is True
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 8, 31)) is False
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 9, 6)) is False
|
|
|
|
|
|
def test_clear_yom_kippur_window_disables():
|
|
set_yom_kippur_window(date(2026, 1, 1), date(2026, 12, 31))
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 6, 1)) is True
|
|
clear_yom_kippur_window()
|
|
assert is_yom_kippur_protocol_active(today=date(2026, 6, 1)) is False
|
|
|
|
|
|
# ============================================================
|
|
# Explicit-authorization context manager
|
|
# ============================================================
|
|
|
|
|
|
def test_authorize_disposal_inside_block_active():
|
|
assert has_explicit_disposal_authorization() is False
|
|
with authorize_disposal():
|
|
assert has_explicit_disposal_authorization() is True
|
|
assert has_explicit_disposal_authorization() is False
|
|
|
|
|
|
def test_authorize_disposal_nested_blocks_decrement_correctly():
|
|
"""Reentrancy: nested with-blocks each increment, each exit
|
|
decrements. Authorization stays active while at least one is open."""
|
|
assert has_explicit_disposal_authorization() is False
|
|
with authorize_disposal():
|
|
assert has_explicit_disposal_authorization() is True
|
|
with authorize_disposal():
|
|
assert has_explicit_disposal_authorization() is True
|
|
# Inner exited; outer still holds
|
|
assert has_explicit_disposal_authorization() is True
|
|
assert has_explicit_disposal_authorization() is False
|
|
|
|
|
|
def test_authorize_disposal_exception_inside_still_clears():
|
|
"""If an exception fires inside the with-block, the authorization
|
|
is still cleared on exit (try/finally guarantee)."""
|
|
with pytest.raises(RuntimeError):
|
|
with authorize_disposal():
|
|
assert has_explicit_disposal_authorization() is True
|
|
raise RuntimeError("boom")
|
|
assert has_explicit_disposal_authorization() is False
|
|
|
|
|
|
def test_authorize_disposal_is_per_thread():
|
|
"""One thread's authorization MUST NOT leak to another. Per-thread
|
|
isolation is the load-bearing scoping property."""
|
|
sibling_authorized = []
|
|
|
|
def sibling():
|
|
sibling_authorized.append(has_explicit_disposal_authorization())
|
|
|
|
with authorize_disposal():
|
|
t = threading.Thread(target=sibling)
|
|
t.start()
|
|
t.join()
|
|
|
|
assert sibling_authorized == [False]
|
|
|
|
|
|
# ============================================================
|
|
# dispatch_for_disposal honors the gate
|
|
# ============================================================
|
|
|
|
|
|
def test_dispatch_proceeds_inside_authorize_disposal(
|
|
active_handler, tmp_in_flight
|
|
):
|
|
with authorize_disposal():
|
|
record = dispatch_for_disposal(
|
|
_input(),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert record is not None
|
|
|
|
|
|
def test_dispatch_proceeds_inside_yom_kippur_window(
|
|
active_handler, tmp_in_flight
|
|
):
|
|
today = date.today()
|
|
set_yom_kippur_window(today - timedelta(days=1), today + timedelta(days=1))
|
|
record = dispatch_for_disposal(
|
|
_input(),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
assert record is not None
|
|
|
|
|
|
def test_dispatch_outside_envelope_writes_no_in_flight_file(
|
|
active_handler, tmp_in_flight
|
|
):
|
|
"""Property 8 + 5 interaction: when the gate refuses, NO
|
|
side-effect occurs. No checkpoint persisted, no payload held,
|
|
no L3 record created."""
|
|
try:
|
|
dispatch_for_disposal(
|
|
_input(case_ref="must-not-persist-001"),
|
|
handler=active_handler,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|
|
except ProtocolViolation:
|
|
pass
|
|
|
|
# No file should have been created
|
|
assert not (tmp_in_flight / "must-not-persist-001.json").exists()
|
|
# The shaliach should not have held the payload
|
|
assert active_handler.held_payload_count() == 0
|
|
|
|
|
|
def test_protocol_violation_raised_before_any_other_check(
|
|
active_handler, tmp_in_flight
|
|
):
|
|
"""The protocol envelope is checked FIRST — even before the role
|
|
gate. An inactive handler invoked outside the envelope should
|
|
raise ProtocolViolation, not ShaliachError. This ordering matters
|
|
because protocol restriction is the avodah-zarah-shape defense:
|
|
we refuse on protocol grounds before doing any other validation."""
|
|
h = IshItti("aharon")
|
|
# Don't activate
|
|
with pytest.raises(ProtocolViolation):
|
|
dispatch_for_disposal(
|
|
_input(),
|
|
handler=h,
|
|
confess_with=_vidui(),
|
|
authorized_by="kohen-gadol",
|
|
in_flight_dir=tmp_in_flight,
|
|
)
|