diff --git a/azazel_processor/src/azazel_processor/__init__.py b/azazel_processor/src/azazel_processor/__init__.py index 45fceded..6d5871b7 100644 --- a/azazel_processor/src/azazel_processor/__init__.py +++ b/azazel_processor/src/azazel_processor/__init__.py @@ -6,23 +6,24 @@ 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 1 — Skeleton + stages. 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 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. -This commit covers: - - Property 11 (classification discipline) — AzazelStage and - AzazelStaged are non-interconvertible with korban_processor. - Staged. - - Property 12 (unmanaged destination) — ERETZ_GEZERAH sentinel - is the only legitimate way to refer to the disposal destination. +Properties defended so far: + - Property 3 (enumerative commit) — Vidui + confess() + - Property 11 (classification discipline) — AzazelStage* + - Property 12 (unmanaged destination) — ERETZ_GEZERAH -Public surface so far: - AzazelStage, AzazelStaged, AzazelStageError — stage-gating types - ERETZ_GEZERAH, is_eretz_gezerah — the destination +Public surface: + AzazelStage, AzazelStaged, AzazelStageError — stage gating + ERETZ_GEZERAH, is_eretz_gezerah — the destination + Vidui, confess, EmptyConfessionError — enumerative commit + confess_empty_vital, confess_halt, + confess_boundary_violation, + confess_malformed_input — categorization helpers Pending in subsequent commits: - vidui (commit 2 — enumerative commit) shaliach + tevilah (commit 3 — role-separated operator) goral (commit 4 — blind routing) azazel orchestrator (commit 5 — full state machine) @@ -31,6 +32,15 @@ Pending in subsequent commits: from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah from azazel_processor.stages import AzazelStage, AzazelStaged, AzazelStageError +from azazel_processor.vidui import ( + EmptyConfessionError, + Vidui, + confess, + confess_boundary_violation, + confess_empty_vital, + confess_halt, + confess_malformed_input, +) __all__ = [ "ERETZ_GEZERAH", @@ -38,4 +48,11 @@ __all__ = [ "AzazelStage", "AzazelStaged", "AzazelStageError", + "Vidui", + "EmptyConfessionError", + "confess", + "confess_empty_vital", + "confess_halt", + "confess_boundary_violation", + "confess_malformed_input", ] diff --git a/azazel_processor/src/azazel_processor/vidui.py b/azazel_processor/src/azazel_processor/vidui.py new file mode 100644 index 00000000..81895d3c --- /dev/null +++ b/azazel_processor/src/azazel_processor/vidui.py @@ -0,0 +1,271 @@ +""" +Vidui — the enumerative commit (property 3 of azazel_engineering_reading.md §14). + +Vayikra 16:21 — וְהִתְוַדָּה עָלָיו אֶת־כָּל־עֲוֺנֹת בְּנֵי יִשְׂרָאֵל +וְאֶת־כָּל־פִּשְׁעֵיהֶם לְכָל־חַטֹּאתָם. +And he shall confess upon it ALL the iniquities (avonot) of the children +of Israel, and ALL their transgressions (peshaim), for ALL their sins +(chatot). + +The verb v'hitvadah is hitpa'el — REFLEXIVE. The confession is +self-addressed before it is addressed to the system. The high priest +must STATE the errors to himself in the act of transferring them. You +discover what you are disposing of in the act of disposing of it. + +The word kol appears three times: kol avonot, kol pish'eihem, l'chol +chatotam. The enumeration is comprehensive by specification. All three +categories. All items in each category named. + +ENGINEERING READ: + +The commit message for a disposal must enumerate what is being disposed +of. Untagged disposal is not disposal; it is loss. Azazel does not +accept uncommitted writes. The enumeration is produced at the moment +of commit, not retrieved from a pre-existing log. + +WHY THE VIDUI IS OPERATOR-PRODUCED, NOT AUTO-DERIVED: + +The Mishkan high priest speaks the confession aloud. The system did +not generate it for him from a classification enum. In software terms: +the caller of confess() MUST supply the descriptions, not have them +auto-generated. If a classification → string table generated the +confession, the operator could dispose state without ever naming it +themselves; the confession would be an autogenerated label, not a +self-addressed enumeration. That's the failure mode property 3 +defends against. + +The convenience helpers (confess_empty_vital, confess_halt, etc.) +DO NOT hide this requirement. Each helper still requires the operator +to supply the specific item (the korban summary, the halt detail, +the boundary description). The helper just routes which category it +goes into; the operator still names the item. + +THE THREE CATEGORIES: + + avonot — iniquities. CATEGORICAL errors: halt-trips + (avodah_zarah, gilui_arayot, shfichut_damim — the + cardinal sins of the system that triggered yehareg + v'al ya'avor). Sins of orientation. + + peshaim — transgressions. BOUNDARY violations: distinction + collapses (gilui-shaped — read/write conflation, + ketiv/qere collapse, specification/interpretation + merge), L3 schema rejections (the trace was not + L1-grounded). Sins of boundary-keeping. + + chatot — sins. TARGET errors: things offered but unprocessable + (empty-vital korban, malformed external responses, + content that arrived but had no extractable Torah). + Sins of target-missing (the literal meaning of chet — + missing the mark). + +Most disposals fill exactly ONE category. The Vidui dataclass +REQUIRES all three fields, but the lists may be empty. What is +forbidden is the absence of the slot, not the absence of items in +the slot. An untagged disposal — confess() called with all three +slots empty — raises EmptyConfessionError; you cannot dispose +without naming. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Iterable + + +class EmptyConfessionError(ValueError): + """Raised when confess() is called with all three slots empty. + + An untagged disposal — the failure mode property 3 defends + against. There must be SOMETHING in at least one of avonot, + peshaim, or chatot. Empty in all three would mean disposal + without naming, which is loss disguised as disposal. + """ + + +@dataclass(frozen=True) +class Vidui: + """The structured enumerative commit. + + All four fields are required. avonot/peshaim/chatot are tuples + (immutable) so the dataclass-frozen guarantee extends through + the contents — no path to mutating the confession after the + moment of confession. + + confessed_at and confessor are non-optional: anonymous or + untimed confession would defeat the operator-attribution + requirement (property 7's `authorized_by` field on the + AzazelRecord traces back to confessor). + """ + + avonot: tuple[str, ...] + peshaim: tuple[str, ...] + chatot: tuple[str, ...] + confessor: str + confessed_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc) + ) + + def __post_init__(self): + # All three slots present (the dataclass enforces this) AND + # at least one is non-empty (property 3: no untagged + # disposal). The dataclass cannot enforce the "at least one + # non-empty" constraint at the type level; we enforce it + # here so misuse fires at construction, not at L3 write. + if not (self.avonot or self.peshaim or self.chatot): + raise EmptyConfessionError( + "Cannot confess with all three categories empty. " + "Property 3 (enumerative commit) requires that " + "SOMETHING be named — disposal without naming is " + "loss, not disposal. Vayikra 16:21 says kol three " + "times; at minimum, one of the kol's must be filled." + ) + # All entries non-empty strings — empty descriptions would + # silently weaken the enumeration without raising. + for category, items in ( + ("avonot", self.avonot), + ("peshaim", self.peshaim), + ("chatot", self.chatot), + ): + if any((not isinstance(s, str)) or (not s.strip()) for s in items): + raise EmptyConfessionError( + f"Confession {category} contains an empty or " + f"non-string entry: {items!r}. Each item must be a " + f"named description; empty strings would degrade the " + f"enumeration to a count." + ) + if not isinstance(self.confessor, str) or not self.confessor.strip(): + raise EmptyConfessionError( + "Vidui requires a named confessor (operator/kohen " + "identifier). Anonymous confession defeats property 7 " + "(authorized routing) — the routing decision must be " + "ownable back to the confessor." + ) + + def to_text(self) -> str: + """Render the vidui as readable confession text — the + Vayikra 16:21 structure preserved. + + Each non-empty category is rendered as a kol-prefixed clause. + The output is what gets persisted in L3 azazel_records.vidui + and what the public observability endpoint summarizes (in + categorical form only — the public endpoint shows which + categories were filled, not the items themselves). + """ + parts = [] + if self.avonot: + parts.append("kol avonot: " + "; ".join(self.avonot)) + if self.peshaim: + parts.append("kol pish'eihem: " + "; ".join(self.peshaim)) + if self.chatot: + parts.append("l'kol chatotam: " + "; ".join(self.chatot)) + return " | ".join(parts) + + def category_summary(self) -> dict[str, int]: + """Return a {category → item count} map. The shape exposed by + the public observability endpoint (commit 8) — counts only, + no item content. Property 9 (observable disposal) at the + public boundary.""" + return { + "avonot": len(self.avonot), + "peshaim": len(self.peshaim), + "chatot": len(self.chatot), + } + + +def confess( + *, + avonot: Iterable[str] = (), + peshaim: Iterable[str] = (), + chatot: Iterable[str] = (), + confessor: str, +) -> Vidui: + """Produce a vidui — the operator-produced enumerative commit. + + The caller MUST supply the items in each category they are + confessing. There is no auto-derivation from a classification + table; the act of naming is the operator's, not the system's. + + All three category arguments default to empty tuples for + convenience (most disposals fill exactly one), but at least + one must be non-empty — see EmptyConfessionError. + + confessor is required keyword-only — there is no default. + Anonymous confession is not a confession; the kohen-gadol + identity is what makes the routing decision authorizable + (property 7). + """ + return Vidui( + avonot=tuple(avonot), + peshaim=tuple(peshaim), + chatot=tuple(chatot), + confessor=confessor, + ) + + +# ============================================================ +# Convenience helpers — categorize at the call site +# ============================================================ +# +# Each helper still REQUIRES the operator to supply the specific +# item being confessed. The helper only routes which category the +# item goes into; it does not auto-generate the description. +# Property 3 stays defended. + + +def confess_empty_vital( + korban_summary: str, *, confessor: str +) -> Vidui: + """Confess a chatot-class disposal: an offering whose zerikah + returned no vital content. The operator must supply a summary + naming what was offered; the helper routes it into chatot + (target-missing). The literal meaning of chet — missing the mark + — is exactly the empty-vital case: the system fired and + nothing landed.""" + return confess( + chatot=(f"empty-vital offering: {korban_summary}",), + confessor=confessor, + ) + + +def confess_halt( + condition: str, detail: str, *, confessor: str +) -> Vidui: + """Confess an avonot-class disposal: content that triggered a + halt rule (avodah_zarah, gilui_arayot, shfichut_damim — the + yehareg-v'al-ya'avor sins). + + The condition is the halt name; the detail is the operator's + description of what specifically tripped the halt. The vidui + preserves both so the L3 record names the orientation-failure + that occurred.""" + return confess( + avonot=(f"halt {condition}: {detail}",), + confessor=confessor, + ) + + +def confess_boundary_violation( + violation: str, *, confessor: str +) -> Vidui: + """Confess a peshaim-class disposal: content that collapsed a + distinction the system requires kept (read/write, ketiv/qere, + specification/interpretation, etc.). The operator names the + specific collapse.""" + return confess( + peshaim=(f"boundary collapse: {violation}",), + confessor=confessor, + ) + + +def confess_malformed_input( + description: str, *, confessor: str +) -> Vidui: + """Confess a chatot-class disposal: an input that arrived at the + intake but could not be parsed or processed at all (malformed + JSON, truncated response, encoding failure). Different from + empty-vital — empty-vital is content with no Torah; malformed is + content with no SHAPE.""" + return confess( + chatot=(f"malformed input: {description}",), + confessor=confessor, + ) diff --git a/azazel_processor/test/test_vidui.py b/azazel_processor/test/test_vidui.py new file mode 100644 index 00000000..7e0c9fc0 --- /dev/null +++ b/azazel_processor/test/test_vidui.py @@ -0,0 +1,283 @@ +""" +Tests for vidui — property 3 (enumerative commit) of +azazel_engineering_reading.md §14. + +The vidui must: + - Have all THREE category slots present (avonot/peshaim/chatot), + even when individual slots are empty + - Refuse construction when ALL slots are empty (no untagged + disposal — the failure mode property 3 defends against) + - Be immutable after construction (the moment of confession + is the moment of fixing) + - Carry an attributable confessor (anonymous confession defeats + property 7's authorized-routing requirement) + - Render to readable text preserving the kol-three-times structure + - Expose category counts WITHOUT the items themselves (for the + public observability endpoint, commit 8 — property 9) +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from azazel_processor.vidui import ( + EmptyConfessionError, + Vidui, + confess, + confess_boundary_violation, + confess_empty_vital, + confess_halt, + confess_malformed_input, +) + + +# ============================================================ +# The three-fold structure +# ============================================================ + + +def test_vidui_has_exactly_the_five_expected_fields(): + """Structural test: the dataclass has exactly avonot, peshaim, + chatot, confessor, confessed_at — no silent additions and no + missing fields. A future contributor who adds a sixth field + (or removes one) breaks this and forces an explicit decision.""" + fields = {f.name for f in Vidui.__dataclass_fields__.values()} + assert fields == {"avonot", "peshaim", "chatot", "confessor", "confessed_at"} + + +def test_confess_with_avonot_only_succeeds(): + v = confess(avonot=("halt avodah_zarah",), confessor="kohen_gadol") + assert v.avonot == ("halt avodah_zarah",) + assert v.peshaim == () + assert v.chatot == () + + +def test_confess_with_peshaim_only_succeeds(): + v = confess(peshaim=("ketiv/qere collapse",), confessor="kohen_gadol") + assert v.peshaim == ("ketiv/qere collapse",) + + +def test_confess_with_chatot_only_succeeds(): + v = confess(chatot=("empty-vital korban",), confessor="kohen_gadol") + assert v.chatot == ("empty-vital korban",) + + +def test_confess_with_all_three_categories_filled_succeeds(): + v = confess( + avonot=("halt 1",), + peshaim=("collapse 1",), + chatot=("missing 1", "missing 2"), + confessor="kohen_gadol", + ) + assert v.avonot == ("halt 1",) + assert v.peshaim == ("collapse 1",) + assert v.chatot == ("missing 1", "missing 2") + + +# ============================================================ +# Untagged disposal is forbidden +# ============================================================ + + +def test_confess_with_all_slots_empty_raises(): + """Property 3: no untagged disposal. The act of naming what is + being disposed is load-bearing. confess() with all three slots + empty is the system trying to dispose without naming — refuse.""" + with pytest.raises(EmptyConfessionError) as exc: + confess(confessor="kohen_gadol") + assert "without naming" in str(exc.value).lower() or "loss" in str(exc.value).lower() + + +def test_confess_with_explicit_empty_tuples_in_all_slots_raises(): + with pytest.raises(EmptyConfessionError): + confess(avonot=(), peshaim=(), chatot=(), confessor="kohen_gadol") + + +def test_vidui_construction_with_empty_string_item_raises(): + """An empty string in a category list would degrade the enumeration + to a count — refuse at construction.""" + with pytest.raises(EmptyConfessionError): + confess(avonot=("",), confessor="kohen_gadol") + + +def test_vidui_construction_with_whitespace_only_item_raises(): + with pytest.raises(EmptyConfessionError): + confess(chatot=(" ",), confessor="kohen_gadol") + + +def test_vidui_construction_with_non_string_item_raises(): + with pytest.raises(EmptyConfessionError): + confess(avonot=(None,), confessor="kohen_gadol") # type: ignore[arg-type] + + +# ============================================================ +# Confessor attribution +# ============================================================ + + +def test_confessor_required(): + """Anonymous confession defeats property 7 (authorized routing). + confess() must take a confessor (keyword-only) — no default.""" + with pytest.raises(TypeError): + # No confessor argument + confess(avonot=("x",)) # type: ignore[call-arg] + + +def test_confessor_must_be_non_empty_string(): + with pytest.raises(EmptyConfessionError): + confess(avonot=("x",), confessor="") + + +def test_confessor_must_be_non_whitespace(): + with pytest.raises(EmptyConfessionError): + confess(avonot=("x",), confessor=" ") + + +def test_confessor_recorded_on_vidui(): + v = confess(avonot=("x",), confessor="aharon_ha_kohen") + assert v.confessor == "aharon_ha_kohen" + + +# ============================================================ +# Immutability +# ============================================================ + + +def test_vidui_is_frozen(): + """The moment of confession is the moment of fixing. No path to + mutating the vidui after construction — that would let an + operator re-write what they confessed.""" + v = confess(avonot=("x",), confessor="kohen_gadol") + with pytest.raises((AttributeError, Exception)): + v.confessor = "someone_else" # type: ignore[misc] + + +def test_vidui_categories_are_tuples_not_lists(): + """Tuples are immutable; lists are not. Even though the dataclass + is frozen, a list-valued field could be mutated in place + (frozen=True doesn't propagate through container types). Tuples + are the right shape for the enumeration.""" + v = confess(avonot=("x",), peshaim=("y",), chatot=("z",), confessor="x") + assert isinstance(v.avonot, tuple) + assert isinstance(v.peshaim, tuple) + assert isinstance(v.chatot, tuple) + + +def test_iterable_input_normalized_to_tuple(): + """confess() should accept any iterable for category lists and + normalize to tuple at construction. A list passed in cannot be + held as a list-valued field — that would re-introduce the + mutability gap.""" + v = confess(avonot=["x", "y"], confessor="kohen_gadol") + assert isinstance(v.avonot, tuple) + assert v.avonot == ("x", "y") + + +# ============================================================ +# Timestamp +# ============================================================ + + +def test_confessed_at_set_to_current_utc_time(): + before = datetime.now(timezone.utc) + v = confess(avonot=("x",), confessor="kohen_gadol") + after = datetime.now(timezone.utc) + assert before - timedelta(seconds=1) <= v.confessed_at <= after + timedelta(seconds=1) + + +def test_confessed_at_is_timezone_aware_utc(): + v = confess(avonot=("x",), confessor="kohen_gadol") + assert v.confessed_at.tzinfo is not None + assert v.confessed_at.utcoffset() == timedelta(0) + + +# ============================================================ +# Rendering +# ============================================================ + + +def test_to_text_preserves_kol_structure_for_all_three(): + v = confess( + avonot=("a1", "a2"), + peshaim=("p1",), + chatot=("c1",), + confessor="kohen", + ) + text = v.to_text() + assert "kol avonot" in text + assert "kol pish'eihem" in text + assert "l'kol chatotam" in text + assert "a1" in text and "a2" in text + assert "p1" in text + assert "c1" in text + + +def test_to_text_omits_empty_categories(): + v = confess(avonot=("only-avonot",), confessor="kohen") + text = v.to_text() + assert "kol avonot" in text + assert "kol pish'eihem" not in text + assert "l'kol chatotam" not in text + + +def test_category_summary_returns_counts_without_items(): + """The shape exposed by the public observability endpoint — + counts only, no item content. Property 9 at the public boundary.""" + v = confess( + avonot=("a1", "a2"), + peshaim=("p1",), + chatot=(), + confessor="kohen", + ) + summary = v.category_summary() + assert summary == {"avonot": 2, "peshaim": 1, "chatot": 0} + # Items themselves are NOT in the summary — only counts. + assert "a1" not in str(summary) + assert "p1" not in str(summary) + + +# ============================================================ +# Convenience helpers +# ============================================================ + + +def test_confess_empty_vital_routes_to_chatot(): + """The literal meaning of chet — missing the mark — is exactly + the empty-vital case: the system fired and nothing landed.""" + v = confess_empty_vital("Claude response on bereishit 1:1", confessor="x") + assert v.chatot + assert v.avonot == () + assert v.peshaim == () + assert "empty-vital" in v.chatot[0] + + +def test_confess_halt_routes_to_avonot(): + """Halt-trips are categorical orientation failures — + yehareg-v'al-ya'avor cardinal sins.""" + v = confess_halt("avodah_zarah", "claude_model treated as authority", confessor="x") + assert v.avonot + assert "avodah_zarah" in v.avonot[0] + assert "claude_model" in v.avonot[0] + + +def test_confess_boundary_violation_routes_to_peshaim(): + """Boundary-keeping failures (gilui-shaped) — distinction + collapses.""" + v = confess_boundary_violation("ketiv/qere collapsed", confessor="x") + assert v.peshaim + assert "boundary collapse" in v.peshaim[0] + + +def test_confess_malformed_input_routes_to_chatot(): + """Different from empty-vital — empty-vital is content with no + Torah; malformed is content with no SHAPE.""" + v = confess_malformed_input("truncated JSON, missing closing brace", confessor="x") + assert v.chatot + assert "malformed" in v.chatot[0] + + +def test_helpers_still_require_confessor(): + """The convenience helpers do not weaken property-3's + operator-attribution requirement.""" + with pytest.raises(TypeError): + confess_empty_vital("x") # type: ignore[call-arg]