diff --git a/azazel_processor/pyproject.toml b/azazel_processor/pyproject.toml new file mode 100644 index 00000000..3d18470a --- /dev/null +++ b/azazel_processor/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "azazel-processor" +version = "0.1.0" +description = "Azazel Processor — disposal channel parallel to korban_processor (the two-goat architecture, Vayikra 16:8)" +authors = [ + {name = "Bezalel"} +] +requires-python = "==3.12.2" +dependencies = [ + "pydantic==2.6.1", +] + +[dependency-groups] +dev = [ + "pytest>=8.0,<9.0", +] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +testpaths = ["test"] +pythonpath = ["src", "../korban_processor/src"] diff --git a/azazel_processor/src/azazel_processor/__init__.py b/azazel_processor/src/azazel_processor/__init__.py new file mode 100644 index 00000000..45fceded --- /dev/null +++ b/azazel_processor/src/azazel_processor/__init__.py @@ -0,0 +1,41 @@ +""" +azazel_processor — the disposal channel parallel to korban_processor. + +Vayikra 16 — the two-goat architecture (shavim b'mareh uv'komah +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. + +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. + +Public surface so far: + AzazelStage, AzazelStaged, AzazelStageError — stage-gating types + ERETZ_GEZERAH, is_eretz_gezerah — the destination + +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) + protocol envelope (commit 6 — protocol-restricted access) +""" + +from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah +from azazel_processor.stages import AzazelStage, AzazelStaged, AzazelStageError + +__all__ = [ + "ERETZ_GEZERAH", + "is_eretz_gezerah", + "AzazelStage", + "AzazelStaged", + "AzazelStageError", +] diff --git a/azazel_processor/src/azazel_processor/eretz_gezerah.py b/azazel_processor/src/azazel_processor/eretz_gezerah.py new file mode 100644 index 00000000..4c97963d --- /dev/null +++ b/azazel_processor/src/azazel_processor/eretz_gezerah.py @@ -0,0 +1,93 @@ +""" +Eretz Gezerah — the unmanaged destination. + +Vayikra 16:22 — וְשִׁלַּח אֶת־הַשָּׂעִיר בַּמִּדְבָּר … אֶל־אֶרֶץ גְּזֵרָה. +And he shall send the goat into the wilderness … to a cut-off land. + +The word `gezerah` is the same word Torah uses for binding judgment +(`gezerah shavah`, the second of R. Yishmael's 13 middot, is the +hermeneutic-decree principle). The destination is *cut off* — severed, +decreed, not-coming-back. + +This module defines the only legitimate way the codebase refers to the +disposal destination: a Python sentinel object with no attributes, no +identifier, no name that encodes anything about where state actually +goes after disposal. The opacity is the specification (property 13 of +azazel_engineering_reading.md). + +WHY A SENTINEL AND NOT A STRING: + +If the destination had a string identifier — "void", "/dev/null", +"oblivion", a URL, a queue name, a hostname — that string would become +a handle. Operators would reach for it, verify arrival with it, build +relationships with it, optimize against it. That is exactly what the +Mishkan two-goat architecture is designed to prevent: coupling between +the system and its disposal sink. A coupled sink is a managed sink; a +managed sink ceases to be a sink and becomes a queue. The disposal +channel that quietly accumulates state is the architectural failure +mode. + +A sentinel `object()` has no attributes, no `__repr__` that leaks +location info, no string conversion that helps an operator track it. +You can compare to it with `is` (identity check); you cannot do +anything else with it. That is the maximal opacity Python permits. + +WHY NOT A STRING-CONSTANT WITH AN OPAQUE NAME: + +A string is still tracked by string identity across the codebase. Two +files importing the same string constant share that string in the +intern table; tooling can grep for it; logs can record it. A sentinel +is created once at import time and identified only by its memory +address — which changes per process, cannot be serialized meaningfully, +and resists tooling. + +The CI invariant that defends property 12: the codebase MUST NOT +contain any string identifier, URL pattern, queue name, or hostname +representing the destination. The only path to "the destination" is +`from azazel_processor.eretz_gezerah import ERETZ_GEZERAH`. A future +contributor who tries to add `DESTINATION_URL = "..."` to any module +in `azazel_processor/` is reintroducing the failure mode the sentinel +was created to prevent. + +USE: + + from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah + + record.routing = ERETZ_GEZERAH + assert is_eretz_gezerah(record.routing) + +Comparison MUST use identity (`is`), not equality (`==`). Identity +guarantees you are pointing at the canonical sentinel; equality could +be satisfied by any object with the right `__eq__`, defeating the +opacity guarantee. +""" + +# The sentinel itself. A plain object() — no class, no attributes, +# no identifier. Created once at module import time. +ERETZ_GEZERAH = object() + + +def is_eretz_gezerah(value) -> bool: + """Identity check against the canonical sentinel. + + Always uses `is`, never `==`. The identity check is what + guarantees opacity — equality would let any object impersonate + the destination, defeating property 12. + """ + return value is ERETZ_GEZERAH + + +def __getattr__(name): + """Refuse attribute access to anything other than the two + public names. The sentinel has no further structure; module + introspection should not be able to discover any. + """ + if name in {"ERETZ_GEZERAH", "is_eretz_gezerah"}: + return globals()[name] + + raise AttributeError( + f"eretz_gezerah has no attribute {name!r}. The destination " + f"is opaque by specification — there are no further handles " + f"to discover. (Vayikra 16:22, property 12 of " + f"azazel_engineering_reading.md §14.)" + ) diff --git a/azazel_processor/src/azazel_processor/stages.py b/azazel_processor/src/azazel_processor/stages.py new file mode 100644 index 00000000..8babe014 --- /dev/null +++ b/azazel_processor/src/azazel_processor/stages.py @@ -0,0 +1,117 @@ +""" +Azazel stage gating — role-attribution for the disposal protocol. + +Parallel to korban_processor/stages.py for the processing protocol, +but intentionally a DIFFERENT type. Property 11 of +azazel_engineering_reading.md §14: processing and disposal state are +not interconvertible. The type system is what enforces this — a +`Staged[KorbanStage.X]` cannot be passed to an Azazel function and an +`AzazelStaged[AzazelStage.X]` cannot be passed to a Korban function. +The classification discipline lives in the type system, not in +runtime checks the operator could disable. + +THE SIX CHECKPOINTS (from BUILD_AZAZEL.md commit 5): + + STAGED — input received, classification known, disposal protocol + entered (the goat-pair has been provisioned and the + lot has fallen on this one for la'Azazel) + CONFESSED — vidui constructed; what is being disposed has been + named (Vayikra 16:21, v'hitvadah alav) + ROUTED — destination decision committed (always ERETZ_GEZERAH); + routing record attached + DESTROYED — payload reference cleared from memory; the disposed + thing no longer exists (eretz gezerah, no return) + ACKNOWLEDGED — out-of-band acknowledgment fired (chatzotzrot + :moadim signal sent); the public fact of disposal + is now visible (property 9, Rambam's + observability requirement) + RECORDED — L3 azazel_record persisted; the disposal is now + part of permanent oral tradition + +Each checkpoint is a recoverable point — if the orchestrator halts +mid-route, restart resumes from the last checkpoint reached. + +WHY NOT REUSE korban_processor.stages.Staged? + +A reused type would let `Staged[KorbanStage.SHECHITAH]` be passed +into an Azazel function unchecked. The compression would re-introduce +the calf-shape: state classified for processing being silently +re-routed into disposal, or vice versa. The two protocols MUST be +expressible as non-interconvertible types. Future contributors who +want to "deduplicate" the two staging modules are deleting the +defense; the duplication is load-bearing. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Generic, TypeVar + + +class AzazelStage(Enum): + """The checkpoints of the disposal protocol. + + NOTE: this enum is DELIBERATELY distinct from korban_processor. + KorbanStage. They are not interchangeable; a function that accepts + one cannot accept the other. The type system enforces classification + discipline (property 11). + """ + + STAGED = "staged" + CONFESSED = "confessed" + ROUTED = "routed" + DESTROYED = "destroyed" + ACKNOWLEDGED = "acknowledged" + RECORDED = "recorded" + + +T = TypeVar("T") + + +class AzazelStageError(RuntimeError): + """Raised when an Azazel checkpoint is invoked with input from the + wrong upstream checkpoint, or when a value bearing a Korban-side + stage marker is passed to an Azazel function. + + The Mishkan analog: the shaliach received the goat from the wrong + place — not from the kohen at the prior checkpoint, but from + somewhere else entirely. The error names BOTH the marker actually + present and the marker the function expected so the operator can + diagnose call-site vs. tag-site. + """ + + +@dataclass(frozen=True) +class AzazelStaged(Generic[T]): + """A payload tagged with the disposal-protocol checkpoint that + produced it. + + The marker is set at construction and cannot be mutated afterward + (`frozen=True`). There is no path to relabeling a payload as + belonging to a checkpoint it did not actually pass through. + + DISTINCT TYPE FROM korban_processor.stages.Staged. The two are + structurally identical Python dataclasses, but the type system + treats them as unrelated — by intent, not by accident. See module + docstring. + """ + + stage: AzazelStage + payload: T + + def expect_stage(self, expected: AzazelStage) -> T: + """Return the payload iff the stage marker matches `expected`. + + Raises AzazelStageError otherwise. The error message names + BOTH stages so the operator can see exactly which transition + was violated. + """ + if self.stage != expected: + raise AzazelStageError( + f"Azazel stage mismatch: payload bears stage " + f"{self.stage.value!r} but the next disposal-protocol " + f"checkpoint requires stage {expected.value!r}. The " + f"shaliach receives the goat from the prior " + f"checkpoint's hand; an offering that skipped a " + f"checkpoint cannot be received." + ) + return self.payload diff --git a/azazel_processor/test/__init__.py b/azazel_processor/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/azazel_processor/test/test_eretz_gezerah.py b/azazel_processor/test/test_eretz_gezerah.py new file mode 100644 index 00000000..21b90e00 --- /dev/null +++ b/azazel_processor/test/test_eretz_gezerah.py @@ -0,0 +1,209 @@ +""" +Tests for the eretz_gezerah sentinel — property 12 (unmanaged +destination opacity) from azazel_engineering_reading.md §14. + +The destination MUST be opaque. There is no name, no URL, no queue +identifier, no string handle that encodes anything about where state +goes after disposal. The only legitimate reference is the sentinel +ERETZ_GEZERAH; identity comparison (`is`) is the only legitimate +predicate. +""" + +import pathlib + +import pytest + +from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah + + +# ============================================================ +# The sentinel itself +# ============================================================ + + +def test_sentinel_exists(): + """ERETZ_GEZERAH is importable and is the same object across imports.""" + from azazel_processor.eretz_gezerah import ERETZ_GEZERAH as second_import + + assert ERETZ_GEZERAH is second_import + + +def test_sentinel_is_a_plain_object_not_a_string(): + """A string would be searchable, interned, serializable. The sentinel + must NOT be a string. Property 12: opacity of name.""" + assert not isinstance(ERETZ_GEZERAH, str) + assert not isinstance(ERETZ_GEZERAH, bytes) + + +def test_sentinel_has_no_meaningful_attributes(): + """A bare object() has no useful attributes for tooling to + discover. We don't grant the sentinel a name, a destination + string, or any other tracking handle.""" + # Plain object() exposes only the dunder methods. No public attrs. + public = [a for a in dir(ERETZ_GEZERAH) if not a.startswith("_")] + assert public == [] + + +def test_sentinel_repr_does_not_leak_destination_info(): + """The repr is just object()'s default — no operator-readable name + encoding 'where it goes'. Default Python repr is `` which leaks only the address (changes per process).""" + r = repr(ERETZ_GEZERAH) + assert "