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:
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
|
Azazel (disposal). The two channels are provisioned at parity, from
|
||||||
the same pool, with assignment blind to the operator.
|
the same pool, with assignment blind to the operator.
|
||||||
|
|
||||||
Build state: commit 4 — goral added. See BUILD_AZAZEL.md for the
|
Build state: commit 5 — orchestrator state machine added. See
|
||||||
full 10-commit staging plan and the 12-property → code mapping from
|
BUILD_AZAZEL.md for the full 10-commit staging plan and the
|
||||||
azazel_engineering_reading.md §14.
|
12-property → code mapping from azazel_engineering_reading.md §14.
|
||||||
|
|
||||||
Properties defended so far:
|
Properties defended so far:
|
||||||
- Property 2 (blind routing / goral) — cast_lot
|
- Property 2 (blind routing / goral) — cast_lot
|
||||||
- Property 3 (enumerative commit) — Vidui + confess()
|
- Property 3 (enumerative commit) — Vidui + confess()
|
||||||
- Property 4 (role-separated operator) — IshItti + tevilah
|
- 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 11 (classification discipline) — AzazelStage*
|
||||||
- Property 12 (unmanaged destination) — ERETZ_GEZERAH
|
- Property 12 (unmanaged destination) — ERETZ_GEZERAH
|
||||||
|
|
||||||
@@ -27,12 +32,23 @@ Public surface:
|
|||||||
IshItti, ShaliachError, assert_shaliach_active — disposal handler
|
IshItti, ShaliachError, assert_shaliach_active — disposal handler
|
||||||
GoralOutcome, cast_lot, cast_lot_outcome_id,
|
GoralOutcome, cast_lot, cast_lot_outcome_id,
|
||||||
GoralError — blind routing
|
GoralError — blind routing
|
||||||
|
AzazelInput, AzazelRecord — orchestrator data types
|
||||||
|
dispatch_for_disposal, list_in_flight — orchestrator API
|
||||||
|
|
||||||
Pending in subsequent commits:
|
Pending in subsequent commits:
|
||||||
azazel orchestrator (commit 5 — full state machine)
|
|
||||||
protocol envelope (commit 6 — protocol-restricted access)
|
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.eretz_gezerah import ERETZ_GEZERAH, is_eretz_gezerah
|
||||||
from azazel_processor.goral import (
|
from azazel_processor.goral import (
|
||||||
GoralError,
|
GoralError,
|
||||||
@@ -76,4 +92,9 @@ __all__ = [
|
|||||||
"cast_lot",
|
"cast_lot",
|
||||||
"cast_lot_outcome_id",
|
"cast_lot_outcome_id",
|
||||||
"GoralError",
|
"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(),
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
Reference in new issue
Block a user