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:
Scott KarpandClaude Opus 4.7 committed 2026-04-26 12:19:55 -04:00
1 parent d3869c5b75
commit 5c76a6f42c
7 files changed
+390 -9

No files matched your search

+167
View File
@@ -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"
)