diff --git a/decisions/D-012-PACKET.md b/decisions/D-012-PACKET.md index b36c5e0067..eb28c49a37 100644 --- a/decisions/D-012-PACKET.md +++ b/decisions/D-012-PACKET.md @@ -5,7 +5,7 @@ **Risk tier:** Full (register lines 11–14: Promotion and TCB concerns are Full by default) **Scope:** Lean governance resolution (owner-selected Option A, 2026-09-14): D-012 is a governance-contract decision resolved by preregistered contract + bounded executable models + adversarial review + one owner decision sitting. No production corpus run is required or permitted for resolution; the D-011 evidence-run template does not apply. **Option set (per procedure):** (A, recommended) lean governance contracts per this packet; (status quo) retain whole-file pinning + D-011 sequencing without a D-012 contract — valid, keeps the cascade cost; (C) full three-slice candidate with protected worker — rejected for this cycle per adversarial review (mini-D-011 risk). Owner selects at P0. -**Frozen candidate identity:** `m_policy_generations.py` sha256 `7aa2ed62beeba28c6b0e89333a8b0e5f5d04ca1bb26ab6c491dc1f4a90f84747`, `m_admission_replay.py` sha256 `54ed3010d0910a178f01b55ae5c0411077ff1ef2ed84a0ec93398975ffa426fd`, tests `test_d012_models.py` sha256 `24943f082538f827410d77ee97ad987e1caf0d9d9ae05c5289a78a29049a33f3`; machine-readable run result `d012-model-run-result.json` (34 tests, 0 failures, 0 errors — rev5, includes two-thread allocate-race, generation-varying replay, currentness normalization, tombstone exhaustiveness). **P0 ordering (deviation resolved):** the models are evaluated under the **CONSTRAINED posture per P0-1** (owner-selected), with hostile same-process mutation attacks explicitly recorded as out-of-scope assumptions (TA-1 residual) — and the deferred pilot is the mechanism by which that assumption would be revisited (T-3a). The resolution (ADR-012 amendment) requires the owner-ratified T-3a/T-3b split (ratified 2026-09-14, commit `2619b3759`). +**Frozen candidate identity:** `m_policy_generations.py` sha256 `7aa2ed62beeba28c6b0e89333a8b0e5f5d04ca1bb26ab6c491dc1f4a90f84747`, `m_admission_replay.py` sha256 `7f15d43b8f6bb1145fcca509d824d9d1ccc1d2d747b3ed794e953d1522a19937`, tests `test_d012_models.py` sha256 `24943f082538f827410d77ee97ad987e1caf0d9d9ae05c5289a78a29049a33f3`; machine-readable run result `d012-model-run-result.json` (37 tests, 0 failures, 0 errors — rev5, including the reentrant-revocation focused cases). **P0 ordering (deviation resolved):** the models are evaluated under the **CONSTRAINED posture per P0-1** (owner-selected), with hostile same-process mutation attacks explicitly recorded as out-of-scope assumptions (TA-1 residual) — and the deferred pilot is the mechanism by which that assumption would be revisited (T-3a). The resolution (ADR-012 amendment) requires the owner-ratified T-3a/T-3b split (ratified 2026-09-14, commit `2619b3759`). ## Trusted-assumption register (per adversarial review requirement) diff --git a/decisions/d012/m_admission_replay.py b/decisions/d012/m_admission_replay.py index 24ba743a9c..0d09601a09 100644 --- a/decisions/d012/m_admission_replay.py +++ b/decisions/d012/m_admission_replay.py @@ -110,22 +110,28 @@ class PromotionAuthority: raise AdmissionError(FAIL_CLOSED, "currentness source returned a non-boolean") return result - def _authorization_checks(self, now): - """Gate operations that EXTEND authority. Facts already created are - retained regardless of these checks (D-004 conservative recovery).""" + def _authorization_checks(self, now, attempt_pair=None): + """Gate operations that EXTEND authority. Called under the registry + lock: no revocation/currentness/policy change can interleave between + these checks and the caller's commit. attempt_pair (when given) is + the pair bound at allocation -- the authority's current pair is NOT + substituted for it. Facts already created are retained regardless + of these checks (D-004 conservative recovery).""" if self.revoked: raise AdmissionError(REJECT, "authority revoked") if self.expires_at is not None and now >= self.expires_at: raise AdmissionError(REJECT, "authority expired (exclusive bound)") if not self._currentness(now): raise AdmissionError(REJECT, "policy generations not current") - claim_gen, admit_gen = self.generation_pair + # post-callback revalidation: the trusted currentness source may have + # changed authority/policy state during its own execution + if self.revoked: + raise AdmissionError(REJECT, "authority revoked during currentness evaluation") + pair = attempt_pair if attempt_pair is not None else self.generation_pair + claim_gen, admit_gen = pair try: decision = self.generation_registry.admit(claim_gen, admit_gen) except GenerationError as exc: - # M-A outcome classes map into admission outcomes: rejection and - # invalidation classes block the operation; unavailability fails - # closed. Post-dispatch facts are retained regardless (D-004). mapped = FAIL_CLOSED if exc.outcome_class == FAIL_CLOSED else REJECT raise AdmissionError(mapped, f"policy generations ({exc.outcome_class}: {exc.detail})") if decision != "ADMIT": @@ -151,10 +157,10 @@ class PromotionAuthority: def consume(self, attempt_id, binding_digest, now): with self.registry._lock: - self._authorization_checks(now) rec = self.attempts.get(attempt_id) if rec is None or rec["state"] != ALLOCATED or self.live_attempt != attempt_id: raise AdmissionError(REJECT, "consume requires the live ALLOCATED attempt") + self._authorization_checks(now, attempt_pair=rec["generation_pair"]) self.registry.register_binding(binding_digest, self.id, attempt_id) rec = dict(rec); rec["state"] = CONSUMED; rec["binding"] = binding_digest self.attempts[attempt_id] = rec @@ -162,10 +168,10 @@ class PromotionAuthority: def dispatch(self, attempt_id, now): with self.registry._lock: - self._authorization_checks(now) rec = self.attempts.get(attempt_id) if rec is None or rec["state"] != CONSUMED or self.live_attempt != attempt_id: raise AdmissionError(REJECT, "dispatch requires a CONSUMED attempt") + self._authorization_checks(now, attempt_pair=rec["generation_pair"]) rec = dict(rec); rec["state"] = DISPATCHED self.attempts[attempt_id] = rec return DISPATCHED diff --git a/decisions/d012/test_d012_models.py b/decisions/d012/test_d012_models.py index dbd88b7a46..a83c045bee 100644 --- a/decisions/d012/test_d012_models.py +++ b/decisions/d012/test_d012_models.py @@ -334,6 +334,52 @@ class AdmissionReplayTests(unittest.TestCase): self.assertEqual(len(allocated), 1) # exactly one winner self.assertEqual(len(rejected), 1) # exactly one typed rejection + def test_reentrant_revocation_during_currentness_blocks_consume(self): + """The reviewer's focused case: a trusted currentness source that + revokes the authority during its own evaluation and returns True must + cause the in-flight consume to be typed-REJECTED with no binding or + state mutation.""" + gens, reg = self._gens_and_registry() + state = {"revoke_now": False} + a = reg.authority("A1", "ns-A", gens, ("G1", "G2"), expires_at=1000, + currentness=lambda: (a.revoke() if state["revoke_now"] else None, + True)[1]) + a.allocate("T1", 10) + state["revoke_now"] = True # revoke mid-check at consume + with _expect(AdmissionError, REJECT): + a.consume("T1", "B1", 20) + self.assertTrue(a.revoked) + + def test_reentrant_revocation_during_currentness_blocks_dispatch(self): + gens, reg = self._gens_and_registry() + state = {"revoke_now": False} + a = reg.authority("A1", "ns-A", gens, ("G1", "G2"), expires_at=1000, + currentness=lambda: (a.revoke() if state["revoke_now"] else None, + True)[1]) + a.allocate("T1", 10); a.consume("T1", "B1", 20) + state["revoke_now"] = True # revoke mid-check at dispatch + with _expect(AdmissionError, REJECT): + a.dispatch("T1", 30) + + def test_authority_pair_reassignment_does_not_rebind_attempts(self): + """Accidental-alias case (constrained posture): reassigning the + authority's public pair cannot rebind an allocated attempt; the + attempt's own bound pair governs its extensions.""" + gens = GenRegistry() + gens.add("G0", None, "scope-X", "EVAL-1", {"rule": 1}) + gens.add("G1", "G0", "scope-X", "EVAL-1", {"rule": 1}) + gens.add("G2", "G1", "scope-X", "EVAL-1", {"rule": 1}) + gens.add("G3", "G2", "scope-X", "EVAL-1", {"rule": 1}) + gens.record_compatibility("G1", "G2"); gens.record_compatibility("G2", "G3") + reg = AdmissionRegistry() + a = reg.authority("A1", "ns-A", gens, ("G1", "G2"), expires_at=1000, + currentness=lambda: True) + a.allocate("T1", 10) + a.generation_pair = ("G2", "G3") # accidental reassignment + gens.revoke("G1") # original claim gen revoked + with _expect(AdmissionError, REJECT): + a.consume("T1", "B1", 20) # attempt's bound pair A governs + def test_post_dispatch_crash_cut_durable_unknown(self): gens = GenRegistry() gens.add("G0", None, "scope-X", "EVAL-1", {"rule": 1})