diff --git a/azazel_processor/src/azazel_processor/__init__.py b/azazel_processor/src/azazel_processor/__init__.py index 353d637a..e087f21c 100644 --- a/azazel_processor/src/azazel_processor/__init__.py +++ b/azazel_processor/src/azazel_processor/__init__.py @@ -6,9 +6,9 @@ uv'd'mim, Yoma 62a). One goat to HaShem (processing); one goat to Azazel (disposal). The two channels are provisioned at parity, from the same pool, with assignment blind to the operator. -Build state: commit 5 — orchestrator state machine added. See -BUILD_AZAZEL.md for the full 10-commit staging plan and the -12-property → code mapping from azazel_engineering_reading.md §14. +Build state: commit 6 — protocol envelope added. The Python side of +the build is now feature-complete; commits 7-9 wire the cross-language +integrations (L3, observability endpoint, intake-side routing). Properties defended so far: - Property 2 (blind routing / goral) — cast_lot @@ -17,6 +17,8 @@ Properties defended so far: - Property 5 (provisioned infrastructure) — checkpoint persistence - Property 7 (authorized routing) — AzazelRecord owns the routing decision + - Property 8 (protocol-restricted access) — envelope gate at + dispatch entry - Property 10 (hard termination) — DESTROYED checkpoint + record without payload - Property 11 (classification discipline) — AzazelStage* @@ -34,9 +36,13 @@ Public surface: GoralError — blind routing AzazelInput, AzazelRecord — orchestrator data types dispatch_for_disposal, list_in_flight — orchestrator API + ProtocolViolation, authorize_disposal, + is_yom_kippur_protocol_active, + has_explicit_disposal_authorization, + set_yom_kippur_window, + clear_yom_kippur_window — protocol envelope Pending in subsequent commits: - protocol envelope (commit 6 — protocol-restricted access) L3 azazel_records integration (commit 7) /peah/azazel_log endpoint (commit 8) Korban→Azazel routing + chatzotzrot (commit 9) @@ -56,6 +62,15 @@ from azazel_processor.goral import ( cast_lot, cast_lot_outcome_id, ) +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, ShaliachError, @@ -97,4 +112,11 @@ __all__ = [ "dispatch_for_disposal", "list_in_flight", "DEFAULT_IN_FLIGHT_DIR", + "ProtocolViolation", + "authorize_disposal", + "is_yom_kippur_protocol_active", + "has_explicit_disposal_authorization", + "set_yom_kippur_window", + "clear_yom_kippur_window", + "assert_protocol_envelope_active", ] diff --git a/azazel_processor/src/azazel_processor/azazel.py b/azazel_processor/src/azazel_processor/azazel.py index 3b4c4f35..4bdc251d 100644 --- a/azazel_processor/src/azazel_processor/azazel.py +++ b/azazel_processor/src/azazel_processor/azazel.py @@ -69,6 +69,7 @@ from typing import Any, Callable, Optional from azazel_processor.azazel_record import AzazelRecord from azazel_processor.eretz_gezerah import ERETZ_GEZERAH +from azazel_processor.protocol import assert_protocol_envelope_active from azazel_processor.shaliach import IshItti, assert_shaliach_active from azazel_processor.stages import AzazelStage, AzazelStaged from azazel_processor.vidui import Vidui @@ -203,6 +204,13 @@ def dispatch_for_disposal( ValueError: case_ref is not filename-safe, or other input validation fails """ + # Protocol envelope gate — property 8 + # Must be the FIRST check: invoking the Azazel channel outside the + # envelope is the avodah-zarah shape (Vayikra 17:7), refused before + # any other side-effect (no shaliach activation, no payload + # registration, no checkpoint persistence). + assert_protocol_envelope_active() + # Role gate — property 4 assert_shaliach_active(handler) diff --git a/azazel_processor/src/azazel_processor/protocol.py b/azazel_processor/src/azazel_processor/protocol.py new file mode 100644 index 00000000..218aa8f8 --- /dev/null +++ b/azazel_processor/src/azazel_processor/protocol.py @@ -0,0 +1,202 @@ +""" +Protocol envelope — property 8 (protocol-restricted access). + +Vayikra 16 specifies the Azazel goat goes ONCE A YEAR, on Yom Kippur, +under the full ritual envelope: the kohen gadol presides, the lots +are cast, the vidui is enumerated, the goat is sent by the ish itti. + +Vayikra 17:7 prohibits the same routing OUTSIDE that envelope: +וְלֹא־יִזְבְּחוּ עוֹד אֶת־זִבְחֵיהֶם לַשְּׂעִירִם — "they shall no +longer offer their sacrifices to the sa'irim." Same destination, +different routing authority. Inside the envelope it is the legitimate +Azazel-path; outside, it is avodah zarah — the failure mode +specs/eish_zarah_reading.md catalogs. + +In Shorashim software: + + - is_yom_kippur_protocol_active() — predicate: are we in the + annual Yom Kippur window the operator has configured? The + Hebrew-calendar conversion is left to the operator (set the + window explicitly via set_yom_kippur_window). + + - authorize_disposal() — context manager: marks the current + thread as having explicit authorization for ONE disposal cycle. + Used for event-driven disposals during the year (e.g. an + empty-vital korban arriving outside the annual window). The + authorization is per-thread, scoped to the with-block; it is + NOT a persistent flag the operator can leave set. + + - assert_protocol_envelope_active() — gate: raises ProtocolViolation + if NEITHER condition is met. dispatch_for_disposal calls this + at entry. + +Why authorize_disposal() is a context manager rather than a flag: + +A persistent flag would mean an operator could "set authorization +true and leave it" — which is exactly the avodah zarah shape (using +the channel without the protocol envelope). The context manager +forces explicit scope: the authorization exists ONLY within the +with-block, and only on the thread that opened it. A function called +across the with-block boundary loses authorization unless the caller +reopens it. + +Why per-thread: + +If authorization were process-wide, two concurrent disposals could +share authorization 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. +""" + +import contextlib +import threading +from datetime import date, datetime, timezone +from typing import Optional + + +class ProtocolViolation(RuntimeError): + """Raised when a disposal-channel function is invoked outside + the protocol envelope. + + The Mishkan analog: someone offered a sacrifice to the sa'irim + outside Yom Kippur (Vayikra 17:7). Same destination, different + routing authority — and outside the envelope, the routing + authority is illegitimate. The system refuses. + """ + + +# ============================================================ +# Yom Kippur annual window (operator-configured) +# ============================================================ +# +# Module-level state because the window is process-global by design: +# Yom Kippur is one date for the system, not per-thread. The operator +# sets it once at process startup and it applies to every subsequent +# dispatch on this process. +# +# Hebrew-calendar conversion is the operator's responsibility — we +# do not bring a Hebrew-calendar library into azazel_processor's +# dependency surface. The vav_pipeline (Elixir) side already knows +# how to compute Hebrew dates; for now this module just stores the +# Gregorian range the operator supplies. + +_yom_kippur_window: Optional[tuple[date, date]] = None +_window_lock = threading.Lock() + + +def set_yom_kippur_window(start: date, end: date) -> None: + """Configure the annual Yom Kippur window. + + `start` and `end` are inclusive Gregorian dates. The operator + is responsible for setting the window each year (or a process + that wraps multiple years). Setting an empty window + (start > end) clears the window — disposal must then go through + the explicit-authorization path. + + Thread-safe; the lock prevents two concurrent set_yom_kippur_window + calls from leaving the window in an inconsistent state. + """ + global _yom_kippur_window + with _window_lock: + if start > end: + _yom_kippur_window = None + else: + _yom_kippur_window = (start, end) + + +def clear_yom_kippur_window() -> None: + """Clear the configured window. After this, only explicit + authorization (via authorize_disposal context manager) permits + dispatch.""" + global _yom_kippur_window + with _window_lock: + _yom_kippur_window = None + + +def is_yom_kippur_protocol_active(today: Optional[date] = None) -> bool: + """True iff the operator has configured a Yom Kippur window AND + today falls within it. + + `today` defaults to UTC today; tests can pass an explicit date. + """ + with _window_lock: + window = _yom_kippur_window + + if window is None: + return False + if today is None: + today = datetime.now(timezone.utc).date() + start, end = window + return start <= today <= end + + +# ============================================================ +# Per-thread explicit-authorization scope +# ============================================================ +# +# threading.local() gives us per-thread isolation: authorize_disposal() +# sets a flag visible only on the current thread; other threads +# cannot see or share it. + +_thread_local = threading.local() + + +def has_explicit_disposal_authorization() -> bool: + """True iff the current thread is inside an authorize_disposal() + with-block. Per-thread, scoped to the with-block.""" + return getattr(_thread_local, "authorized", 0) > 0 + + +@contextlib.contextmanager +def authorize_disposal(): + """Context manager: explicitly authorize one disposal cycle on + the current thread. + + Use for event-driven disposals during the year (outside the + Yom Kippur window). The authorization exists ONLY within the + with-block; on exit the flag is decremented and (if reaching 0) + cleared. Reentrant — nesting is permitted (each entry + increments the counter, each exit decrements). + + Usage: + + from azazel_processor.protocol import authorize_disposal + from azazel_processor.azazel import dispatch_for_disposal + + with authorize_disposal(): + record = dispatch_for_disposal(input, ...) + """ + current = getattr(_thread_local, "authorized", 0) + _thread_local.authorized = current + 1 + try: + yield + finally: + _thread_local.authorized = max(0, _thread_local.authorized - 1) + + +def assert_protocol_envelope_active() -> None: + """Gate. Raises ProtocolViolation if NEITHER: + - is_yom_kippur_protocol_active() is True (operator-configured + annual window covers today) + - has_explicit_disposal_authorization() is True (current thread + is inside an authorize_disposal() with-block) + + Called by dispatch_for_disposal() at entry. Outside this gate, + invoking the Azazel channel is the avodah zarah shape (Vayikra + 17:7); the system refuses. + """ + if is_yom_kippur_protocol_active(): + return + if has_explicit_disposal_authorization(): + return + + raise ProtocolViolation( + "Azazel disposal cannot proceed outside the protocol envelope. " + "Either: (a) configure the Yom Kippur window via " + "set_yom_kippur_window(start, end) and invoke during it, or " + "(b) wrap the dispatch in an authorize_disposal() context " + "manager. Invoking the disposal channel privately is the " + "avodah zarah shape (Vayikra 17:7 — lo yizbechu od et " + "zivchehem la'sa'irim)." + ) diff --git a/azazel_processor/test/test_azazel.py b/azazel_processor/test/test_azazel.py index 53dfdf16..0f59f4a4 100644 --- a/azazel_processor/test/test_azazel.py +++ b/azazel_processor/test/test_azazel.py @@ -30,6 +30,7 @@ from azazel_processor.azazel import ( ) 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 @@ -51,6 +52,17 @@ def active_handler(): 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, diff --git a/azazel_processor/test/test_protocol_restriction.py b/azazel_processor/test/test_protocol_restriction.py new file mode 100644 index 00000000..59540df4 --- /dev/null +++ b/azazel_processor/test/test_protocol_restriction.py @@ -0,0 +1,262 @@ +""" +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, + )