Files
bikoret/azazel_processor/test/test_protocol_restriction.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

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,
)