azazel_processor: commit 3 — shaliach + tevilah (property 4, role-separated operator)
Pass 4 / Finding F, commit 3 of 10 from BUILD_AZAZEL.md.
Vayikra 16:21 — וְשִׁלַּח בְּיַד־אִישׁ עִתִּי הַמִּדְבָּרָה.
And he shall send the goat into the wilderness by the hand of a
designated man (ish itti).
Vayikra 16:26 — וְהַמְשַׁלֵּחַ … יְכַבֵּס בְּגָדָיו וְרָחַץ אֶת־בְּשָׂרוֹ
בַּמַּיִם וְאַחֲרֵי־כֵן יָבוֹא אֶל־הַמַּחֲנֶה.
The sender washes his clothes and bathes his flesh (tevilah), and
AFTERWARD comes into the camp.
Yoma 67a: the ish itti is a dedicated operator role, prepared in
advance, distinct from the kohanim who handle sacred processing.
Contact with the disposal channel produces state that must be
reset before the operator rejoins the trusted boundary.
THE THREE ROLES IN SHORASHIM:
Owner — submits the offering (upstream caller)
Kohen — performs shechitah/zerikah/haktarah (korban_processor
stages.py enforces owner/kohen separation)
Shaliach — receives input the kohen REFUSED and disposes of it
through the Azazel protocol (THIS module)
Without tevilah, payload references would persist across disposals;
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 into the camp.
CREATED:
src/azazel_processor/shaliach.py ~210 lines
IshItti class:
__init__ — sets _immutable_identifier (no other immutable
attrs; everything else must be cleared by tevilah)
activate() — begin a disposal; refuses if already active (no
state-mixing across disposals)
hold_payload() — register payload ref for later destruction
tag()/get_tag()— intermediate metadata during the disposal route
tevilah() — Vayikra 16:26: clears ALL non-_immutable_
attributes (discovered dynamically via vars(),
so future state additions are auto-cleared)
identifier — propagates onto AzazelRecords (commit 7) for
shaliach attribution
ShaliachError — raised on role-separation violations
assert_shaliach_active(handler) — runtime gate every disposal-channel
function calls at entry; the type-system gate is
AzazelStaged (property 11). Refuses non-IshItti
callers AND inactive handlers. Identity-checked
(isinstance), not duck-typed.
THE LOAD-BEARING CI INVARIANT (test_tevilah_clears_all_non_immutable_state):
Walks vars(handler) post-tevilah and asserts every non-_immutable_
attribute is in a 'fresh' state (empty container, False, None, 0).
A future contributor who adds `self._cache = {}` (or any state
attribute) without extending tevilah() to clear it breaks this and
the test fires before the regression ships. The discovery is
DYNAMIC — no hand-maintained list of attributes to clear; tevilah
is implemented to clear all non-_immutable_ attrs and the test
verifies it does.
test/test_shaliach.py 23 tests covering:
- construction (named identifier required, validates non-empty,
non-whitespace, string)
- identifier immutability across activate→tevilah→reactivate
- lifecycle: starts inactive; activate marks active; re-activate
without tevilah raises; tevilah→reactivate succeeds
- hold_payload/tag require active; both work when active
- tevilah clears payload refs, intermediate tags, sets inactive
- tevilah preserves _immutable_identifier
- THE CI INVARIANT (dynamic walk)
- assert_shaliach_active: passes for active IshItti, refuses
inactive, refuses non-IshItti, refuses duck-typed impostor
- integration: stale-state-across-disposals prevented end-to-end
NOTE ON PYTEST INVOCATION:
When running azazel_processor tests, use `--rootdir=.` from the
package directory. Without it, pytest auto-discovers /Users/scottkarp/
shorashim as rootdir and ignores the local pyproject.toml's
testpaths config. Documented here so future runs match.
cd azazel_processor
PYTHONPATH=src:../korban_processor/src python3 -m pytest --rootdir=.
Tests: azazel_processor 71 passed (was 48, +23). 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:
1 parent
a2744c012a
commit
85155b23fb
3 files changed
+558
-4
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user