diff --git a/azazel_processor/src/azazel_processor/__init__.py b/azazel_processor/src/azazel_processor/__init__.py index 6d5871b7..dc73921d 100644 --- a/azazel_processor/src/azazel_processor/__init__.py +++ b/azazel_processor/src/azazel_processor/__init__.py @@ -6,12 +6,13 @@ 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 2 — vidui 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 3 — shaliach + tevilah added. See BUILD_AZAZEL.md +for the full 10-commit staging plan and the 12-property → code +mapping from azazel_engineering_reading.md §14. Properties defended so far: - Property 3 (enumerative commit) — Vidui + confess() + - Property 4 (role-separated operator) — IshItti + tevilah - Property 11 (classification discipline) — AzazelStage* - Property 12 (unmanaged destination) — ERETZ_GEZERAH @@ -22,15 +23,20 @@ Public surface: confess_empty_vital, confess_halt, confess_boundary_violation, confess_malformed_input — categorization helpers + IshItti, ShaliachError, assert_shaliach_active — disposal handler Pending in subsequent commits: - shaliach + tevilah (commit 3 — role-separated operator) goral (commit 4 — blind routing) azazel orchestrator (commit 5 — full state machine) protocol envelope (commit 6 — protocol-restricted access) """ from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah +from azazel_processor.shaliach import ( + IshItti, + ShaliachError, + assert_shaliach_active, +) from azazel_processor.stages import AzazelStage, AzazelStaged, AzazelStageError from azazel_processor.vidui import ( EmptyConfessionError, @@ -55,4 +61,7 @@ __all__ = [ "confess_halt", "confess_boundary_violation", "confess_malformed_input", + "IshItti", + "ShaliachError", + "assert_shaliach_active", ] diff --git a/azazel_processor/src/azazel_processor/shaliach.py b/azazel_processor/src/azazel_processor/shaliach.py new file mode 100644 index 00000000..63233b2b --- /dev/null +++ b/azazel_processor/src/azazel_processor/shaliach.py @@ -0,0 +1,226 @@ +""" +Shaliach — the designated disposal handler (property 4 of +azazel_engineering_reading.md §14). + +Vayikra 16:21 — וְשִׁלַּח בְּיַד־אִישׁ עִתִּי הַמִּדְבָּרָה. +And he shall send the goat into the wilderness by the hand of a +designated man (ish itti). + +Vayikra 16:26 — וְהַמְשַׁלֵּחַ אֶת־הַשָּׂעִיר לַעֲזָאזֵל יְכַבֵּס בְּגָדָיו +וְרָחַץ אֶת־בְּשָׂרוֹ בַּמַּיִם וְאַחֲרֵי־כֵן יָבוֹא אֶל־הַמַּחֲנֶה. +And he who sends the goat to Azazel shall wash his clothes and bathe +his flesh in water (tevilah), and AFTERWARD come into the camp. + +Yoma 67a: the ish itti is "prepared in advance for exactly this task" +— a dedicated operator role, distinct from the kohanim who handle +sacred processing. Contact with the disposal channel produces state +that must be reset (tevilah) before the operator rejoins the +trusted boundary. + +THE THREE ROLES IN SHORASHIM: + + Owner — submits the offering. Performed by the upstream + caller (e.g. external_offering.consult_external_model). + Cannot be a kohen and cannot be a shaliach. + Kohen — receives the offering from the owner, performs + shechitah/zerikah/haktarah. korban_processor's + stages.py enforces owner/kohen separation via the + Staged envelope. + Shaliach — receives input that the kohen REFUSED (empty-vital + korban, halt-tripping content, malformed responses) + and disposes of it through the Azazel protocol. This + module defines the role. + +The shaliach's accumulated state must be reset between disposals. +Without tevilah, payload references would persist in the handler's +memory across disposals — the disposal of disposal-N would +"contaminate" disposal-N+1 with information from N. The Mishkan +analog: the ish itti who returns from the wilderness without +tevilah brings desert-state with him into the camp. + +CI INVARIANT (test_shaliach.py, test_tevilah_clears_all_state): + +Any non-`_immutable_` attribute on IshItti MUST be reset by +tevilah(). A future contributor who adds `self._cache = {}` (or any +other state-bearing attribute) without extending tevilah() to clear +it breaks the property — and the test fails before the regression +ships. + +The naming convention: attributes that are SET ONCE at construction +and never change for the lifetime of the handler must start with +`_immutable_`. Everything else is mutable per-disposal state and +must be cleared. +""" + +from typing import Any + + +class ShaliachError(RuntimeError): + """Raised when shaliach role-separation is violated. + + The Mishkan analog: a kohen tried to perform the disposal walk, + or the ish itti tried to perform an altar function. The roles + are distinct by specification; cross-role invocation is refused. + """ + + +class IshItti: + """The designated disposal handler. + + Lifecycle: + __init__ — set the handler's identifier (immutable) + activate() — begin a disposal; mark the handler active + hold_payload() — register a payload for later destruction + tevilah() — wash before rejoining the camp (Vayikra 16:26); + clears all non-immutable state, deactivates + + Re-activation requires a fresh tevilah. The same handler can + perform many disposals across its lifetime, but only one at a + time, with mandatory state-reset between. + """ + + def __init__(self, identifier: str): + if not isinstance(identifier, str) or not identifier.strip(): + raise ShaliachError( + "IshItti requires a named identifier (the operator " + "id of the designated handler). Anonymous shaliach " + "defeats audit traceability." + ) + # Immutable — the handler's identity is fixed at construction + # and propagates onto AzazelRecords via shaliach attribution + # (commit 7). The `_immutable_` prefix tells tevilah to leave + # it alone. + self._immutable_identifier: str = identifier + # Mutable per-disposal state — all of this must be cleared by + # tevilah() before the handler can be re-used. + self._active: bool = False + self._payload_refs: list[Any] = [] + self._intermediate_tags: dict[str, Any] = {} + + @property + def identifier(self) -> str: + """The handler's stable identifier — immutable across disposals.""" + return self._immutable_identifier + + def is_active(self) -> bool: + """True iff this handler is currently in the middle of a + disposal. The orchestrator (commit 5) checks this at every + checkpoint; a handler that becomes inactive mid-route halts + the disposal.""" + return self._active + + def activate(self) -> None: + """Begin a disposal. Marks the handler active. + + Refuses if already active — re-activation without tevilah + would mix two disposals' state, the exact failure mode + tevilah is required to prevent.""" + if self._active: + raise ShaliachError( + f"IshItti {self._immutable_identifier!r} is already " + f"active. tevilah() required before re-activation; " + f"the handler must wash before rejoining the camp " + f"(Vayikra 16:26) and may not begin a new disposal " + f"with state from the previous one." + ) + self._active = True + + def hold_payload(self, payload: Any) -> None: + """Register a payload reference for later destruction. + + The shaliach holds the goat from intake until the + DESTROYED checkpoint, then releases. References tracked + here are what the orchestrator clears at destruction + time (commit 5, property 10 — hard termination). + """ + if not self._active: + raise ShaliachError( + "Cannot hold payload — shaliach is not active. " + "Call activate() before beginning a disposal." + ) + self._payload_refs.append(payload) + + def tag(self, key: str, value: Any) -> None: + """Attach an intermediate tag during the disposal route. + + Used by the orchestrator's checkpoints to record per-disposal + metadata (commit 5). All tags are cleared by tevilah.""" + if not self._active: + raise ShaliachError( + "Cannot tag — shaliach is not active." + ) + self._intermediate_tags[key] = value + + def get_tag(self, key: str, default: Any = None) -> Any: + """Read an intermediate tag set earlier in the disposal.""" + return self._intermediate_tags.get(key, default) + + def held_payload_count(self) -> int: + """How many payload references the shaliach is currently holding. + Used by tests and by the orchestrator's RECORDED checkpoint + (post-destruction; should be 0 if destruction was complete).""" + return len(self._payload_refs) + + def tevilah(self) -> None: + """Wash before rejoining the camp. + + Vayikra 16:26 — yekhabes b'gadav v'rachatz et b'saro b'mayim + v'acharei khen yavo el ha'machaneh. The handler washes their + garments and bathes their flesh, and ONLY AFTER may they come + into the camp. + + In software: clear ALL non-`_immutable_` attributes. The + handler returns to a fresh state and may be re-activated. + Required after every disposal, before any other code path + in the same process touches a non-azazel function. + + Future contributors: any state attribute you add MUST either + (a) be prefixed `_immutable_` because it is set once at + construction, or (b) be cleared/reset here. The CI test + test_tevilah_clears_all_state catches violations. + """ + # Discover all non-immutable attribute names dynamically. This + # is what makes the CI invariant work: future state additions + # are auto-cleared by name, with no need to enumerate them + # explicitly. + for attr_name in list(vars(self).keys()): + if not attr_name.startswith("_immutable_"): + delattr(self, attr_name) + # Re-establish the canonical empty mutable state. + self._active = False + self._payload_refs = [] + self._intermediate_tags = {} + + +# ============================================================ +# Role-assertion helpers — every Azazel-channel function uses these +# ============================================================ + + +def assert_shaliach_active(handler: IshItti) -> None: + """Assert the supplied handler is an active shaliach. + + Every function in azazel_processor that operates on the disposal + flow (the orchestrator's checkpoints, the destruction step, the + ack fire, the L3 write) MUST call this at function entry. This + is the runtime gate; the type system gate is AzazelStaged + (property 11). + + Refuses if the handler is not an IshItti, or if the IshItti is + not currently active. Either failure is a role-separation + violation: a kohen-shaped or owner-shaped caller is reaching + into shaliach-only territory. + """ + if not isinstance(handler, IshItti): + raise ShaliachError( + f"Function requires an IshItti shaliach; got " + f"{type(handler).__name__}. Disposal-channel functions " + f"are role-restricted (property 4)." + ) + if not handler.is_active(): + raise ShaliachError( + f"IshItti {handler.identifier!r} is not active. " + f"Call activate() before invoking disposal-channel " + f"functions; the shaliach must hold the offering for " + f"the function to act." + ) diff --git a/azazel_processor/test/test_shaliach.py b/azazel_processor/test/test_shaliach.py new file mode 100644 index 00000000..a1c4beed --- /dev/null +++ b/azazel_processor/test/test_shaliach.py @@ -0,0 +1,319 @@ +""" +Tests for shaliach + tevilah — property 4 (role-separated operator) +of azazel_engineering_reading.md §14. + +The disposal handler (IshItti, "designated man") is the ONLY role +authorized to invoke disposal-channel functions. The handler +accumulates state during a disposal (payload references, intermediate +tags) that MUST be cleared by tevilah before the handler can be +re-used or before the same process touches non-azazel code. + +The CI invariant — test_tevilah_clears_all_non_immutable_state — +catches future contributors who add state-bearing attributes to +IshItti without extending tevilah() to clear them. Without that +invariant, the property would silently degrade as the codebase +evolves. +""" + +import pytest + +from azazel_processor.shaliach import ( + IshItti, + ShaliachError, + assert_shaliach_active, +) + + +# ============================================================ +# Construction +# ============================================================ + + +def test_handler_requires_named_identifier(): + h = IshItti("aharon") + assert h.identifier == "aharon" + + +def test_handler_refuses_empty_identifier(): + with pytest.raises(ShaliachError): + IshItti("") + + +def test_handler_refuses_whitespace_identifier(): + with pytest.raises(ShaliachError): + IshItti(" ") + + +def test_handler_refuses_non_string_identifier(): + with pytest.raises(ShaliachError): + IshItti(None) # type: ignore[arg-type] + with pytest.raises(ShaliachError): + IshItti(123) # type: ignore[arg-type] + + +def test_identifier_is_immutable(): + """The identifier is set at construction and propagates onto + AzazelRecords for shaliach attribution. It must not be + re-assignable, and it must survive tevilah.""" + h = IshItti("aharon") + h.activate() + h.tevilah() + assert h.identifier == "aharon" + + +# ============================================================ +# Activation lifecycle +# ============================================================ + + +def test_handler_starts_inactive(): + h = IshItti("aharon") + assert h.is_active() is False + + +def test_activate_marks_handler_active(): + h = IshItti("aharon") + h.activate() + assert h.is_active() is True + + +def test_activate_when_already_active_raises(): + """Re-activation without tevilah would mix two disposals' state. + Refuse — the handler must wash before rejoining (Vayikra 16:26).""" + h = IshItti("aharon") + h.activate() + with pytest.raises(ShaliachError) as exc: + h.activate() + assert "tevilah" in str(exc.value).lower() + + +def test_tevilah_then_reactivate_succeeds(): + """The handler can perform many disposals across its lifetime, + one at a time, with mandatory tevilah between.""" + h = IshItti("aharon") + h.activate() + h.tevilah() + assert h.is_active() is False + h.activate() + assert h.is_active() is True + + +# ============================================================ +# Hold payload + tag — only when active +# ============================================================ + + +def test_hold_payload_requires_active(): + h = IshItti("aharon") + with pytest.raises(ShaliachError): + h.hold_payload({"data": "x"}) + + +def test_hold_payload_tracks_references(): + h = IshItti("aharon") + h.activate() + h.hold_payload({"data": "first"}) + h.hold_payload({"data": "second"}) + assert h.held_payload_count() == 2 + + +def test_tag_requires_active(): + h = IshItti("aharon") + with pytest.raises(ShaliachError): + h.tag("checkpoint", "STAGED") + + +def test_tag_round_trip(): + h = IshItti("aharon") + h.activate() + h.tag("checkpoint", "STAGED") + assert h.get_tag("checkpoint") == "STAGED" + assert h.get_tag("missing") is None + assert h.get_tag("missing", "default") == "default" + + +# ============================================================ +# Tevilah — the load-bearing reset +# ============================================================ + + +def test_tevilah_clears_payload_refs(): + h = IshItti("aharon") + h.activate() + h.hold_payload({"data": "x"}) + assert h.held_payload_count() == 1 + + h.tevilah() + assert h.held_payload_count() == 0 + + +def test_tevilah_clears_intermediate_tags(): + h = IshItti("aharon") + h.activate() + h.tag("checkpoint", "STAGED") + h.tag("vidui_id", "abc") + + h.tevilah() + h.activate() # re-activate to allow get_tag (which doesn't actually require active, but for clarity) + assert h.get_tag("checkpoint") is None + assert h.get_tag("vidui_id") is None + + +def test_tevilah_returns_handler_to_inactive(): + h = IshItti("aharon") + h.activate() + h.tevilah() + assert h.is_active() is False + + +def test_tevilah_preserves_immutable_identifier(): + h = IshItti("aharon") + h.activate() + h.tevilah() + assert h.identifier == "aharon" + + +# ============================================================ +# THE LOAD-BEARING CI INVARIANT +# ============================================================ + + +def test_tevilah_clears_all_non_immutable_state(): + """Property 4's structural defense: ANY attribute on IshItti + that does not start with `_immutable_` MUST be reset to a fresh + value (or removed entirely) by tevilah(). + + A future contributor who adds `self._cache = {}` (or any other + state attribute) to IshItti without extending tevilah() to + clear it breaks the property — and this test fires before the + regression ships. + + The discovery is dynamic: we walk the handler's `vars()` after + pre-tevilah state has been seeded, then verify each non-immutable + attribute is in a 'fresh' state post-tevilah. + """ + h = IshItti("aharon") + h.activate() + + # Seed every state-bearing attribute we know about. + h.hold_payload({"data": "first"}) + h.hold_payload({"data": "second"}) + h.tag("checkpoint", "STAGED") + h.tag("disposal_id", "abc-123") + + # Snapshot the pre-tevilah attribute set. + pre_tevilah = dict(vars(h)) + # Sanity: at least one non-immutable attribute should be non-fresh. + non_immutable = { + k: v for k, v in pre_tevilah.items() if not k.startswith("_immutable_") + } + assert any(_is_non_fresh(v) for v in non_immutable.values()), ( + "Test setup error: no non-fresh state to clear" + ) + + h.tevilah() + + # Post-tevilah: every non-immutable attribute must be 'fresh'. + for attr_name, value in vars(h).items(): + if attr_name.startswith("_immutable_"): + continue + assert _is_fresh(value), ( + f"tevilah() did not reset {attr_name}: value is {value!r}. " + f"If this attribute was added without extending tevilah, " + f"that is the property-4 regression. Either prefix the " + f"name with `_immutable_` (if it is set once at " + f"construction) or extend tevilah() to clear it." + ) + + # All immutable attributes must STILL be present. + for attr_name in pre_tevilah: + if attr_name.startswith("_immutable_"): + assert attr_name in vars(h), ( + f"tevilah() incorrectly removed immutable attribute {attr_name!r}." + ) + + +def _is_fresh(value) -> bool: + """A value is 'fresh' if it is the canonical empty value for its + type — empty container, False, None, 0.""" + if value is None or value is False: + return True + if value == 0: + return True + if isinstance(value, (list, tuple, set, dict, str, bytes)) and len(value) == 0: + return True + return False + + +def _is_non_fresh(value) -> bool: + return not _is_fresh(value) + + +# ============================================================ +# assert_shaliach_active — the role gate +# ============================================================ + + +def test_assert_shaliach_active_passes_for_active_ish_itti(): + h = IshItti("aharon") + h.activate() + # Should not raise + assert_shaliach_active(h) + + +def test_assert_shaliach_active_refuses_inactive_handler(): + h = IshItti("aharon") + with pytest.raises(ShaliachError) as exc: + assert_shaliach_active(h) + assert "not active" in str(exc.value).lower() + + +def test_assert_shaliach_active_refuses_non_ish_itti(): + """A kohen-shaped or owner-shaped value passed where a shaliach + is expected — refuse. Property 4: cross-role invocation is + forbidden.""" + not_a_shaliach = "string-not-an-IshItti" + with pytest.raises(ShaliachError) as exc: + assert_shaliach_active(not_a_shaliach) # type: ignore[arg-type] + assert "ishitti" in str(exc.value).lower() or "shaliach" in str(exc.value).lower() + + +def test_assert_shaliach_active_refuses_object_impersonating_handler(): + """A duck-typed object with the same method names is NOT an + IshItti. The role gate is identity, not interface.""" + + class DuckIshItti: + identifier = "fake" + + def is_active(self): + return True + + with pytest.raises(ShaliachError): + assert_shaliach_active(DuckIshItti()) # type: ignore[arg-type] + + +# ============================================================ +# Stale-state-across-disposals detection +# ============================================================ + + +def test_stale_state_across_disposals_is_prevented(): + """The integration property: state from disposal-N must not + leak into disposal-N+1. tevilah is the gate; this test verifies + it works end-to-end.""" + h = IshItti("aharon") + + # Disposal 1 + h.activate() + h.hold_payload({"id": 1, "data": "first"}) + h.tag("disposal_id", "first") + h.tevilah() + + # Disposal 2 — must start fresh + h.activate() + assert h.held_payload_count() == 0 + assert h.get_tag("disposal_id") is None + h.hold_payload({"id": 2, "data": "second"}) + h.tag("disposal_id", "second") + assert h.held_payload_count() == 1 + assert h.get_tag("disposal_id") == "second"