From 5cf724b4e7606c02f509f261c4b0fd022b3d68a3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:15:43 +0100 Subject: [PATCH] Keep recovery independent of its phase writer interface Exercise direct recovering and terminal OSError injection as well as atomic receipt faults. Preserve the real restored estate and complete watches while evidence loss remains failed and unverified. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 13 ++++++--- .../tests/test_demo_runtime_transaction.py | 29 +++++++++++++++++++ .../subsystems/deployment-installability.md | 3 +- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 04c95f4490..1bec825f82 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -417,11 +417,14 @@ class Transaction: def recovery_save(self, status): """Receipt storage must not gate the already-owned restoration.""" + self.receipt["status"] = status try: self.save(status) - except Failure as error: - if str(error) != "receipt-observation": + except (OSError, Failure) as error: + if isinstance(error, Failure) and str(error) != "receipt-observation": raise + if status not in self.receipt["observation_failures"]: + self.receipt["observation_failures"].append(status) def finish(self, status): """Best-effort failed evidence is not a verified terminal outcome.""" @@ -437,9 +440,11 @@ class Transaction: unverified() try: self.save(self.receipt["status"] if self.receipt["observation_failures"] else status) - except Failure as error: - if str(error) != "receipt-observation": + except (OSError, Failure) as error: + if isinstance(error, Failure) and str(error) != "receipt-observation": raise + if self.receipt["status"] not in self.receipt["observation_failures"]: + self.receipt["observation_failures"].append(self.receipt["status"]) unverified() # One bounded attempt to retain the failure, not to obtain a pass. self.recovery_save("observation_failed") diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 39452b1ece..e249f17e0b 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -318,6 +318,35 @@ class TransactionTest(unittest.TestCase): self.assertEqual(tx.receipt["forward"]["elapsed_seconds"], 50) self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + def test_recovery_survives_direct_phase_and_terminal_writer_oserror(self): + for phase in ("recovering", "rolled_back"): + with self.subTest(phase=phase): + req = request("update"); req["version"] = "v6.4.6" + tx = engine.Transaction(self.host, req, self.attempt) + self.host.transaction = tx; self.host.fail_at = self.host.clock + 55 + self.host.failed = False + original = tx.save + + def write(status): + if status == phase: + raise OSError("private writer error") + original(status) + + with patch.object(tx, "save", side_effect=write): + code = tx.run() + self.assertEqual((code, tx.receipt["status"], tx.receipt["rollback"]), + (2, "observation_failed", "unverified")) + self.assertEqual(engine.estate_hash(self.paths), self.before) + self.assertEqual(tx.receipt["recovery"]["elapsed_seconds"], 300) + self.assertEqual(tx.receipt["failure"], "new-service-crash") + self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + # Keep each independent snapshot/intent; never reuse its path. + self.attempt = self.root / "attempts" / ("b" * 64) + if phase == "recovering": + # The prior unverified estate correctly blocks a new + # request; use an independent fixture for the next fault. + self.doCleanups(); self.setUp() + def test_recovery_terminal_receipt_failure_retains_full_restoration(self): tx = self.run_fault("rolled_back") self.assertEqual(tx.receipt["observed_outcome"], "rolled_back") diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c7189987c1..932168a995 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -39,7 +39,8 @@ mutation. No source-worker production capability, package upgrade, Relay copy activation or credential is added. Receipt I/O failure must not skip the owned stop/restore or shorten either -recovery observation window. Physical restoration and complete observations +recovery observation window, including an OSError at the phase/terminal writer +interface itself. Physical restoration and complete observations remain distinct from retained evidence: any phase/terminal write loss yields failed, unverified `observation_failed` evidence and blocks later mutation, even when the old runtime/data are restored. The capture remains private and