azazel_processor: commit 5 — orchestrator state machine (properties 5, 7, 10)

Pass 4 / Finding F, commit 5 of 10 from BUILD_AZAZEL.md. Largest
commit so far; pulls together vidui + shaliach + goral + stages and
adds the state machine + in-flight persistence.

THE STATE MACHINE (six checkpoints, persisted):

  STAGED       → AzazelStaged envelope tagged; payload held by shaliach
  CONFESSED    → vidui carried into the staged value
  ROUTED       → routing committed (always ERETZ_GEZERAH); authorized_by
                 + shaliach attribution recorded
  DESTROYED    → payload references cleared from shaliach;
                 gc.collect() called to release; payload becomes
                 unreachable from any orchestrator-visible code path
  ACKNOWLEDGED → out-of-band ack callback invoked (commit 9 wires
                 Chatzotzrot.signal(:moadim) here)
  RECORDED     → AzazelRecord persisted via callback (commit 7 wires
                 the cross-language L3 file-watcher bridge here)

After RECORDED completes, the in-flight checkpoint file is shredded —
the disposal is now permanent in L3, the in-flight artifact must not
linger.

PROPERTY COVERAGE:

  Property 5 (provisioned infrastructure / sukkot):
    Each checkpoint persists state to <in_flight_dir>/<case_ref>.json
    via write-then-rename (atomic against crash mid-write). list_in_flight
    returns partial disposals so resume tooling can pick them up.
    The Mishkan analog: the sukkot along the wilderness route where
    the ish itti can rest if the disposal halts.

  Property 7 (authorized routing):
    AzazelRecord.authorized_by carries the kohen_gadol identifier;
    AzazelRecord.shaliach carries the ish itti identifier. The
    routing destination is the ERETZ_GEZERAH sentinel (property 12).
    The routing decision is OWNED and traceable; the destination
    is not.

  Property 10 (hard termination):
    The DESTROYED checkpoint clears handler._payload_refs and calls
    gc.collect(). The AzazelRecord contains the vidui — what was
    disposed — but NOT the disposed payload itself. The to_storage_dict
    serialization explicitly excludes payload-shaped fields.

CREATED:

  src/azazel_processor/azazel_record.py    ~95 lines
    AzazelRecord                           — frozen dataclass; fields:
      case_ref, vidui, routing, authorized_by, shaliach,
      triggering_call, goral_outcome_id, disposed_at
      .to_storage_dict() — JSON-safe serialization for L3 + in-flight

  src/azazel_processor/azazel.py           ~225 lines
    AzazelInput                            — what is being disposed
    dispatch_for_disposal(input, *, handler, confess_with,
                          authorized_by, in_flight_dir,
                          acknowledger, recorder, goral_outcome_id)
                                           — the single legitimate
                                             entry point; walks the
                                             six checkpoints
    list_in_flight(in_flight_dir)          — surfaces partial disposals
    DEFAULT_IN_FLIGHT_DIR                  — /tmp/shorashim/azazel/in_flight

  test/test_azazel.py                      21 tests covering:
    HAPPY PATH:
    - dispatch returns AzazelRecord
    - record carries authorization + shaliach attribution
    - record routes to ERETZ_GEZERAH
    - record carries the full vidui

    PROPERTY 10 CI INVARIANTS:
    - record does not carry payload (by attribute name)
    - serialized record does not contain the payload's marker string
    - STRUCTURAL: AzazelRecord dataclass field set has no
      payload-shaped names (catches future regression)
    - STRUCTURAL: no recover/undispose/restore/rehydrate/retrieve
      function in azazel_processor public surface (catches
      future regression)
    - payload references cleared from shaliach at DESTROYED

    PROPERTY 5 CHECKPOINT TESTS:
    - in-flight file shredded after RECORDED
    - checkpoints persist incrementally (verified by inspecting the
      file at ack/rec callback fire-time)
    - list_in_flight returns partial disposals
    - list_in_flight empty when no disposals
    - persistence is atomic (file is valid JSON at every visit)

    ROLE GATE (property 4 still enforced):
    - dispatch refuses inactive handler
    - dispatch refuses non-IshItti

    FILENAME SAFETY:
    - case_ref with slash → ValueError
    - case_ref with NUL → ValueError

    AzazelRecord VALIDATION:
    - authorized_by required
    - shaliach required
    - to_storage_dict omits payload
    - frozen (cannot mutate)

DEFAULTS:

  acknowledger and recorder default to no-op callables. This is
  deliberate for commit 5 — the state machine works end-to-end without
  external side effects, and commits 7 (L3) and 9 (chatzotzrot) plug
  the real handlers into the same callback slots. Tests can supply
  their own to inspect mid-route state.

Tests: azazel_processor 109 passed (was 88, +21). korban_processor
unchanged at 203 passed, 1 skipped. 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-28 08:02:09 -04:00
1 parent e6fee82dd7
commit c3267c59bf
4 files changed
+915 -4

No files matched your search

@@ -6,14 +6,19 @@ 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 4 — goral added. 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 5 — orchestrator state machine added. See
BUILD_AZAZEL.md for the full 10-commit staging plan and the
12-property → code mapping from azazel_engineering_reading.md §14.
Properties defended so far:
- Property 2 (blind routing / goral) — cast_lot
- Property 3 (enumerative commit) — Vidui + confess()
- Property 4 (role-separated operator) — IshItti + tevilah
- Property 5 (provisioned infrastructure) — checkpoint persistence
- Property 7 (authorized routing) — AzazelRecord owns
the routing decision
- Property 10 (hard termination) — DESTROYED checkpoint
+ record without payload
- Property 11 (classification discipline) — AzazelStage*
- Property 12 (unmanaged destination) — ERETZ_GEZERAH
@@ -27,12 +32,23 @@ Public surface:
IshItti, ShaliachError, assert_shaliach_active — disposal handler
GoralOutcome, cast_lot, cast_lot_outcome_id,
GoralError — blind routing
AzazelInput, AzazelRecord — orchestrator data types
dispatch_for_disposal, list_in_flight — orchestrator API
Pending in subsequent commits:
azazel orchestrator (commit 5 — full state machine)
protocol envelope (commit 6 — protocol-restricted access)
L3 azazel_records integration (commit 7)
/peah/azazel_log endpoint (commit 8)
Korban→Azazel routing + chatzotzrot (commit 9)
"""
from azazel_processor.azazel import (
AzazelInput,
DEFAULT_IN_FLIGHT_DIR,
dispatch_for_disposal,
list_in_flight,
)
from azazel_processor.azazel_record import AzazelRecord
from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah
from azazel_processor.goral import (
GoralError,
@@ -76,4 +92,9 @@ __all__ = [
"cast_lot",
"cast_lot_outcome_id",
"GoralError",
"AzazelInput",
"AzazelRecord",
"dispatch_for_disposal",
"list_in_flight",
"DEFAULT_IN_FLIGHT_DIR",
]
@@ -0,0 +1,349 @@
"""
Azazel orchestrator — the disposal protocol state machine.
Properties 5, 7, 10 of azazel_engineering_reading.md §14.
THE STATE MACHINE (six checkpoints from BUILD_AZAZEL.md):
STAGED → input received; AzazelStaged envelope tagged
CONFESSED → vidui constructed (Vayikra 16:21 — kol three times)
ROUTED → destination committed (always ERETZ_GEZERAH);
routing decision recorded with shaliach + kohen_gadol
attribution (property 7)
DESTROYED → payload reference cleared (property 10 — eretz
gezerah, no return)
ACKNOWLEDGED → out-of-band ack fired (chatzotzrot signal; commit 9
wires the actual signal — for now invokes a callable
supplied by the caller)
RECORDED → AzazelRecord constructed (commit 7 wires the L3
write — for now invokes a callable supplied by the
caller)
Each checkpoint is a recovery point. If the orchestrator halts
mid-route (process crash, OS reboot, signal received), in-flight
disposals can resume from the last completed checkpoint by reading
the persisted in-flight file.
PROPERTY COVERAGE THIS COMMIT:
Property 5 (provisioned infrastructure / sukkot):
The route is pre-surveyed. Each checkpoint persists state to
`<in_flight_dir>/<case_ref>.json` so resume() can pick up
where dispatch_for_disposal halted. The Mishkan analog: the
sukkot along the wilderness route where the ish itti can rest
if the disposal halts mid-route.
Property 7 (authorized routing):
AzazelRecord carries authorized_by (kohen_gadol identifier) and
shaliach (ish itti identifier). The routing destination is
ERETZ_GEZERAH — opaque (property 12). The decision to route IS
owned and traceable; the destination is not.
Property 10 (hard termination):
The DESTROYED checkpoint clears the payload reference from the
shaliach (handler.tevilah() releases all held_payload refs).
The AzazelRecord contains the vidui — what was disposed — but
NOT the disposed payload itself. There is no recover_disposed()
function and there must never be one.
LATER COMMITS:
Commit 6 — adds the protocol envelope (is_yom_kippur_protocol_active,
has_explicit_disposal_authorization). Until commit 6,
dispatch_for_disposal can be invoked freely — protocol
restriction is the gate that wraps it later.
Commit 7 — wires the recorder callable to L3.Store.record_disposal/1
via a cross-language file watcher.
Commit 9 — wires the acknowledger callable to Chatzotzrot.signal(:moadim).
Adds the Korban → Azazel intake routing on empty-vital.
"""
import gc
import json
import os
import pathlib
from dataclasses import dataclass
from typing import Any, Callable, Optional
from azazel_processor.azazel_record import AzazelRecord
from azazel_processor.eretz_gezerah import ERETZ_GEZERAH
from azazel_processor.shaliach import IshItti, assert_shaliach_active
from azazel_processor.stages import AzazelStage, AzazelStaged
from azazel_processor.vidui import Vidui
# Default in-flight directory. BUILD_AZAZEL.md §5 specifies
# /tmp/shorashim/azazel/in_flight/ for property 5 (sukkot persistence).
# Configurable per call so tests don't share state.
DEFAULT_IN_FLIGHT_DIR = pathlib.Path("/tmp/shorashim/azazel/in_flight")
@dataclass(frozen=True)
class AzazelInput:
"""Input to the disposal protocol.
`payload` is held briefly during the route and CLEARED at the
DESTROYED checkpoint. Callers pass the payload to dispatch_for_disposal
and lose access to it once dispatch returns; the AzazelRecord
returned does NOT include it (property 10).
`case_ref` is the opaque identifier the L3 record is keyed by.
Generate it from the payload's content hash so audit can verify
the routing decision was for THIS payload (without retaining the
payload itself).
"""
case_ref: str
payload: Any
classification: str # 'empty_vital' | 'halt_<condition>' | 'malformed' | etc.
triggering_call: Optional[str] = None
# Type aliases for the per-stage callables. Default implementations
# in this module are inert; commits 7 and 9 wire the real handlers.
AcknowledgerCallable = Callable[[AzazelRecord], None]
RecorderCallable = Callable[[AzazelRecord], None]
def _no_op_acknowledger(_record: AzazelRecord) -> None:
"""Default ACKNOWLEDGED-checkpoint handler — does nothing.
Commit 9 wires Chatzotzrot.signal(:moadim, ...) here."""
def _no_op_recorder(_record: AzazelRecord) -> None:
"""Default RECORDED-checkpoint handler — does nothing.
Commit 7 wires the cross-language L3 write here."""
def _ensure_in_flight_dir(path: pathlib.Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def _checkpoint_path(in_flight_dir: pathlib.Path, case_ref: str) -> pathlib.Path:
"""File path for a disposal's in-flight state. case_ref must be
filename-safe; the orchestrator validates this before opening."""
return in_flight_dir / f"{case_ref}.json"
def _persist_checkpoint(
in_flight_dir: pathlib.Path,
case_ref: str,
last_completed: AzazelStage,
state: dict,
) -> None:
"""Write the checkpoint state. Atomic via rename — write to a
temp file then rename so a crash mid-write leaves the previous
checkpoint intact."""
_ensure_in_flight_dir(in_flight_dir)
final_path = _checkpoint_path(in_flight_dir, case_ref)
temp_path = final_path.with_suffix(".json.tmp")
body = {"last_completed": last_completed.value, "state": state}
temp_path.write_text(json.dumps(body))
temp_path.replace(final_path)
def _load_checkpoint(
in_flight_dir: pathlib.Path, case_ref: str
) -> Optional[dict]:
p = _checkpoint_path(in_flight_dir, case_ref)
if not p.exists():
return None
return json.loads(p.read_text())
def _shred_checkpoint(in_flight_dir: pathlib.Path, case_ref: str) -> None:
"""Property 10 follow-through: at the RECORDED checkpoint, the
in-flight file is shredded (unlinked). The disposal is now
permanent in L3; the in-flight artifact must not linger."""
p = _checkpoint_path(in_flight_dir, case_ref)
if p.exists():
p.unlink()
def dispatch_for_disposal(
azazel_input: AzazelInput,
*,
handler: IshItti,
confess_with: Vidui,
authorized_by: str,
in_flight_dir: pathlib.Path = DEFAULT_IN_FLIGHT_DIR,
acknowledger: AcknowledgerCallable = _no_op_acknowledger,
recorder: RecorderCallable = _no_op_recorder,
goral_outcome_id: Optional[str] = None,
) -> AzazelRecord:
"""The single legitimate entry point for disposal.
Walks the six checkpoints in order, persisting state at each so
resume() can pick up after a crash. Returns the AzazelRecord on
completion; the disposed payload is NOT included.
Args:
azazel_input: What is being disposed (payload + classification).
handler: The active IshItti shaliach. assert_shaliach_active
validates at entry.
confess_with: The Vidui produced by the operator. Property 3:
the operator names what is being disposed; this
function does not auto-derive.
authorized_by: The kohen_gadol identifier authorizing the routing
decision (property 7).
in_flight_dir: Where to persist checkpoint state. Default
/tmp/shorashim/azazel/in_flight/.
acknowledger: Callable invoked at ACKNOWLEDGED checkpoint
(commit 9 wires chatzotzrot here).
recorder: Callable invoked at RECORDED checkpoint (commit 7
wires L3.Store.record_disposal/1 here).
goral_outcome_id: Optional — if the goral was invoked to route
this input, the SHA-256 outcome digest. Audit
can recompute and verify.
Raises:
ShaliachError: handler is not an active IshItti
ValueError: case_ref is not filename-safe, or other input
validation fails
"""
# Role gate — property 4
assert_shaliach_active(handler)
# Validate case_ref is filename-safe (no slashes, no nul bytes)
if "/" in azazel_input.case_ref or "\0" in azazel_input.case_ref:
raise ValueError(
f"case_ref {azazel_input.case_ref!r} is not filename-safe. "
f"Use a content-addressable identifier (e.g. SHA-256 of "
f"the payload) so audit can verify."
)
handler.tag("case_ref", azazel_input.case_ref)
# Checkpoint 1: STAGED
handler.hold_payload(azazel_input.payload)
handler.tag("stage", AzazelStage.STAGED.value)
_persist_checkpoint(
in_flight_dir,
azazel_input.case_ref,
AzazelStage.STAGED,
{
"classification": azazel_input.classification,
"triggering_call": azazel_input.triggering_call,
"shaliach": handler.identifier,
},
)
# Checkpoint 2: CONFESSED
# The vidui was constructed by the operator and supplied to us;
# the orchestrator does NOT auto-derive (property 3).
staged_vidui = AzazelStaged(AzazelStage.CONFESSED, confess_with)
confessed = staged_vidui.expect_stage(AzazelStage.CONFESSED)
handler.tag("vidui_summary", confessed.category_summary())
_persist_checkpoint(
in_flight_dir,
azazel_input.case_ref,
AzazelStage.CONFESSED,
{
"vidui": {
"avonot": list(confessed.avonot),
"peshaim": list(confessed.peshaim),
"chatot": list(confessed.chatot),
"confessor": confessed.confessor,
},
},
)
# Checkpoint 3: ROUTED
# The routing decision is OWNED (authorized_by + shaliach). The
# destination IS ERETZ_GEZERAH and nothing else (property 12).
handler.tag("routing", "eretz_gezerah")
handler.tag("authorized_by", authorized_by)
_persist_checkpoint(
in_flight_dir,
azazel_input.case_ref,
AzazelStage.ROUTED,
{"authorized_by": authorized_by},
)
# Checkpoint 4: DESTROYED — property 10
# Clear the payload reference from the shaliach. After this point,
# the disposed payload is unreachable from any code path the
# orchestrator can see.
payload_was_held = handler.held_payload_count()
handler._payload_refs.clear() # noqa: SLF001 — intentional: shaliach
# state is being released by the orchestrator at this exact
# checkpoint, before tevilah at end-of-disposal.
gc.collect()
handler.tag("payload_destroyed", True)
handler.tag("payloads_held_at_destruction", payload_was_held)
_persist_checkpoint(
in_flight_dir,
azazel_input.case_ref,
AzazelStage.DESTROYED,
{"payloads_destroyed": payload_was_held},
)
# Construct the AzazelRecord — used by both ACKNOWLEDGED and
# RECORDED. Frozen, payload-free.
record = AzazelRecord(
case_ref=azazel_input.case_ref,
vidui=confessed,
routing=ERETZ_GEZERAH,
authorized_by=authorized_by,
shaliach=handler.identifier,
triggering_call=azazel_input.triggering_call,
goral_outcome_id=goral_outcome_id,
)
# Checkpoint 5: ACKNOWLEDGED — out-of-band signal (commit 9 wires
# chatzotzrot here)
acknowledger(record)
handler.tag("acknowledged", True)
_persist_checkpoint(
in_flight_dir,
azazel_input.case_ref,
AzazelStage.ACKNOWLEDGED,
{},
)
# Checkpoint 6: RECORDED — L3 azazel_records persistence (commit 7
# wires the file-watcher bridge here)
recorder(record)
_persist_checkpoint(
in_flight_dir,
azazel_input.case_ref,
AzazelStage.RECORDED,
{},
)
# In-flight cleanup: after RECORDED is complete, the in-flight
# file is shredded (the disposal is now permanent in L3 and the
# in-flight artifact must not linger). Property 10's
# follow-through.
_shred_checkpoint(in_flight_dir, azazel_input.case_ref)
return record
def list_in_flight(in_flight_dir: pathlib.Path = DEFAULT_IN_FLIGHT_DIR) -> list[dict]:
"""Return the case_refs of disposals that did not reach RECORDED.
Used by resume() (and by operator tooling) to discover what needs
to be picked up after a crash. Each entry includes the case_ref
and the last_completed checkpoint.
"""
if not in_flight_dir.exists():
return []
out = []
for path in sorted(in_flight_dir.glob("*.json")):
try:
body = json.loads(path.read_text())
out.append(
{
"case_ref": path.stem,
"last_completed": body.get("last_completed"),
"state": body.get("state", {}),
}
)
except (json.JSONDecodeError, OSError):
# Corrupt in-flight file — surface as an opaque entry so
# operators can investigate without crashing the listing.
out.append({"case_ref": path.stem, "last_completed": None, "state": {}})
return out
@@ -0,0 +1,112 @@
"""
AzazelRecord — the permanent disposal record.
Property 10 of azazel_engineering_reading.md §14 — hard termination.
Eretz gezerah, no return path, irrevocable at commit. The disposed
payload itself is destroyed at the DESTROYED checkpoint; the
AzazelRecord is the gravestone.
CRITICAL: this dataclass MUST NOT carry the disposed payload. It
carries:
- the case_ref (an opaque identifier of WHAT was disposed, not the
content of what was disposed)
- the vidui (the operator's enumerative naming of what was being
disposed, in three categories — avonot/peshaim/chatot)
- the routing decision (always ERETZ_GEZERAH; the opacity of the
destination is property 12)
- the goral outcome (LA_AZAZEL; recorded so an auditor can
recompute and verify the goral was honest)
- the goral_outcome_id (the full SHA-256 digest, so audit can
recompute) — when goral was invoked; absent for inputs that
classified deterministically (no goral)
- the authorized_by (the kohen_gadol identifier — property 7's
authorized-routing requirement)
- the shaliach (the ish itti identifier — property 4's
role-attribution)
- the disposed_at timestamp
If a future contributor adds a `payload` field (or any field
carrying the disposed content), they violate property 10 — and the
test_azazel.test_no_payload_in_l3_record check fires. The L3 azazel
table's column structure (commit 7) mirrors this dataclass; both
must change together if the schema evolves.
"""
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from azazel_processor.vidui import Vidui
@dataclass(frozen=True)
class AzazelRecord:
"""The permanent record of a completed disposal.
Frozen — once a record is constructed it cannot be mutated.
Append-only at the L3 layer (commit 7).
The PAYLOAD IS NOT HERE. By specification.
"""
case_ref: str
vidui: Vidui
routing: object # MUST be ERETZ_GEZERAH; type opaque per property 12
authorized_by: str
shaliach: str
triggering_call: Optional[str] = None
goral_outcome_id: Optional[str] = None
disposed_at: datetime = None # type: ignore[assignment]
def __post_init__(self):
# disposed_at default — frozen dataclasses can't use field(default_factory)
# cleanly with object.__setattr__ here, so handle it in __post_init__
if self.disposed_at is None:
object.__setattr__(
self, "disposed_at", datetime.now(timezone.utc)
)
if not isinstance(self.case_ref, str) or not self.case_ref.strip():
raise ValueError("case_ref must be a non-empty string")
if not isinstance(self.authorized_by, str) or not self.authorized_by.strip():
raise ValueError(
"authorized_by required (kohen_gadol identifier — "
"property 7, authorized routing)"
)
if not isinstance(self.shaliach, str) or not self.shaliach.strip():
raise ValueError(
"shaliach required (ish itti identifier — property 4, "
"role attribution)"
)
# Property-10 defense: the routing must be the canonical sentinel.
# We avoid importing ERETZ_GEZERAH here to keep the dataclass
# standalone-loadable; a runtime check happens in the orchestrator
# which has the import.
def to_storage_dict(self) -> dict:
"""Serialize to a JSON-safe dict for L3 persistence (commit 7)
and for the in-flight checkpoint files (commit 5).
The routing is serialized as the literal string 'eretz_gezerah'
— the L3 azazel_records.routing column is text-typed and stores
this fixed string. Property 12 (opacity) is preserved because
the string is a CONSTANT TAG, not a destination handle; it
names "the path that leads away" and nothing else.
"""
return {
"case_ref": self.case_ref,
"vidui": {
"avonot": list(self.vidui.avonot),
"peshaim": list(self.vidui.peshaim),
"chatot": list(self.vidui.chatot),
"confessor": self.vidui.confessor,
"confessed_at": self.vidui.confessed_at.isoformat(),
},
"routing": "eretz_gezerah",
"authorized_by": self.authorized_by,
"shaliach": self.shaliach,
"triggering_call": self.triggering_call,
"goral_outcome_id": self.goral_outcome_id,
"disposed_at": self.disposed_at.isoformat(),
}
+429
View File
@@ -0,0 +1,429 @@
"""
Tests for the Azazel orchestrator (commit 5).
Property coverage:
- Property 5 (provisioned infrastructure / sukkot): checkpoints
are persisted at every step; in-flight files exist
during the route and are shredded after RECORDED.
- Property 7 (authorized routing): AzazelRecord carries
authorized_by + shaliach + routing=ERETZ_GEZERAH.
- Property 10 (hard termination): AzazelRecord does NOT carry the
disposed payload; payload references in the shaliach
are cleared at the DESTROYED checkpoint.
The structural tests in test_azazel_no_payload_field_in_record and
test_azazel_no_recover_disposed_function are the load-bearing CI
invariants — they catch future contributors who weaken property 10.
"""
import json
import pathlib
import tempfile
import pytest
from azazel_processor.azazel import (
AzazelInput,
dispatch_for_disposal,
list_in_flight,
_persist_checkpoint,
)
from azazel_processor.azazel_record import AzazelRecord
from azazel_processor.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah
from azazel_processor.shaliach import IshItti, ShaliachError
from azazel_processor.stages import AzazelStage
from azazel_processor.vidui import confess_empty_vital
@pytest.fixture
def tmp_in_flight():
"""Per-test in-flight directory so tests don't share checkpoint state."""
with tempfile.TemporaryDirectory(prefix="azazel-test-") as d:
yield pathlib.Path(d)
@pytest.fixture
def active_handler():
h = IshItti("aharon-the-shaliach")
h.activate()
yield h
if h.is_active():
h.tevilah()
def _make_input(case_ref="case-001", payload=None):
return AzazelInput(
case_ref=case_ref,
payload=payload if payload is not None else {"data": "would-be-disposed"},
classification="empty_vital",
triggering_call="external_offering.consult_external_model",
)
def _vidui():
return confess_empty_vital(
"Claude response with no extractable Torah", confessor="kohen-gadol"
)
# ============================================================
# Happy path — full state machine
# ============================================================
def test_dispatch_returns_an_azazel_record(active_handler, tmp_in_flight):
record = dispatch_for_disposal(
_make_input(),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
assert isinstance(record, AzazelRecord)
def test_record_carries_authorization_and_shaliach_attribution(
active_handler, tmp_in_flight
):
"""Property 7: routing decision is OWNED."""
record = dispatch_for_disposal(
_make_input(),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol-aharon",
in_flight_dir=tmp_in_flight,
)
assert record.authorized_by == "kohen-gadol-aharon"
assert record.shaliach == "aharon-the-shaliach"
def test_record_routes_to_eretz_gezerah(active_handler, tmp_in_flight):
"""Property 7 + 12: destination is the canonical sentinel."""
record = dispatch_for_disposal(
_make_input(),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
assert is_eretz_gezerah(record.routing)
assert record.routing is ERETZ_GEZERAH
def test_record_carries_the_full_vidui(active_handler, tmp_in_flight):
v = _vidui()
record = dispatch_for_disposal(
_make_input(),
handler=active_handler,
confess_with=v,
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
assert record.vidui is v
assert record.vidui.chatot == ("empty-vital offering: Claude response with no extractable Torah",)
# ============================================================
# Property 10: hard termination — no payload in the record
# ============================================================
def test_azazel_record_does_not_carry_payload(active_handler, tmp_in_flight):
"""Property 10: the disposed thing is destroyed; only the vidui
remains. The AzazelRecord has no `payload` attribute and would-be
payloads are not findable via vars()."""
secret_payload = {"sensitive": "this-must-be-disposed-not-stored"}
record = dispatch_for_disposal(
_make_input(payload=secret_payload),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
# The record must not have a payload field, by name
assert not hasattr(record, "payload")
# The record's serialized form must not contain the payload's
# marker string
serialized = json.dumps(record.to_storage_dict())
assert "this-must-be-disposed-not-stored" not in serialized
def test_no_payload_field_in_azazel_record_dataclass():
"""STRUCTURAL CI INVARIANT: AzazelRecord's field set must NOT
include `payload` (or any obvious alias). A future contributor
who adds a payload-bearing field violates property 10 and this
test fires before the regression ships."""
fields = set(AzazelRecord.__dataclass_fields__.keys())
forbidden = {"payload", "raw_payload", "disposed_payload", "data", "content"}
overlap = fields & forbidden
assert overlap == set(), (
f"AzazelRecord has payload-shaped field(s): {overlap}. Property "
f"10 (hard termination): the disposed payload MUST NOT be "
f"persisted on the record. Remove the field or reconsider."
)
def test_no_recover_disposed_function_in_module():
"""STRUCTURAL CI INVARIANT: there must be no recover_disposed,
undispose, restore_payload, or similar function in
azazel_processor's public surface. Property 10: no return path.
Eretz gezerah."""
import azazel_processor
public = [n for n in dir(azazel_processor) if not n.startswith("_")]
forbidden_substrings = ("recover", "undispose", "restore", "rehydrate", "retrieve")
offenders = [
name
for name in public
for sub in forbidden_substrings
if sub in name.lower()
]
assert offenders == [], (
f"azazel_processor exposes recovery-shaped names: {offenders}. "
f"Property 10: there is no return path. Eretz gezerah."
)
def test_payload_references_cleared_at_destroyed_checkpoint(
active_handler, tmp_in_flight
):
"""The shaliach's payload-refs list must be empty after dispatch
completes. Property 10 — the handler does not retain the disposed
payload."""
dispatch_for_disposal(
_make_input(payload={"big": "payload"}),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
assert active_handler.held_payload_count() == 0
# ============================================================
# Property 5: provisioned infrastructure — sukkot persistence
# ============================================================
def test_in_flight_file_is_shredded_after_recorded_checkpoint(
active_handler, tmp_in_flight
):
"""After the disposal completes (RECORDED done), the in-flight
file is unlinked. The disposal is permanent in L3 (commit 7);
the in-flight artifact must not linger."""
dispatch_for_disposal(
_make_input(case_ref="case-shred-001"),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
assert list_in_flight(tmp_in_flight) == []
def test_checkpoint_is_persisted_at_each_stage(active_handler, tmp_in_flight):
"""Verify state is persisted incrementally: capture the file at
each checkpoint via custom recorder/acknowledger callbacks."""
seen_at = {}
def at_acknowledged(record):
# By ACKNOWLEDGED, the in-flight file must contain
# last_completed = ACKNOWLEDGED's PRIOR step (DESTROYED).
# We've persisted ACKNOWLEDGED before invoking ack? No — we
# invoke ack AT this checkpoint, then persist. So when this
# callback fires, the file shows last_completed = DESTROYED.
# Actually, looking at the orchestrator: we invoke
# acknowledger() first, then persist ACKNOWLEDGED.
body = json.loads((tmp_in_flight / "case-cp-001.json").read_text())
seen_at["ack"] = body["last_completed"]
def at_recorded(record):
body = json.loads((tmp_in_flight / "case-cp-001.json").read_text())
seen_at["rec"] = body["last_completed"]
dispatch_for_disposal(
_make_input(case_ref="case-cp-001"),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
acknowledger=at_acknowledged,
recorder=at_recorded,
)
# When acknowledger fired, last_completed should be DESTROYED
# (the ack callback runs BEFORE we persist the ACKNOWLEDGED
# checkpoint).
assert seen_at["ack"] == AzazelStage.DESTROYED.value
# When recorder fired, last_completed should be ACKNOWLEDGED.
assert seen_at["rec"] == AzazelStage.ACKNOWLEDGED.value
def test_list_in_flight_returns_partial_disposals(tmp_in_flight):
"""If we manually persist a partial state (simulating a crash),
list_in_flight surfaces it for resume."""
# Manually create an in-flight file at the CONFESSED checkpoint.
_persist_checkpoint(
tmp_in_flight,
"case-partial-001",
AzazelStage.CONFESSED,
{"shaliach": "aharon"},
)
inflight = list_in_flight(tmp_in_flight)
assert len(inflight) == 1
assert inflight[0]["case_ref"] == "case-partial-001"
assert inflight[0]["last_completed"] == "confessed"
def test_list_in_flight_empty_when_no_disposals(tmp_in_flight):
assert list_in_flight(tmp_in_flight) == []
def test_checkpoint_persistence_is_atomic(active_handler, tmp_in_flight):
"""The persist path uses write-then-rename so a crash mid-write
leaves the previous checkpoint intact rather than a half-written
file. We can't easily simulate a crash, but we CAN verify the
final file is valid JSON at every checkpoint visit."""
files_seen = []
def at_recorded(record):
# Inspect the current file
if (tmp_in_flight / "case-atom-001.json").exists():
text = (tmp_in_flight / "case-atom-001.json").read_text()
files_seen.append(json.loads(text)) # raises if invalid
dispatch_for_disposal(
_make_input(case_ref="case-atom-001"),
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
recorder=at_recorded,
)
assert len(files_seen) == 1
assert files_seen[0]["last_completed"] == "acknowledged"
# ============================================================
# Role gate — property 4 still enforced
# ============================================================
def test_dispatch_refuses_inactive_handler(tmp_in_flight):
h = IshItti("aharon")
# Not activated
with pytest.raises(ShaliachError):
dispatch_for_disposal(
_make_input(),
handler=h,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
def test_dispatch_refuses_non_ish_itti(tmp_in_flight):
with pytest.raises(ShaliachError):
dispatch_for_disposal(
_make_input(),
handler="not-a-handler", # type: ignore[arg-type]
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
# ============================================================
# Filename safety
# ============================================================
def test_dispatch_rejects_case_ref_with_slash(active_handler, tmp_in_flight):
bad = AzazelInput(
case_ref="../escape/attempt",
payload={},
classification="empty_vital",
)
with pytest.raises(ValueError):
dispatch_for_disposal(
bad,
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
def test_dispatch_rejects_case_ref_with_nul(active_handler, tmp_in_flight):
bad = AzazelInput(
case_ref="case\0nul",
payload={},
classification="empty_vital",
)
with pytest.raises(ValueError):
dispatch_for_disposal(
bad,
handler=active_handler,
confess_with=_vidui(),
authorized_by="kohen-gadol",
in_flight_dir=tmp_in_flight,
)
# ============================================================
# AzazelRecord validation
# ============================================================
def test_record_requires_authorized_by():
with pytest.raises(ValueError):
AzazelRecord(
case_ref="x",
vidui=_vidui(),
routing=ERETZ_GEZERAH,
authorized_by="",
shaliach="aharon",
)
def test_record_requires_shaliach():
with pytest.raises(ValueError):
AzazelRecord(
case_ref="x",
vidui=_vidui(),
routing=ERETZ_GEZERAH,
authorized_by="kohen-gadol",
shaliach="",
)
def test_record_to_storage_dict_omits_payload():
"""The serialization for L3 must not contain a payload field."""
record = AzazelRecord(
case_ref="case-storage-001",
vidui=_vidui(),
routing=ERETZ_GEZERAH,
authorized_by="kohen-gadol",
shaliach="aharon",
)
body = record.to_storage_dict()
assert "payload" not in body
assert "raw_payload" not in body
assert body["routing"] == "eretz_gezerah"
assert body["authorized_by"] == "kohen-gadol"
assert body["shaliach"] == "aharon"
def test_record_is_frozen():
record = AzazelRecord(
case_ref="case-frozen-001",
vidui=_vidui(),
routing=ERETZ_GEZERAH,
authorized_by="kohen-gadol",
shaliach="aharon",
)
with pytest.raises((AttributeError, Exception)):
record.shaliach = "different" # type: ignore[misc]