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>
This commit is contained in:
Scott KarpandClaude Opus 4.7 committed 2026-04-28 08:06:27 -04:00
1 parent c3267c59bf
commit 6eb555a5cf
5 files changed
+510 -4

No files matched your search

@@ -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",
]
@@ -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)
@@ -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)."
)
+12
View File
@@ -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,
@@ -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,
)