azazel_processor: commit 1 — skeleton + stages (properties 11 & 12)

Pass 4 / Finding F, commit 1 of 10 from BUILD_AZAZEL.md. Lays the
load-bearing foundation: classification discipline (property 11) at
the type system level, and unmanaged-destination opacity (property
12) via a Python sentinel. Every later commit in the Azazel build
inherits these defenses for free.

CREATED:

  azazel_processor/
    pyproject.toml                      — Poetry config matching
                                          korban_processor; pythonpath
                                          includes ../korban_processor/src
                                          so the cross-package
                                          classification-discipline test
                                          can import both packages
    src/azazel_processor/
      __init__.py                       — public surface for commit 1:
                                          AzazelStage, AzazelStaged,
                                          AzazelStageError, ERETZ_GEZERAH,
                                          is_eretz_gezerah
      eretz_gezerah.py                  — the destination sentinel; module
                                          __getattr__ refuses any
                                          attribute beyond the two public
                                          names so introspection cannot
                                          discover further handles
      stages.py                          — AzazelStage enum (six
                                          checkpoints from the build doc:
                                          STAGED → CONFESSED → ROUTED →
                                          DESTROYED → ACKNOWLEDGED →
                                          RECORDED), AzazelStaged frozen
                                          dataclass, AzazelStageError
    test/
      test_eretz_gezerah.py             — 11 tests covering: sentinel
                                          identity stable across imports,
                                          not-a-string, no public attrs,
                                          repr leaks no destination info,
                                          identity (is) not equality (==),
                                          impostor with __eq__ rejected,
                                          module __getattr__ refuses
                                          unknown access, CI invariant
                                          forbidding destination-handle
                                          ASSIGNMENTS in source (catches
                                          regressions; mention in
                                          docstrings/__all__/getattr
                                          allowlist is fine)
      test_stages.py                    — 10 tests: intra-Azazel gating
                                          (happy path, mismatch raises
                                          naming both stages, frozen
                                          dataclass, six distinct
                                          checkpoints) and CROSS-PACKAGE
                                          classification discipline:
                                          KorbanStaged and AzazelStaged
                                          are distinct types; enum value
                                          spaces are disjoint; an Azazel
                                          function refuses a KorbanStaged
                                          input and vice versa; no
                                          conversion function exists in
                                          the public surface

PROPERTY COVERAGE THIS COMMIT:

  Property 11 (classification discipline):
    Defended by the type-distinctness of AzazelStaged vs.
    korban_processor.stages.Staged. Cross-package tests verify the
    two cannot be substituted at runtime; the absence of any
    korban_to_azazel/to_korban/from_korban/convert function in the
    public surface is verified by name-shape grep.

  Property 12 (unmanaged destination):
    Defended by the sentinel design: only object identity is exposed;
    no string handle exists; module __getattr__ refuses introspection;
    CI invariant catches assignment-shaped regressions across all of
    azazel_processor/src/.

PENDING (commits 2-10 per BUILD_AZAZEL.md):

  vidui (commit 2 — enumerative commit, property 3)
  shaliach + tevilah (commit 3 — role-separated operator, property 4)
  goral (commit 4 — blind routing, property 2)
  azazel orchestrator + state machine (commit 5 — properties 5, 7, 10)
  protocol envelope (commit 6 — protocol-restricted access, property 8)
  L3 azazel_records (commit 7 — operator-internal half of property 9)
  /peah/azazel_log endpoint (commit 8 — public half of property 9)
  Korban→Azazel routing on empty-vital + chatzotzrot wiring
    (commit 9 — properties 1, 6)
  Closure pass (commit 10)

Tests: azazel_processor 21 passed. korban_processor unchanged at
203 passed, 1 skipped (live-API). No regressions across the boundary.

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-27 16:07:44 -04:00
1 parent 1fb7d33124
commit d06ecd244d
7 files changed
+666

No files matched your search

View File
Whitespace-only changes.
+209
View File
@@ -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 `<object object at
0x...>` which leaks only the address (changes per process)."""
r = repr(ERETZ_GEZERAH)
assert "<object object at 0x" in r
assert "azazel" not in r.lower()
assert "void" not in r.lower()
assert "null" not in r.lower()
# ============================================================
# is_eretz_gezerah/1
# ============================================================
def test_is_eretz_gezerah_recognizes_canonical_sentinel():
assert is_eretz_gezerah(ERETZ_GEZERAH)
def test_is_eretz_gezerah_rejects_other_objects():
assert not is_eretz_gezerah(None)
assert not is_eretz_gezerah(object())
assert not is_eretz_gezerah("eretz_gezerah")
assert not is_eretz_gezerah("ERETZ_GEZERAH")
assert not is_eretz_gezerah(0)
assert not is_eretz_gezerah([])
def test_is_eretz_gezerah_uses_identity_not_equality():
"""A class with __eq__ that returns True for anything must not
be confused with the sentinel. Identity is the gate; equality
would let any impostor in."""
class Impostor:
def __eq__(self, other):
return True
def __hash__(self):
return 0
impostor = Impostor()
assert impostor == ERETZ_GEZERAH # equality is True (impostor lies)
assert not is_eretz_gezerah(impostor) # but identity is not
# ============================================================
# Module-level __getattr__ refuses introspection
# ============================================================
def test_module_refuses_unknown_attribute_access():
"""The module exposes only ERETZ_GEZERAH and is_eretz_gezerah.
Any other attribute access must raise — there are no further
handles to discover."""
import azazel_processor.eretz_gezerah as mod
with pytest.raises(AttributeError) as exc:
mod.DESTINATION_URL # noqa: B018 — deliberate access for the test
assert "no further handles" in str(exc.value).lower() or \
"opaque" in str(exc.value).lower()
def test_module_refuses_invented_metadata_lookup():
import azazel_processor.eretz_gezerah as mod
for invented in ("destination", "endpoint", "queue_name", "host", "address"):
with pytest.raises(AttributeError):
getattr(mod, invented)
# ============================================================
# CI-style invariant: no destination string in the codebase
# ============================================================
#
# This is the grep-the-codebase check from BUILD_AZAZEL.md §12.
# It runs as a normal test so a regression at any layer of the
# build (a future contributor adding `DESTINATION_URL = "..."` to
# any module) fires immediately.
# Forbidden ASSIGNMENT-SHAPED patterns. The failure mode is someone
# adding a module-level constant that reintroduces a destination
# handle (e.g. `DESTINATION_URL = "..."`). Mere mention in a
# docstring, an `__all__` re-export, or a `__getattr__` allowlist is
# NOT the failure mode; what we defend against is binding a forbidden
# name to a value at module scope.
import re
_FORBIDDEN_ASSIGNMENT_PATTERNS = [
re.compile(rf"^\s*{name}\s*=", re.MULTILINE)
for name in (
"DESTINATION_URL",
"DESTINATION_HOST",
"AZAZEL_QUEUE",
"AZAZEL_ENDPOINT",
"DISPOSAL_HOST",
"DISPOSAL_URL",
"DISPOSAL_QUEUE",
"AZAZEL_RECIPIENT",
"DISPOSAL_RECIPIENT",
)
]
def _walk_source_files():
"""Yield every .py file under src/ — the source the operator has
to read to understand the channel. Tests are excluded (test
files legitimately reference forbidden patterns in assertions)."""
src_root = pathlib.Path(__file__).resolve().parent.parent / "src"
yield from src_root.rglob("*.py")
def test_no_destination_handle_assigned_in_source():
"""Property 12 enforced as a CI invariant. If a future contributor
adds `DESTINATION_URL = "..."` (or any of the other forbidden
handle-shaped names) to ANY module in azazel_processor/, this
test fails — they reintroduced the failure mode the sentinel
exists to prevent.
Catches assignment, not mention. Documenting "do not add
DESTINATION_URL" in a docstring is fine; `DESTINATION_URL = "x"`
is the regression.
"""
offenders = []
for path in _walk_source_files():
text = path.read_text()
for pattern in _FORBIDDEN_ASSIGNMENT_PATTERNS:
for match in pattern.finditer(text):
offenders.append((path.name, match.group().strip()))
assert offenders == [], (
f"Forbidden destination-handle assignment(s) in azazel_processor "
f"source: {offenders}. The destination is opaque by specification "
f"(property 12, azazel_engineering_reading.md §14). Use "
f"ERETZ_GEZERAH (the Python sentinel), not a string-valued "
f"module constant."
)
def test_sentinel_name_not_assigned_to_string_in_source():
"""A module-level `X = 'ERETZ_GEZERAH'` would treat the sentinel
name as if it were a string handle — re-introducing the very
string-tracking the sentinel exists to prevent. Mere mention in
`__all__` or `__getattr__`'s allowlist is fine; binding a name
to the string is not.
"""
string_assignment = re.compile(
r"""^\s*\w+\s*=\s*["']ERETZ_GEZERAH["']""", re.MULTILINE
)
offenders = []
for path in _walk_source_files():
text = path.read_text()
if string_assignment.search(text):
offenders.append(path.name)
assert offenders == [], (
f"Module-level assignment binding the string 'ERETZ_GEZERAH' "
f"to a name in: {offenders}. The sentinel is a Python object, "
f"not a string. Refer to it via the imported name, not via a "
f"string handle."
)
+182
View File
@@ -0,0 +1,182 @@
"""
Tests for the Azazel stage gating — properties 11 (classification
discipline) and the basic gating semantics that mirror
korban_processor/test/test_stages.py.
Two layers of test:
1. INTRA-AZAZEL: stage-mismatch within the disposal protocol
(the kohen receives the goat from the wrong checkpoint).
2. CROSS-PACKAGE: a Korban-side staged value cannot be used as
an Azazel-side staged value, and vice versa. Property 11:
processing and disposal are non-interconvertible at the type
level. The two structurally identical dataclasses are the same
SHAPE but the type system treats them as unrelated.
The cross-package test is the load-bearing one. Every later commit
in the Azazel build inherits its protection: if AzazelStaged and
korban_processor.stages.Staged were the same type (or interconvertible
via subtype), every downstream function would have to add runtime
checks to defend the boundary. Because they are distinct types, the
defense is automatic.
"""
import pytest
from azazel_processor.stages import (
AzazelStage,
AzazelStaged,
AzazelStageError,
)
# ============================================================
# Intra-Azazel: stage gating within the disposal protocol
# ============================================================
def test_expect_stage_returns_payload_when_marker_matches():
s = AzazelStaged(AzazelStage.STAGED, "some-input")
assert s.expect_stage(AzazelStage.STAGED) == "some-input"
def test_expect_stage_raises_when_marker_does_not_match():
s = AzazelStaged(AzazelStage.STAGED, "some-input")
with pytest.raises(AzazelStageError) as exc:
s.expect_stage(AzazelStage.CONFESSED)
msg = str(exc.value)
# Both stages named — the operator can diagnose call-site vs. tag-site.
assert "staged" in msg
assert "confessed" in msg
def test_azazel_staged_is_frozen():
"""The marker cannot be re-labeled after construction. There is
no path to taking a RECORDED-tagged value and re-tagging it as
STAGED to bypass the gate."""
s = AzazelStaged(AzazelStage.STAGED, "some-input")
with pytest.raises((AttributeError, Exception)):
s.stage = AzazelStage.RECORDED # type: ignore[misc]
def test_all_six_checkpoints_are_distinct():
"""STAGED → CONFESSED → ROUTED → DESTROYED → ACKNOWLEDGED →
RECORDED. No collapse, no aliases."""
stages = [
AzazelStage.STAGED,
AzazelStage.CONFESSED,
AzazelStage.ROUTED,
AzazelStage.DESTROYED,
AzazelStage.ACKNOWLEDGED,
AzazelStage.RECORDED,
]
assert len(set(stages)) == 6
def test_stage_error_names_both_stages():
s = AzazelStaged(AzazelStage.ROUTED, "p")
with pytest.raises(AzazelStageError) as exc:
s.expect_stage(AzazelStage.STAGED)
msg = str(exc.value)
assert "routed" in msg
assert "staged" in msg
# ============================================================
# Cross-package: classification discipline (property 11)
# ============================================================
#
# These tests import from BOTH korban_processor and azazel_processor
# to verify the two staging types are non-interconvertible. The test
# is feasible because pyproject.toml's pythonpath includes both
# packages' src directories.
def test_korban_staged_is_a_distinct_type_from_azazel_staged():
"""Even though both are dataclasses with the same field shape,
they are distinct Python types. isinstance/type checks see them
as unrelated."""
from korban_processor.stages import KorbanStage, Staged as KorbanStaged
k = KorbanStaged(KorbanStage.SEMICHAH, "a-korban")
a = AzazelStaged(AzazelStage.STAGED, "a-disposal-input")
assert type(k) is not type(a)
assert not isinstance(k, AzazelStaged)
assert not isinstance(a, KorbanStaged)
def test_korban_stage_enum_distinct_from_azazel_stage_enum():
from korban_processor.stages import KorbanStage
# The two enums share NO members — the names that DO overlap
# by coincidence (none currently) would still be distinct
# enum identities.
korban_values = {s.value for s in KorbanStage}
azazel_values = {s.value for s in AzazelStage}
# No accidental overlap in stage names. If a future contributor
# adds a stage that collides, this fires — and they should pick
# a different name to keep the two channels lexically distinct.
assert korban_values.isdisjoint(azazel_values), (
f"KorbanStage and AzazelStage share value(s): "
f"{korban_values & azazel_values}. The two protocols should "
f"have lexically distinct stage names so logs and call sites "
f"cannot accidentally treat one as the other."
)
def test_azazel_function_refuses_korban_staged_value():
"""The structural test of property 11: an Azazel function cannot
receive a KorbanStaged value. This is enforced via expect_stage's
AttributeError when called on the wrong dataclass — KorbanStaged
has expect_stage too (it's the same method name) but its stage
field carries a KorbanStage value, which `==` against an
AzazelStage member is always False, raising AzazelStageError."""
from korban_processor.stages import KorbanStage, Staged as KorbanStaged
korban_value = KorbanStaged(KorbanStage.SHECHITAH, "wrong-channel-payload")
# An Azazel function would call .expect_stage(AzazelStage.STAGED)
# on this value. KorbanStage.SHECHITAH != AzazelStage.STAGED
# (different enums, never equal), so expect_stage raises.
with pytest.raises(Exception):
# We deliberately cast through Any-ish to mimic the misuse;
# at runtime this fires.
korban_value.expect_stage(AzazelStage.STAGED) # type: ignore[arg-type]
def test_korban_function_refuses_azazel_staged_value():
"""The reverse direction: a Korban function cannot receive an
AzazelStaged value."""
from korban_processor.stages import KorbanStage
azazel_value = AzazelStaged(AzazelStage.STAGED, "wrong-channel-payload")
with pytest.raises(Exception):
azazel_value.expect_stage(KorbanStage.SEMICHAH) # type: ignore[arg-type]
def test_no_korban_to_azazel_conversion_function_exists():
"""There must be no `korban_to_azazel` or similar conversion
function in azazel_processor. If one is added, this test fails —
classification discipline is being deliberately broken."""
import azazel_processor
public = [name for name in dir(azazel_processor) if not name.startswith("_")]
forbidden_substrings = ("korban_to", "to_korban", "from_korban", "convert")
offenders = [
name
for name in public
for sub in forbidden_substrings
if sub in name.lower()
]
assert offenders == [], (
f"azazel_processor exposes conversion-shaped names: {offenders}. "
f"Property 11 (classification discipline): processing and disposal "
f"state are not interconvertible. There must be no path from one "
f"channel to the other."
)