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:
1 parent
1fb7d33124
commit
d06ecd244d
7 files changed
+666
No files matched your search
Whitespace-only changes.
@@ -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."
|
||||
)
|
||||
@@ -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."
|
||||
)
|
||||
Reference in new issue
Block a user