azazel_processor: commit 4 — goral (property 2, blind routing)

Pass 4 / Finding F, commit 4 of 10 from BUILD_AZAZEL.md.

Vayikra 16:8 — וְנָתַן אַהֲרֹן עַל־שְׁנֵי הַשְּׂעִירִם גֹּרָלוֹת,
גּוֹרָל אֶחָד לַה׳ וְגוֹרָל אֶחָד לַעֲזָאזֵל.
And Aharon shall place lots on the two goats — one lot for HaShem,
one lot for Azazel.

Yoma 62a: a kalpi (shuffled vessel), drawn with both hands
simultaneously. The high priest cannot see the lots in advance.
He cannot shape the outcome.

THREAT MODEL:

An operator who, given the choice, would route preferentially —
assigning the "better" goat to processing and the "worse" to
disposal. The specification makes that preference structurally
impossible.

DESIGN (BUILD_AZAZEL.md §6, Q4):

cast_lot(input_id) is DETERMINISTIC per (input_id, today). The
seed is SHA-256 over (L1 integrity hash, today's UTC date,
input_id). The first bit of the digest is the outcome.

Properties guaranteed:
  - Same (input_id, today) → SAME outcome (no shopping by re-roll)
  - Different input_ids same day → outcomes can differ (~50/50)
  - Same input_id across days → outcomes can differ (~50/50)
  - Operators cannot pre-compute without invoking (SHA-256 over
    L1 hash + date is not mentally computable)
  - Seed inherits L1 tamper-resistance: rewriting the daily seed
    requires rewriting L1, which has its own halt rules

The L1 hash mirrored from vav_pipeline/lib/vav_pipeline/l1_reader.ex
(@l1_expected_hash). Both must move together if L1 is rebuilt.

CREATED:

  src/azazel_processor/goral.py          ~155 lines
    GoralOutcome enum                    LA_SHEM | LA_AZAZEL
                                         (both legitimate; the goral
                                         enforces blindness, not a
                                         "good vs. bad" distinction)
    cast_lot(input_id, *, today=None)    deterministic per (id, day)
                                         outcome
    cast_lot_outcome_id(input_id,        full SHA-256 hex digest;
                          *, today=None) used by L3 records (commit 7)
                                         so audit can recompute
    GoralError                           on malformed input

  test/test_goral.py                     17 tests covering:
    - outcome shape (one of two; enum members distinct)
    - same input same day → same outcome (anti-shopping; verified
      across 50 re-invocations + 1000-iteration audit-shopping test)
    - outcome_id is reproducible 64-char hex
    - 100 different input_ids → 100 distinct outcome_ids
    - same input across 30 days → 30 distinct outcome_ids
    - distribution across 10,000 input_ids on one day → ~50/50
      (±5% tolerance, well within SHA-256 binomial expectations)
    - distribution across 365 days for one input → ~50/50 (±10%)
    - input validation (empty, whitespace, None, non-string raise)
    - default today = UTC date
    - audit can recompute outcome_id from (input_id, today) — the
      audit-trail half of property 2

NOTE: when the orchestrator (commit 5) decides WHEN to invoke the
goral. For inputs that are cleanly processable or cleanly
un-processable, the goral is NOT invoked — they go to their channel
deterministically. The goral is the tie-breaker for genuinely
borderline inputs (Q1 in BUILD_AZAZEL.md §6).

Tests: azazel_processor 88 passed (was 71, +17). 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 07:54:26 -04:00
1 parent 85155b23fb
commit e6fee82dd7
3 files changed
+407 -4

No files matched your search

@@ -6,11 +6,12 @@ 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 3 — shaliach + tevilah added. See BUILD_AZAZEL.md Build state: commit 4 — goral added. See BUILD_AZAZEL.md for the
for the full 10-commit staging plan and the 12-property → code full 10-commit staging plan and the 12-property → code mapping from
mapping from azazel_engineering_reading.md §14. azazel_engineering_reading.md §14.
Properties defended so far: Properties defended so far:
- 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 11 (classification discipline) — AzazelStage* - Property 11 (classification discipline) — AzazelStage*
@@ -24,14 +25,21 @@ Public surface:
confess_boundary_violation, confess_boundary_violation,
confess_malformed_input — categorization helpers confess_malformed_input — categorization helpers
IshItti, ShaliachError, assert_shaliach_active — disposal handler IshItti, ShaliachError, assert_shaliach_active — disposal handler
GoralOutcome, cast_lot, cast_lot_outcome_id,
GoralError — blind routing
Pending in subsequent commits: Pending in subsequent commits:
goral (commit 4 — blind routing)
azazel orchestrator (commit 5 — full state machine) azazel orchestrator (commit 5 — full state machine)
protocol envelope (commit 6 — protocol-restricted access) protocol envelope (commit 6 — protocol-restricted access)
""" """
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 (
GoralError,
GoralOutcome,
cast_lot,
cast_lot_outcome_id,
)
from azazel_processor.shaliach import ( from azazel_processor.shaliach import (
IshItti, IshItti,
ShaliachError, ShaliachError,
@@ -64,4 +72,8 @@ __all__ = [
"IshItti", "IshItti",
"ShaliachError", "ShaliachError",
"assert_shaliach_active", "assert_shaliach_active",
"GoralOutcome",
"cast_lot",
"cast_lot_outcome_id",
"GoralError",
] ]
@@ -0,0 +1,179 @@
"""
Goral — the blind routing decision (property 2 of
azazel_engineering_reading.md §14).
Vayikra 16:8 — וְנָתַן אַהֲרֹן עַל־שְׁנֵי הַשְּׂעִירִם גֹּרָלוֹת,
גּוֹרָל אֶחָד לַה׳ וְגוֹרָל אֶחָד לַעֲזָאזֵל.
And Aharon shall place lots on the two goats — one lot for HaShem,
one lot for Azazel.
Yoma 62a specifies a kalpi (shuffled vessel) from which the lots are
drawn. The high priest draws with both hands simultaneously. The lot
drawn by the right hand determines the goat on the right. The priest
cannot see the lots in advance. He cannot shape the outcome.
THREAT MODEL:
An operator who, given the choice, would route preferentially —
assigning the "better" goat to the processing path and the "worse"
one to disposal. The specification makes that preference structurally
impossible. The operator participates in the selection but does not
determine it.
DESIGN DECISION (BUILD_AZAZEL.md §6, Q4):
`cast_lot(input_id)` is DETERMINISTIC per (input_id, today). The seed
combines:
- The L1 integrity hash (the system's cryptographic anchor;
operators cannot rewrite without tampering with L1, which has
its own halt rules)
- Today's UTC date (operators cannot rewrite the calendar)
- The caller-supplied input_id (different inputs get different
outcomes; same input gets the same outcome)
This makes "outcome shopping" structurally impossible: re-invoking
cast_lot with the same input_id on the same day returns the same
result. An operator who dislikes the outcome cannot re-roll. An
operator who wants to compute the outcome in their head before
deciding to invoke cannot — SHA-256 over the (L1_hash, date,
input_id) tuple is not mentally computable.
The same input_id ACROSS DAYS produces different outcomes (because
the daily date component changes). This means an operator who tries
to "wait for tomorrow" loses the per-input determinism (the outcome
genuinely changes day-to-day). They cannot pre-compute, cannot re-roll,
cannot shop.
PARITY WITH the MISHKAN GORAL:
The Mishkan goral has TWO outcomes — la'Shem (processing) or
la'Azazel (disposal). Both routes are legitimate; the goral chooses
which goat goes which way. In Shorashim, the goral applies to
borderline cases (Q1 in BUILD_AZAZEL.md): inputs that COULD be
processed with low confidence OR safely disposed. The threshold
above which inputs are routed deterministically (no goral) is a
configuration choice; below it, the goral fires.
For inputs that are cleanly processable (zerikah returns vital with
high confidence), the goral is NOT invoked — they go to korban
deterministically. For inputs that are cleanly un-processable
(empty-vital, halt-tripping, malformed), the goral is also NOT
invoked — they go to azazel deterministically. The goral is the
tie-breaker for the genuinely-uncertain middle.
This module provides cast_lot(); commit 5 (the orchestrator) decides
WHEN to invoke it.
"""
import hashlib
from datetime import date, datetime, timezone
from enum import Enum
# The L1 integrity hash — Shorashim's cryptographic anchor. The
# value is mirrored from vav_pipeline/lib/vav_pipeline/l1_reader.ex
# (@l1_expected_hash). Both must move together: if L1 is rebuilt and
# the hash changes, this constant updates too. The shared value is
# how the goral's seed inherits L1's tamper-resistance.
_L1_INTEGRITY_HASH = (
"99bbd07a5406e8acfc9d77ace277fe3e3ccee379a7fc4e48c61cc7c4ab7ea377"
)
class GoralError(ValueError):
"""Raised when cast_lot is called with malformed input."""
class GoralOutcome(Enum):
"""The two-goat assignment.
LA_SHEM — process via the Korban path
LA_AZAZEL — dispose via the Azazel path
Both are LEGITIMATE outcomes. The goral is not "good vs. bad";
it is the architectural enforcement that the operator does not
bias which goat goes which way.
"""
LA_SHEM = "la_shem"
LA_AZAZEL = "la_azazel"
def cast_lot(
input_id: str,
*,
today: date | None = None,
) -> GoralOutcome:
"""Cast the goral for a given input.
Returns GoralOutcome.LA_SHEM or GoralOutcome.LA_AZAZEL, determined
by SHA-256 over (L1 integrity hash, today's UTC date, input_id).
The first bit of the digest is the outcome.
Properties guaranteed:
- Deterministic per (input_id, today): no outcome shopping
- Different input_ids same day → outcomes can differ (~50/50)
- Same input_id across days → outcomes can differ (~50/50)
- Operators cannot pre-compute without invoking
- The seed depends on L1 integrity, which operators cannot
rewrite without tampering with the L1 filesystem
Args:
input_id: A stable identifier for the input being routed.
Required, non-empty. Different input_ids must
produce reproducibly different seeds; the caller
should use a content-addressable id (e.g. a hash
of the input payload), NOT a sequential number,
to prevent operators from picking input_ids that
bias toward a desired outcome.
today: Optional UTC date for testing. In production, defaults
to datetime.now(timezone.utc).date().
Raises:
GoralError: if input_id is not a non-empty string.
"""
if not isinstance(input_id, str) or not input_id.strip():
raise GoralError(
"cast_lot requires a non-empty input_id. The Mishkan "
"goral is cast over a goat — there is always something "
"specific being routed; an unlabeled invocation is not "
"a goral."
)
if today is None:
today = datetime.now(timezone.utc).date()
seed_input = f"{_L1_INTEGRITY_HASH}|{today.isoformat()}|{input_id}"
digest = hashlib.sha256(seed_input.encode("utf-8")).digest()
# Use the low bit of the first byte. SHA-256's output is uniformly
# distributed, so the bit is uniformly 0 or 1 across inputs.
if digest[0] & 0x01:
return GoralOutcome.LA_AZAZEL
return GoralOutcome.LA_SHEM
def cast_lot_outcome_id(
input_id: str,
*,
today: date | None = None,
) -> str:
"""Return a stable identifier for the cast lot — the SHA-256
hex digest of the seed. Used by L3 records (commit 7) so the
operator can audit a posteriori which (input_id, day) pair
produced which outcome.
The full digest, not just the bit, so the audit can be verified
by recomputing — proof that the goral was not tampered with.
"""
if not isinstance(input_id, str) or not input_id.strip():
raise GoralError(
"cast_lot_outcome_id requires a non-empty input_id."
)
if today is None:
today = datetime.now(timezone.utc).date()
seed_input = f"{_L1_INTEGRITY_HASH}|{today.isoformat()}|{input_id}"
return hashlib.sha256(seed_input.encode("utf-8")).hexdigest()
+212
View File
@@ -0,0 +1,212 @@
"""
Tests for goral — property 2 (blind routing) of
azazel_engineering_reading.md §14.
The goral protects against operator preference. Properties verified:
- Returns one of two outcomes (la_shem / la_azazel)
- Deterministic per (input_id, today) — no outcome shopping
- Distribution roughly even across many input_ids
- Operator cannot pre-compute without invoking
- Same input_id different days → outcomes can differ
- Empty / non-string input_id refused
"""
from collections import Counter
from datetime import date, timedelta
import pytest
from azazel_processor.goral import (
GoralError,
GoralOutcome,
cast_lot,
cast_lot_outcome_id,
)
# ============================================================
# Outcome shape
# ============================================================
def test_cast_lot_returns_goral_outcome():
result = cast_lot("input-001", today=date(2026, 4, 28))
assert isinstance(result, GoralOutcome)
assert result in (GoralOutcome.LA_SHEM, GoralOutcome.LA_AZAZEL)
def test_la_shem_and_la_azazel_are_distinct():
assert GoralOutcome.LA_SHEM != GoralOutcome.LA_AZAZEL
assert {GoralOutcome.LA_SHEM, GoralOutcome.LA_AZAZEL} == set(GoralOutcome)
# ============================================================
# Determinism — no outcome shopping
# ============================================================
def test_same_input_same_day_yields_same_outcome():
"""The load-bearing anti-shopping property: re-invoking with the
same input_id on the same day MUST return the same outcome.
Without this, an operator who dislikes the result could just
re-roll until they get the desired one."""
today = date(2026, 4, 28)
first = cast_lot("input-001", today=today)
for _ in range(50):
assert cast_lot("input-001", today=today) == first
def test_outcome_id_is_stable_across_invocations():
"""The outcome-id (full SHA-256 digest) must also be reproducible
so audit can re-verify. Property 2's audit-trail half."""
today = date(2026, 4, 28)
first = cast_lot_outcome_id("input-001", today=today)
second = cast_lot_outcome_id("input-001", today=today)
assert first == second
def test_outcome_id_is_64_char_hex():
today = date(2026, 4, 28)
digest = cast_lot_outcome_id("input-001", today=today)
assert len(digest) == 64
assert all(c in "0123456789abcdef" for c in digest)
# ============================================================
# Different input_ids produce different outcomes (different seeds)
# ============================================================
def test_different_input_ids_produce_different_outcome_ids():
today = date(2026, 4, 28)
digests = {cast_lot_outcome_id(f"input-{i:05d}", today=today) for i in range(100)}
# All 100 distinct
assert len(digests) == 100
def test_different_days_produce_different_outcome_ids_for_same_input():
"""The per-day component of the seed: the same input_id on
different days gets DIFFERENT outcome IDs (not necessarily
different outcome BITS, but different digests)."""
digests = {
cast_lot_outcome_id("input-001", today=date(2026, 4, 28) + timedelta(days=i))
for i in range(30)
}
# All 30 days produce distinct digests
assert len(digests) == 30
# ============================================================
# Distribution — over many draws, roughly even
# ============================================================
def test_distribution_is_roughly_even_over_many_input_ids():
"""SHA-256's output is uniformly distributed; the low bit of the
first byte should be ~50/50 across many input_ids on the same
day. Tolerance ±5% (over 10,000 draws this is well within
binomial-distribution expectations)."""
today = date(2026, 4, 28)
counts = Counter(
cast_lot(f"input-{i:08d}", today=today)
for i in range(10_000)
)
la_shem_count = counts[GoralOutcome.LA_SHEM]
la_azazel_count = counts[GoralOutcome.LA_AZAZEL]
assert la_shem_count + la_azazel_count == 10_000
# ±5% tolerance — SHA-256 + 10,000 samples should be FAR within this
assert 4500 <= la_shem_count <= 5500
assert 4500 <= la_azazel_count <= 5500
def test_distribution_across_days_for_one_input_is_roughly_even():
"""Across many days for the same input_id, the bit also tends
50/50 because SHA-256 reseeds with the date component."""
counts = Counter(
cast_lot("input-fixed", today=date(2026, 1, 1) + timedelta(days=i))
for i in range(365)
)
# Tolerance ±10% — fewer samples, wider tolerance
la_shem_count = counts[GoralOutcome.LA_SHEM]
la_azazel_count = counts[GoralOutcome.LA_AZAZEL]
assert la_shem_count + la_azazel_count == 365
assert 145 <= la_shem_count <= 220
assert 145 <= la_azazel_count <= 220
# ============================================================
# Input validation
# ============================================================
def test_empty_input_id_raises():
with pytest.raises(GoralError):
cast_lot("")
def test_whitespace_input_id_raises():
with pytest.raises(GoralError):
cast_lot(" ")
def test_none_input_id_raises():
with pytest.raises(GoralError):
cast_lot(None) # type: ignore[arg-type]
def test_non_string_input_id_raises():
with pytest.raises(GoralError):
cast_lot(42) # type: ignore[arg-type]
def test_outcome_id_validates_input_id_too():
with pytest.raises(GoralError):
cast_lot_outcome_id("")
# ============================================================
# Today defaults to UTC date in production
# ============================================================
def test_today_defaults_to_utc_date():
"""When today is not supplied, cast_lot uses datetime.now(UTC).date().
We verify by calling without `today` — should not raise, should
return a valid outcome."""
result = cast_lot("input-default-today")
assert isinstance(result, GoralOutcome)
# ============================================================
# Anti-shopping integration: an operator cannot search for outcomes
# ============================================================
def test_operator_cannot_iterate_to_find_a_target_outcome_for_same_input():
"""The threat model: an operator wants input-001 to come out
LA_SHEM today. They re-invoke cast_lot many times hoping for a
different result. They cannot — the outcome is fixed by the
seed."""
today = date(2026, 4, 28)
first_result = cast_lot("input-001", today=today)
for _ in range(1000):
assert cast_lot("input-001", today=today) == first_result
def test_audit_can_recompute_the_outcome_from_seed_components():
"""Property 2's audit half: given (input_id, today), anyone can
recompute cast_lot_outcome_id and verify the recorded value
matches. No party — including the operator who invoked the goral
— can claim a different outcome was produced."""
today = date(2026, 4, 28)
invoked_outcome = cast_lot("input-audit-001", today=today)
invoked_outcome_id = cast_lot_outcome_id("input-audit-001", today=today)
# Auditor (some other process) recomputes:
auditor_outcome = cast_lot("input-audit-001", today=today)
auditor_outcome_id = cast_lot_outcome_id("input-audit-001", today=today)
assert auditor_outcome == invoked_outcome
assert auditor_outcome_id == invoked_outcome_id