diff --git a/azazel_processor/src/azazel_processor/__init__.py b/azazel_processor/src/azazel_processor/__init__.py index dc73921d..dbaf029c 100644 --- a/azazel_processor/src/azazel_processor/__init__.py +++ b/azazel_processor/src/azazel_processor/__init__.py @@ -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 the same pool, with assignment blind to the operator. -Build state: commit 3 — shaliach + tevilah 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 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. 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 11 (classification discipline) — AzazelStage* @@ -24,14 +25,21 @@ Public surface: confess_boundary_violation, confess_malformed_input — categorization helpers IshItti, ShaliachError, assert_shaliach_active — disposal handler + GoralOutcome, cast_lot, cast_lot_outcome_id, + GoralError — blind routing Pending in subsequent commits: - goral (commit 4 — blind routing) azazel orchestrator (commit 5 — full state machine) protocol envelope (commit 6 — protocol-restricted access) """ 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 ( IshItti, ShaliachError, @@ -64,4 +72,8 @@ __all__ = [ "IshItti", "ShaliachError", "assert_shaliach_active", + "GoralOutcome", + "cast_lot", + "cast_lot_outcome_id", + "GoralError", ] diff --git a/azazel_processor/src/azazel_processor/goral.py b/azazel_processor/src/azazel_processor/goral.py new file mode 100644 index 00000000..30380e87 --- /dev/null +++ b/azazel_processor/src/azazel_processor/goral.py @@ -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() diff --git a/azazel_processor/test/test_goral.py b/azazel_processor/test/test_goral.py new file mode 100644 index 00000000..476d9bc5 --- /dev/null +++ b/azazel_processor/test/test_goral.py @@ -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