From 59d1441cf8c3953b609161e84c97140739fe8e4f Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 16 Oct 2026 01:51:53 +0200 Subject: [PATCH] fix(execution): retain scoped cleanup after uncertain outcomes Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786 --- docs/task-execution-cleanup.md | 66 ++++++ docs/task-execution-plan-compiler.md | 4 +- scripts/task_execution_cleanup.py | 72 +++++++ scripts/task_execution_journal.py | 104 ++++++++- tests/test_task_execution_cleanup.py | 199 ++++++++++++++++++ tests/test_task_execution_journal.py | 7 +- ...-WP-0035-credential-lane-implementation.md | 14 ++ 7 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 docs/task-execution-cleanup.md create mode 100644 scripts/task_execution_cleanup.py create mode 100644 tests/test_task_execution_cleanup.py diff --git a/docs/task-execution-cleanup.md b/docs/task-execution-cleanup.md new file mode 100644 index 0000000..c21050b --- /dev/null +++ b/docs/task-execution-cleanup.md @@ -0,0 +1,66 @@ +# Durable cleanup obligations for the metadata execution pilot + +RPF-WP-0035-T12 source preparation, 2026-10-16. This source is not an installed +mediator, native execution entitlement, founder approval or owner-native proof. + +Platform's existing admission journal made `unknown` and `failed` terminal and +blocked all further calls. Its single business-state cleanup transition could not +express a timed-out write with confirmed token revocation, or a verified write +with unconfirmed cleanup. A future mediator using that interface could suppress +cleanup precisely when needed. The journal now retains the original business +outcome and records cleanup in a separate durable table. + +At fresh reservation the trusted owner supplies at most one commitment each for +`task-token-revoke`, `identity-token-revoke`, and `grant-policy-delete`. Each is a +kind and exact native-call digest; no token/accessor, payload, path override or +shell command is accepted. Obligations and admission are inserted in one SQLite +transaction with FULL synchronous commits and directory fsync. Existing journal +files gain the separate table; old admissions gain no obligations or authority. +Empty cleanup evidence cannot produce a confirmed result or legacy success state. + +Only the same live exclusive invocation that transitioned this binding through +`consume-confirmed` can record cleanup intent. Neither a caller string nor journal +observation establishes consume authority: the trusted mediator must independently +validate the real served approval/PDP/consume before that transition. Cleanup can +proceed when the business call is pending or state is unknown/failed. Starting it +permanently closes business dispatch for that admission. An exact obligation moves +`owed -> pending -> confirmed|unconfirmed`; no retry transition exists. A crash +leaves pending, and reopening never restores admission. Pending business-call +uncertainty remains even if every cleanup succeeds. + +`task_execution_cleanup.run_cleanup` is the fixed caller of those journal methods. +It is intended for the owner mediator's `finally` block inside the original +`Journal.locked()` context. It dispatches only the three named owner cleanup +methods, passing their admitted digest for independent native-call comparison. +Ordinary exceptions/malformed evidence become closed unconfirmed reason evidence +without logging or hashing raw backend text. Remaining independent obligations +continue. A process death or BaseException leaves pending intent and requires +owner reconciliation. A journal write failure prevents the corresponding effect. +The component has no HTTP client, CLI command execution or native credential opener. + +The owner adapter must retain the exact handles in private memory, implement +bounded native calls, compare each reconstructed cleanup call against the admitted +commitment, and return confirmed only after the appropriate native revoke/absence +verification. A timeout, policy deletion alone, or eventual TTL expiry cannot prove +issued-token revocation. Cleanup has the original separate obligation rather than +the expired business deadline; this component does not extend any business grant. +No automatic postcrash recovery is implemented: owner-private recovery requires +fresh scoped authority and must never retry a business effect. + +The current Compiler Plan does not yet contain these cleanup commitments. A +production mediator must bind them into the exact reviewed grant/full canonical +request/owner plan before reservation. The journal's digest parameter does not +verify that correspondence. It must also integrate authenticated initiating actor, +served claim/PDP and independently verified atomic consume, exact source/body and +baseline/readback validation, ephemeral owner-held token materialization, per-call +trusted-time/use enforcement, and native audit/recovery. There is no production +executor in this repository to wire to this driver today; existing production +readiness remains refused. Approval CAS alone cannot provide exactly-once target +effects across process/network failure. T12 and SECRETS-WP-0013 remain open. + +Validation uses actual SQLite/process locks and abrupt `os._exit` before and after +a separately fsynced synthetic fixture effect. Reopened processes cannot resume +cleanup or business work; a four-process race admits one driver. Other tests cover +pending business calls, revoke timeout without suppressing later cleanup, changed +commitments and bindings, malformed confirmations and empty-evidence refusal. +These are disposable local fixtures, not OpenBao or production acceptance. diff --git a/docs/task-execution-plan-compiler.md b/docs/task-execution-plan-compiler.md index 32c440c..9bb49f7 100644 --- a/docs/task-execution-plan-compiler.md +++ b/docs/task-execution-plan-compiler.md @@ -94,7 +94,9 @@ cleanup use; this component records limits, it does not enforce native expiry/us or provide a hard elapsed timer. Trusted owner runtime must bound parser/import/ filesystem/compiler work by remaining action budget before any actual call. Token custody/revoke, temporary object removal, owner-private recovery after unknown -and truthful native readback remain gates. General lifecycle/apply/exec/default +and truthful native readback remain gates. The 2026-10-16 source cleanup journal +and fixed driver are documented in task-execution-cleanup.md; they preserve unknown +outcomes but are not yet bound into this compiler or a production mediator. General lifecycle/apply/exec/default migration and SecretsT06/RPF35T02/T12 cannot close from this source component. Validation:45 compiler tests and 15 journal tests pass, covering real owner K8s/OIDC body generation, diff --git a/scripts/task_execution_cleanup.py b/scripts/task_execution_cleanup.py new file mode 100644 index 0000000..c865c41 --- /dev/null +++ b/scripts/task_execution_cleanup.py @@ -0,0 +1,72 @@ +"""Fixed cleanup driver for a trusted owner mediator; no native transport exists here. + +Call from the mediator's finally block while its original Journal.locked context +is still live. The owner adapter retains credential/handle custody and reconstructs +and compares the native call with the expected digest before effect. Its three +methods take no caller-selected path, body, shell command or token. This source +component neither validates consume nor creates a native execution entitlement. +""" +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import re +from typing import Protocol + +from task_execution_journal import Binding, Cleanup, Journal, Refused + + +@dataclass(frozen=True) +class CleanupResult: + confirmed: bool + evidence_digest: str + + def validate(self): + if type(self.confirmed) is not bool or type(self.evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", self.evidence_digest): + raise Refused("invalid owner cleanup evidence") + + +class OwnerCleanup(Protocol): + # Confirm requires actual native revoke/absence evidence; a timeout is never + # proof of absence. Implementations must use bounded calls and discard raw + # backend failure output. Protected handles never leave the owner adapter. + def revoke_task_token(self, expected_call_digest: str) -> CleanupResult: ... + def revoke_identity_token(self, expected_call_digest: str) -> CleanupResult: ... + def delete_grant_policy(self, expected_call_digest: str) -> CleanupResult: ... + + +def run_cleanup(journal: Journal, binding: Binding, owner: OwnerCleanup): + """Attempt each fixed obligation once, preserving business/unknown outcomes. + + Ordinary adapter failures record unconfirmed evidence and do not suppress the + remaining independent cleanup. BaseException/process death leaves durable + pending intent; neither this driver nor a reopened journal retries it. + Journal failures propagate and prohibit dispatch without durable intent. + """ + obligations = journal.cleanup_status(binding)['obligations'] + if not obligations: + raise Refused("explicit owner cleanup obligations required") + if any(item['outcome'] != 'owed' for item in obligations): + raise Refused("cleanup already attempted; owner reconciliation required") + for item in obligations: + obligation = Cleanup(item['kind'], item['call_digest']) + journal.before_cleanup(binding, item['ordinal'], obligation) + try: + if obligation.kind == 'task-token-revoke': + result = owner.revoke_task_token(obligation.call_digest) + elif obligation.kind == 'identity-token-revoke': + result = owner.revoke_identity_token(obligation.call_digest) + elif obligation.kind == 'grant-policy-delete': + result = owner.delete_grant_policy(obligation.call_digest) + else: + raise Refused("unsupported cleanup obligation") + if type(result) is not CleanupResult: + raise Refused("typed owner cleanup evidence required") + result.validate() + except Exception: + # Closed metadata only. Never hash/log a backend exception, response, + # token, accessor or secret-bearing stdout/stderr. + result = CleanupResult(False, hashlib.sha256(b'owner-cleanup-unconfirmed/v1').hexdigest()) + journal.after_cleanup(binding, item['ordinal'], obligation, + confirmed=result.confirmed, evidence_digest=result.evidence_digest) + return journal.cleanup_status(binding) diff --git a/scripts/task_execution_journal.py b/scripts/task_execution_journal.py index 4bff83c..7c54ac8 100644 --- a/scripts/task_execution_journal.py +++ b/scripts/task_execution_journal.py @@ -57,6 +57,26 @@ _NEXT = { } +@dataclass(frozen=True) +class Cleanup: + """Owner-derived exact call commitment, never a handle or authority token. + + The owner mediator must include these commitments in its approved plan before + reservation, then compare the actual native cleanup call immediately before + dispatch. This journal does not authenticate that plan or native adapter. + """ + kind: str + call_digest: str + + def validate(self): + if type(self.kind) is not str or self.kind not in { + "task-token-revoke", "identity-token-revoke", "grant-policy-delete" + }: + raise Refused("unsupported cleanup obligation") + if type(self.call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", self.call_digest): + raise Refused("invalid cleanup call digest") + + class Journal: def __init__(self, directory: Path): # Require an existing owner-private directory: deployment creates it. @@ -82,8 +102,10 @@ class Journal: self.db.execute("PRAGMA journal_mode=DELETE") self.db.execute("CREATE TABLE IF NOT EXISTS operations (approval TEXT PRIMARY KEY, request TEXT NOT NULL, operation TEXT UNIQUE NOT NULL, plan TEXT NOT NULL, state TEXT NOT NULL, evidence TEXT)") self.db.execute("CREATE TABLE IF NOT EXISTS calls (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal))") + self.db.execute("CREATE TABLE IF NOT EXISTS cleanup (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal), UNIQUE(operation,kind))") self._held = False self._admission = None + self._consumed_admission = None self._sync_directory() @staticmethod @@ -123,11 +145,20 @@ class Journal: yield self finally: self._admission = None + self._consumed_admission = None self._held = False fcntl.flock(self._lock_fd, fcntl.LOCK_UN) - def reserve(self, binding: Binding): + def reserve(self, binding: Binding, cleanup: tuple[Cleanup, ...] = ()): binding.validate() + if type(cleanup) is not tuple or len(cleanup) > 3: + raise Refused("closed cleanup plan required") + for item in cleanup: + if type(item) is not Cleanup: + raise Refused("typed cleanup obligation required") + item.validate() + if len({item.kind for item in cleanup}) != len(cleanup): + raise Refused("duplicate cleanup obligation") if not self._held or self._admission is not None: raise Refused("reservation requires fresh exclusive invocation") try: @@ -135,6 +166,9 @@ class Journal: self.db.execute("INSERT INTO operations VALUES (?,?,?,?,?,NULL)", (binding.approval_id, binding.request_digest, binding.operation_id, binding.plan_digest, "reserved")) + self.db.executemany("INSERT INTO cleanup VALUES (?,?,?,?,'owed',NULL)", + [(binding.operation_id, i, item.kind, item.call_digest) + for i, item in enumerate(cleanup)]) self.db.execute("COMMIT") self._sync_directory() except sqlite3.IntegrityError: @@ -158,6 +192,13 @@ class Journal: try: self.db.execute("BEGIN IMMEDIATE") pending=self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'",(binding.operation_id,)).fetchone()[0] + started = self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'", (binding.operation_id,)).fetchone()[0] + if started and target not in {'unknown', 'cleanup-confirmed'}: + raise Refused("cleanup has closed business execution") + if target == 'cleanup-confirmed': + total, owed = self.db.execute("SELECT COUNT(*),COALESCE(SUM(outcome!='confirmed'),0) FROM cleanup WHERE operation=?", (binding.operation_id,)).fetchone() + if not total or owed: + raise Refused("explicit cleanup obligations are unconfirmed") if pending and target != 'unknown': raise Refused("unconfirmed external call requires unknown") changed = self.db.execute( @@ -168,6 +209,8 @@ class Journal: raise Refused("journal state changed") self.db.execute("COMMIT") self._sync_directory() + if target == "consume-confirmed": + self._consumed_admission = binding except BaseException: if self.db.in_transaction: self.db.execute("ROLLBACK") @@ -182,6 +225,7 @@ class Journal: raise Refused("journal binding mismatch") # Readback cannot create a live admission, even for an interrupted state. pending = self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0] + pending += self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0] return {"state": row[3], "evidence_digest": row[4], "dispatch_allowed": False, "external_outcome_unknown": bool(pending)} @@ -201,7 +245,8 @@ class Journal: self.db.execute("BEGIN IMMEDIATE") count,pending = self.db.execute("SELECT COUNT(*), COALESCE(SUM(outcome='pending'),0) FROM calls WHERE operation=?",(binding.operation_id,)).fetchone() state=self.db.execute("SELECT state FROM operations WHERE operation=?",(binding.operation_id,)).fetchone()[0] - if ordinal != count or pending or state in {'failed','unknown','cleanup-confirmed'}: + cleanup_started = self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'", (binding.operation_id,)).fetchone()[0] + if cleanup_started or ordinal != count or pending or state in {'failed','unknown','cleanup-confirmed'}: raise Refused("call is replayed, uncertain or terminal") self.db.execute("INSERT INTO calls VALUES (?,?,?,'pending',NULL)",(binding.operation_id,ordinal,call_digest)) self.db.execute("COMMIT") @@ -229,6 +274,61 @@ class Journal: if self.db.in_transaction: self.db.execute("ROLLBACK") raise + def cleanup_status(self, binding: Binding): + # Observation never restores live execution or cleanup authority. + if self.observe(binding) is None: + raise Refused("operation is not admitted") + rows = self.db.execute("SELECT ordinal,kind,call_digest,outcome,evidence FROM cleanup WHERE operation=? ORDER BY ordinal", (binding.operation_id,)).fetchall() + return {"confirmed": bool(rows) and all(row[3] == "confirmed" for row in rows), + "obligations": [{"ordinal": row[0], "kind": row[1], "call_digest": row[2], + "outcome": row[3], "evidence_digest": row[4]} for row in rows]} + + def _cleanup_admission(self, binding, ordinal, obligation): + if not self._held or self._admission != binding or self._consumed_admission != binding: + raise Refused("cleanup requires same live consumed admission") + if type(ordinal) is not int or not 0 <= ordinal < 3 or type(obligation) is not Cleanup: + raise Refused("exact cleanup obligation required") + obligation.validate() + + def before_cleanup(self, binding: Binding, ordinal: int, obligation: Cleanup): + """Commit intent for one pre-admitted cleanup call, even after unknown. + + Never resumes an interrupted invocation. Independent owner recovery after + a crash still requires the owner's protected recovery channel/approval. + Cleanup has no business deadline: its authority is the original separate + explicit obligation, and cannot authorize a read, repair or business write. + """ + self._cleanup_admission(binding, ordinal, obligation) + try: + self.db.execute("BEGIN IMMEDIATE") + changed = self.db.execute("UPDATE cleanup SET outcome='pending' WHERE operation=? AND ordinal=? AND kind=? AND call_digest=? AND outcome='owed'", + (binding.operation_id, ordinal, obligation.kind, obligation.call_digest)).rowcount + if changed != 1: + raise Refused("cleanup is unadmitted, replayed or uncertain") + self.db.execute("COMMIT") + self._sync_directory() + except BaseException: + if self.db.in_transaction: self.db.execute("ROLLBACK") + raise + + def after_cleanup(self, binding: Binding, ordinal: int, obligation: Cleanup, + *, confirmed: bool, evidence_digest: str): + self._cleanup_admission(binding, ordinal, obligation) + if type(confirmed) is not bool or type(evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", evidence_digest): + raise Refused("closed cleanup evidence required") + try: + self.db.execute("BEGIN IMMEDIATE") + changed = self.db.execute("UPDATE cleanup SET outcome=?, evidence=? WHERE operation=? AND ordinal=? AND kind=? AND call_digest=? AND outcome='pending'", + ('confirmed' if confirmed else 'unconfirmed', evidence_digest, + binding.operation_id, ordinal, obligation.kind, obligation.call_digest)).rowcount + if changed != 1: + raise Refused("cleanup confirmation mismatch") + self.db.execute("COMMIT") + self._sync_directory() + except BaseException: + if self.db.in_transaction: self.db.execute("ROLLBACK") + raise + def close(self): if hasattr(self, "db"): self.db.close() diff --git a/tests/test_task_execution_cleanup.py b/tests/test_task_execution_cleanup.py new file mode 100644 index 0000000..348d4b8 --- /dev/null +++ b/tests/test_task_execution_cleanup.py @@ -0,0 +1,199 @@ +"""Disposable local SQLite/owner-adapter fixtures. No OpenBao or credential access.""" +import multiprocessing +import os +from pathlib import Path +import sys +import tempfile +import unittest +import uuid + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +from task_execution_journal import Binding, Cleanup, Journal, Refused +from task_execution_cleanup import CleanupResult, run_cleanup + +OBLIGATIONS = (Cleanup('task-token-revoke', '1'*64), + Cleanup('identity-token-revoke', '2'*64), + Cleanup('grant-policy-delete', '3'*64)) + + +class OwnerFixture: + def __init__(self, fail=None): self.calls = []; self.fail = fail + def call(self, kind, digest): + self.calls.append((kind, digest)) + if kind == self.fail: raise TimeoutError('SYNTHETIC-PRIVATE-BACKEND-OUTPUT') + return CleanupResult(True, 'e'*64) + def revoke_task_token(self, digest): return self.call('task-token-revoke', digest) + def revoke_identity_token(self, digest): return self.call('identity-token-revoke', digest) + def delete_grant_policy(self, digest): return self.call('grant-policy-delete', digest) + + +def crash_worker(directory, binding, after_effect): + journal = Journal(Path(directory)) + with journal.locked(): + journal.reserve(binding, OBLIGATIONS) + journal.transition(binding, 'reserved', 'consume-confirmed') + journal.before_cleanup(binding, 0, OBLIGATIONS[0]) + if after_effect: + # A real durable synthetic effect, separate from the admission DB. + fd = os.open(Path(directory)/'synthetic-effect', os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600) + os.write(fd, b'one fixture revocation\n'); os.fsync(fd); os.close(fd) + os._exit(23) + + +def cleanup_racer(directory, binding, gate, results): + journal = Journal(Path(directory)) + gate.wait() + try: + with journal.locked(): + journal.reserve(binding, OBLIGATIONS) + journal.transition(binding, 'reserved', 'consume-confirmed') + run_cleanup(journal, binding, OwnerFixture()) + results.put('admitted') + except Refused: results.put('refused') + finally: journal.close() + + +class Tests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) + self.binding = Binding('approval-cleanup', 'sha256:'+'a'*64, str(uuid.uuid4()), 'b'*64) + self.journal = Journal(self.path); self.addCleanup(self.journal.close) + + def consumed(self): + self.journal.reserve(self.binding, OBLIGATIONS) + self.journal.transition(self.binding, 'reserved', 'consume-confirmed') + + def test_timeout_business_call_keeps_unknown_while_all_cleanup_runs(self): + with self.journal.locked(): + self.consumed() + self.journal.before_call(self.binding, 0, 'd'*64) + self.journal.transition(self.binding, 'consume-confirmed', 'unknown') + owner = OwnerFixture() + status = run_cleanup(self.journal, self.binding, owner) + self.assertTrue(status['confirmed']) + self.assertEqual(owner.calls, [(c.kind,c.call_digest) for c in OBLIGATIONS]) + self.assertEqual(self.journal.observe(self.binding)['state'], 'unknown') + self.assertTrue(self.journal.observe(self.binding)['external_outcome_unknown']) + with self.assertRaises(Refused): self.journal.before_call(self.binding, 1, 'f'*64) + + def test_failed_business_outcome_preserved(self): + with self.journal.locked(): + self.consumed(); self.journal.transition(self.binding,'consume-confirmed','failed') + self.assertTrue(run_cleanup(self.journal,self.binding,OwnerFixture())['confirmed']) + self.assertEqual(self.journal.observe(self.binding)['state'], 'failed') + + def test_one_revoke_failure_never_passes_but_independent_cleanup_continues(self): + with self.journal.locked(): + self.consumed(); owner = OwnerFixture('task-token-revoke') + result = run_cleanup(self.journal,self.binding,owner) + self.assertFalse(result['confirmed']); self.assertEqual(len(owner.calls),3) + self.assertEqual([r['outcome'] for r in result['obligations']], ['unconfirmed','confirmed','confirmed']) + self.assertNotIn('SYNTHETIC-PRIVATE', str(result)) + with self.assertRaises(Refused): run_cleanup(self.journal,self.binding,owner) + self.assertEqual(len(owner.calls),3) + self.assertNotIn(b'SYNTHETIC-PRIVATE', (self.path/'journal.sqlite').read_bytes()) + + def test_cleanup_closes_business_before_native_attempt(self): + with self.journal.locked(): + self.consumed(); self.journal.before_cleanup(self.binding,0,OBLIGATIONS[0]) + with self.assertRaises(Refused): self.journal.before_call(self.binding,0,'d'*64) + with self.assertRaises(Refused): self.journal.transition(self.binding,'consume-confirmed','authenticating') + + def test_no_preconsume_cleanup_even_when_unknown(self): + with self.journal.locked(): + self.journal.reserve(self.binding,OBLIGATIONS) + self.journal.transition(self.binding,'reserved','unknown') + owner = OwnerFixture() + with self.assertRaises(Refused): run_cleanup(self.journal,self.binding,owner) + self.assertEqual(owner.calls,[]) + + def test_changed_kind_digest_ordinal_or_binding_never_dispatches(self): + with self.journal.locked(): + self.consumed() + for ordinal,item in [(False,OBLIGATIONS[0]),(3,OBLIGATIONS[0]),(0,Cleanup('task-token-revoke','9'*64)),(0,Cleanup('kv-delete','1'*64)),(0,{'kind':'task-token-revoke'})]: + with self.subTest(ordinal=ordinal,item=item): + with self.assertRaises(Refused): self.journal.before_cleanup(self.binding,ordinal,item) + changed = Binding('another',self.binding.request_digest,self.binding.operation_id,self.binding.plan_digest) + with self.assertRaises(Refused): self.journal.before_cleanup(changed,0,OBLIGATIONS[0]) + self.assertTrue(all(x['outcome']=='owed' for x in self.journal.cleanup_status(self.binding)['obligations'])) + + def test_cleanup_plan_must_be_closed_before_reservation(self): + for plan in [list(OBLIGATIONS), (OBLIGATIONS[0],OBLIGATIONS[0]), (Cleanup('kv-delete','1'*64),), (Cleanup('task-token-revoke',False),)]: + with self.journal.locked(): + with self.assertRaises(Refused): self.journal.reserve(self.binding,plan) + self.assertIsNone(self.journal.observe(self.binding)) + + def test_crash_before_and_after_real_synthetic_effect_does_not_resume(self): + context = multiprocessing.get_context('fork') + for after_effect in (False, True): + with tempfile.TemporaryDirectory() as directory: + worker = context.Process(target=crash_worker,args=(directory,self.binding,after_effect)) + worker.start(); worker.join(10) + if worker.is_alive(): worker.terminate(); worker.join(); self.fail('crash fixture exceeded bound') + self.assertEqual(worker.exitcode,23) + reopened = Journal(Path(directory)) + try: + self.assertEqual(reopened.cleanup_status(self.binding)['obligations'][0]['outcome'],'pending') + self.assertTrue(reopened.observe(self.binding)['external_outcome_unknown']) + owner = OwnerFixture() + with reopened.locked(): + with self.assertRaises(Refused): reopened.reserve(self.binding,OBLIGATIONS) + with self.assertRaises(Refused): run_cleanup(reopened,self.binding,owner) + with self.assertRaises(Refused): reopened.before_cleanup(self.binding,1,OBLIGATIONS[1]) + self.assertEqual(owner.calls,[]) + effect = Path(directory)/'synthetic-effect' + self.assertEqual(effect.read_bytes() if effect.exists() else b'',b'one fixture revocation\n' if after_effect else b'') + self.assertFalse(reopened.cleanup_status(self.binding)['confirmed']) + finally: reopened.close() + + def test_actual_process_race_admits_one_cleanup_driver(self): + context = multiprocessing.get_context('fork'); gate = context.Event(); results = context.Queue() + workers = [context.Process(target=cleanup_racer,args=(str(self.path),self.binding,gate,results)) for _ in range(4)] + for worker in workers: worker.start() + gate.set() + for worker in workers: + worker.join(10) + if worker.is_alive(): worker.terminate(); worker.join(); self.fail('race exceeded bound') + self.assertEqual(worker.exitcode,0) + self.assertEqual(sorted(results.get(timeout=2) for _ in workers),['admitted','refused','refused','refused']) + self.assertTrue(self.journal.cleanup_status(self.binding)['confirmed']) + + def test_empty_or_malformed_native_result_never_proves_cleanup(self): + with self.journal.locked(): + self.journal.reserve(self.binding) + self.journal.transition(self.binding,'reserved','consume-confirmed') + with self.assertRaises(Refused): run_cleanup(self.journal,self.binding,OwnerFixture()) + self.assertFalse(self.journal.cleanup_status(self.binding)['confirmed']) + + def test_bad_native_confirmation_still_attempts_remaining_cleanup(self): + class BadOwner(OwnerFixture): + def revoke_task_token(self, digest): + self.calls.append(('task-token-revoke',digest)) + return CleanupResult('true','e'*64) + with self.journal.locked(): + self.consumed(); owner=BadOwner() + self.assertFalse(run_cleanup(self.journal,self.binding,owner)['confirmed']) + self.assertEqual(len(owner.calls),3) + + def test_confirmation_exact_types_and_no_duplicate_receipts(self): + with self.journal.locked(): + self.consumed(); self.journal.before_cleanup(self.binding,0,OBLIGATIONS[0]) + for confirmed,digest in [('true','e'*64),(1,'e'*64),(True,None),(True,'E'*64)]: + with self.assertRaises(Refused): self.journal.after_cleanup(self.binding,0,OBLIGATIONS[0],confirmed=confirmed,evidence_digest=digest) + self.journal.after_cleanup(self.binding,0,OBLIGATIONS[0],confirmed=True,evidence_digest='e'*64) + with self.assertRaises(Refused): self.journal.after_cleanup(self.binding,0,OBLIGATIONS[0],confirmed=True,evidence_digest='e'*64) + with self.assertRaises(Refused): self.journal.before_cleanup(self.binding,0,OBLIGATIONS[0]) + + def test_cleanup_state_is_independent_of_legacy_business_transition(self): + with self.journal.locked(): + self.consumed() + states=['consume-confirmed','authenticating','grant-issuing','grant-issued','applying','readback','verified'] + for before,after in zip(states,states[1:]): self.journal.transition(self.binding,before,after) + with self.assertRaises(Refused): self.journal.transition(self.binding,'verified','cleanup-confirmed','e'*64) + run_cleanup(self.journal,self.binding,OwnerFixture()) + self.journal.transition(self.binding,'verified','cleanup-confirmed','e'*64) + self.assertEqual(self.journal.observe(self.binding)['state'],'cleanup-confirmed') + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_task_execution_journal.py b/tests/test_task_execution_journal.py index cb9874d..b1a76fc 100644 --- a/tests/test_task_execution_journal.py +++ b/tests/test_task_execution_journal.py @@ -12,6 +12,7 @@ module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) Binding, Journal, Refused = module.Binding, module.Journal, module.Refused +Cleanup = module.Cleanup def reserve_worker(directory, binding, gate, results): @@ -74,8 +75,12 @@ class Tests(unittest.TestCase): def test_full_path_cleanup_and_terminal_replay(self): states=['reserved','consume-confirmed','authenticating','grant-issuing','grant-issued','applying','readback','verified','cleanup-confirmed'] with self.journal.locked(): - self.journal.reserve(self.binding) + cleanup = Cleanup('task-token-revoke','d'*64) + self.journal.reserve(self.binding,(cleanup,)) for before,after in zip(states,states[1:]): + if after == 'cleanup-confirmed': + self.journal.before_cleanup(self.binding,0,cleanup) + self.journal.after_cleanup(self.binding,0,cleanup,confirmed=True,evidence_digest='c'*64) self.journal.transition(self.binding,before,after,'c'*64) with self.assertRaises(Refused): self.journal.transition(self.binding,'cleanup-confirmed','applying') self.assertEqual(self.journal.observe(self.binding),{'state':'cleanup-confirmed','evidence_digest':'c'*64,'dispatch_allowed':False,'external_outcome_unknown':False}) diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index ebd890f..269ad08 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -1225,3 +1225,17 @@ this change needs its own integration review. T12 retains all existing full contract, identity/consume, dispatch, custody and native acceptance requirements. RPF-IN-0012 remains open for the independent contract/wrapped-delivery return; this code correction does not authorize either operation. + +2026-10-16 T12 source preparation (isolated owner checkout; not owner delivery): +added durable precommitted cleanup obligations and separate outcome evidence to +the existing Journal, plus a fixed three-method owner cleanup driver. Same live +postconsume admission can clean after a business timeout/failed/unknown outcome; +cleanup closes business dispatch and cannot replay or restore admission after a +crash. Empty obligations no longer produce cleanup-confirmed. Real SQLite +multiprocess races and abrupt process death before/after a separately durable +synthetic effect verify refusal on reopen. Current compiler/journal/cleanup suite: +73 passed, local source/fixture only. Details: docs/task-execution-cleanup.md. +No native adapter or production mediator exists; full grant/request/cleanup binding, +authenticated consume/identity, ephemeral token issuance, deadline/use checks, +native effect/revoke/absence, audit and protected recovery remain prerequisites. +T12 remains progress. No CCR38 default/entitlement or live grant was changed.