korban_processor: role-gate the four-stage offering protocol
Pass 2 finding C: in the Mishkan, the four ritual acts of bringing an
offering are performed by TWO different roles —
semichah — OWNER lays hands (Vayikra 1:4, וְסָמַךְ יָדוֹ)
shechitah — kohen receives FROM owner's hand and slaughters (1:5)
zerikah — kohen sprinkles (1:5)
haktarah — kohen burns on altar (1:9)
Before this commit, every stage function was independently callable.
Nothing structural enforced that zerikah() received output from
shechitah(), or that shechitah() received a korban from semichah().
An external model output (e.g. a Claude response) could in principle
have been brought through zerikah() without ever having passed
semichah — the ownership-and-attribution step. That is the exact
shape of "the calf that came out by itself" (Shemot 32:24, Aaron's
reading). The pipeline-glue in external_offering happened to call
them in order, but nothing structural enforced the order.
This commit adds the structural gate.
CHANGES:
stages.py (new): KorbanStage enum + Staged[T] frozen dataclass +
StageError. The Staged envelope carries a ritual-stage marker that
is set at construction and cannot be mutated afterward; downstream
functions demand the right upstream stage and raise StageError if
it is missing.
semichah.py: + semichah_staged() returning Staged[Korban]
shechitah.py: + shechitah_staged(Staged[Korban]) -> Staged[str]
zerikah.py: + zerikah_staged(Staged[str], Staged[Korban])
-> Staged[Tuple[str, List[str]]]
TWO staged inputs: requires SHECHITAH-tagged terminated
content AND the original SEMICHAH-tagged korban for
context. Severing output from source is gilui_arayot
(eish_zarah_reading.md Form 3); the dual-staged
signature is the structural defense — zerikah cannot
process content disconnected from its owner-of-record.
haktarah.py: + haktarah_staged(Staged[Tuple[str, List[str]]])
-> Staged[List[str]]
external_offering.py: rewired to use the *_staged variants. The
pipeline now goes through the gates structurally; misuse cannot
bypass them.
The non-staged functions (semichah, shechitah, zerikah, haktarah)
are PRESERVED for direct testing of individual ritual acts. They do
NOT enforce role gating. Production-shaped callers MUST go through
the staged variants — and external_offering, the only legitimate
entry point for external model consultation per its docstring, now
does.
TESTS:
test/test_stages.py (new, 7 tests):
- happy path: full chain produces HAKTARAH-tagged result
- zerikah refuses input that skipped semichah
- zerikah refuses korban with wrong stage marker (preserves
the line back to the owner — Form 3 defense)
- haktarah refuses input that skipped zerikah (the calf-shape)
- shechitah refuses input not from semichah
- Staged is frozen — no re-tagging
- StageError names BOTH actual and expected stages so the
operator can diagnose call-site vs. tag-site
Full korban_processor suite: 203 passed, 1 skipped (the live API
test that requires SHORASHIM_LIVE_API=1). No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d3869c5b75
commit
5c76a6f42c
7 files changed
+390
-9
No files matched your search
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Tests for the role-gated four-stage offering protocol.
|
||||
|
||||
Pass 2 finding C: in the Mishkan, semichah is OWNER work; everything
|
||||
downstream (shechitah, zerikah, haktarah) is KOHEN work, and each
|
||||
kohen step receives its input from the previous step's hand.
|
||||
|
||||
Before stages.py existed, the Python implementation had no structural
|
||||
enforcement: an external model output could be brought through
|
||||
zerikah() without ever having passed semichah — the
|
||||
ownership-and-attribution step. That is the exact shape of "the calf
|
||||
that came out by itself" (Shemot 32:24 in Aaron's reading).
|
||||
|
||||
These tests verify the gate fires whenever the role/stage boundary is
|
||||
violated. They are NOT testing the content of the four ritual acts;
|
||||
each act has its own dedicated test file. These tests test the PIPING.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from korban_processor.haktarah import haktarah_staged
|
||||
from korban_processor.korban import OfferingType, Provenance
|
||||
from korban_processor.semichah import semichah_staged
|
||||
from korban_processor.shechitah import shechitah_staged
|
||||
from korban_processor.stages import KorbanStage, Staged, StageError
|
||||
from korban_processor.zerikah import zerikah_staged
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Happy path — the full chain runs end-to-end
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_full_chain_completes_with_correct_stages():
|
||||
"""Running all four staged variants in order produces a HAKTARAH-tagged
|
||||
final value with the correct payload type."""
|
||||
s_korban = semichah_staged(
|
||||
content="Bereishit 1:1 begins with the root bara. The shoresh ב-ר-א means to create.",
|
||||
source=Provenance.CLAUDE,
|
||||
query="explain bereishit 1:1",
|
||||
)
|
||||
assert s_korban.stage is KorbanStage.SEMICHAH
|
||||
|
||||
s_terminated = shechitah_staged(s_korban)
|
||||
assert s_terminated.stage is KorbanStage.SHECHITAH
|
||||
|
||||
s_zerikah = zerikah_staged(s_terminated, s_korban)
|
||||
assert s_zerikah.stage is KorbanStage.ZERIKAH
|
||||
|
||||
s_propositions = haktarah_staged(s_zerikah)
|
||||
assert s_propositions.stage is KorbanStage.HAKTARAH
|
||||
assert isinstance(s_propositions.payload, list)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Skip cases — the calf that came out by itself
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_zerikah_refuses_input_that_skipped_semichah():
|
||||
"""If someone constructs a Staged[str] with the wrong stage marker
|
||||
(e.g. directly tagging external content as SHECHITAH-output without
|
||||
actually running shechitah), zerikah_staged refuses it.
|
||||
|
||||
The defense matters less for the wrong-tag case (which requires
|
||||
deliberate construction) than for the missing-stage case below."""
|
||||
fake_terminated = Staged(KorbanStage.HAKTARAH, "some text")
|
||||
fake_korban = Staged(KorbanStage.SEMICHAH, _make_korban())
|
||||
|
||||
with pytest.raises(StageError) as exc:
|
||||
zerikah_staged(fake_terminated, fake_korban)
|
||||
assert "haktarah" in str(exc.value)
|
||||
assert "shechitah" in str(exc.value)
|
||||
|
||||
|
||||
def test_zerikah_refuses_korban_with_wrong_stage_marker():
|
||||
"""zerikah needs BOTH the SHECHITAH-staged terminated content AND
|
||||
the SEMICHAH-staged original korban. If the korban is wrong-tagged,
|
||||
refuse — preserves the line back to the owner of record (eish_zarah
|
||||
Form 3, gilui arayot — severing output from source)."""
|
||||
s_korban = semichah_staged(
|
||||
content="x", source=Provenance.CLAUDE, query="q"
|
||||
)
|
||||
s_terminated = shechitah_staged(s_korban)
|
||||
|
||||
# Re-tag the korban as if it had been processed further. zerikah
|
||||
# should refuse because the original-korban argument MUST bear
|
||||
# SEMICHAH (the owner's act); any other stage means the line back
|
||||
# to the owner has been broken.
|
||||
mistagged_korban = Staged(KorbanStage.HAKTARAH, s_korban.payload)
|
||||
|
||||
with pytest.raises(StageError) as exc:
|
||||
zerikah_staged(s_terminated, mistagged_korban)
|
||||
assert "semichah" in str(exc.value)
|
||||
|
||||
|
||||
def test_haktarah_refuses_input_that_skipped_zerikah():
|
||||
"""The main shape of the calf-that-came-out-by-itself: someone takes
|
||||
raw shechitah output and tries to offer it on the altar without
|
||||
sprinkling. haktarah_staged refuses."""
|
||||
s_korban = semichah_staged(
|
||||
content="content", source=Provenance.CLAUDE, query="q"
|
||||
)
|
||||
s_terminated = shechitah_staged(s_korban)
|
||||
|
||||
# Construct a Staged[Tuple[str, List[str]]] with the wrong stage tag
|
||||
fake_zerikah = Staged(KorbanStage.SHECHITAH, ("vital", []))
|
||||
|
||||
with pytest.raises(StageError) as exc:
|
||||
haktarah_staged(fake_zerikah)
|
||||
assert "zerikah" in str(exc.value)
|
||||
|
||||
|
||||
def test_shechitah_refuses_input_not_from_semichah():
|
||||
"""The kohen takes from the owner's hand. If a payload arrives
|
||||
without bearing SEMICHAH, shechitah refuses."""
|
||||
fake = Staged(KorbanStage.ZERIKAH, _make_korban())
|
||||
|
||||
with pytest.raises(StageError) as exc:
|
||||
shechitah_staged(fake)
|
||||
assert "semichah" in str(exc.value)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stage immutability
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_staged_is_frozen():
|
||||
"""Stage markers cannot be re-labeled after construction. There is
|
||||
no path to taking a HAKTARAH-tagged value and re-tagging it as
|
||||
SEMICHAH to bypass the gate."""
|
||||
s = semichah_staged(content="x", source=Provenance.CLAUDE, query="q")
|
||||
with pytest.raises((AttributeError, Exception)):
|
||||
s.stage = KorbanStage.HAKTARAH # type: ignore[misc]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Error message names BOTH stages
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_stage_error_names_both_actual_and_expected_stages():
|
||||
"""The error message must name BOTH the stage on the offending
|
||||
payload AND the stage the failing function expected. Otherwise the
|
||||
operator cannot tell from the call site alone whether the payload
|
||||
was wrong-tagged or the wrong function was called."""
|
||||
bad = Staged(KorbanStage.HAKTARAH, "anything")
|
||||
with pytest.raises(StageError) as exc:
|
||||
bad.expect_stage(KorbanStage.SEMICHAH)
|
||||
msg = str(exc.value)
|
||||
assert "haktarah" in msg
|
||||
assert "semichah" in msg
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _make_korban():
|
||||
"""Tests need a Korban-shaped object for skip-case construction."""
|
||||
from korban_processor.semichah import semichah
|
||||
|
||||
return semichah(
|
||||
content="placeholder", source=Provenance.CLAUDE, query="placeholder"
|
||||
)
|
||||
Reference in new issue
Block a user