diff --git a/docs/bridge-cutover-sealed-runtimes.md b/docs/bridge-cutover-sealed-runtimes.md index 7b2a5afbf4c..25a00cf2ed9 100644 --- a/docs/bridge-cutover-sealed-runtimes.md +++ b/docs/bridge-cutover-sealed-runtimes.md @@ -168,6 +168,12 @@ Both transactions revalidate the quiet point, release bindings, regular-file ide That final compare-and-swap is performed after the staged object itself has been rehashed. A live symlink, regular file, registry, or adoption target that changes after the quiet-point scan is never overwritten. +The worker-state gate loads the candidate API through bundle validation, which transitively re-runs the sealed-adoption assessment; provision verification, finalize, and rollback identity attribution all take that path, while the snapshot step revalidates only the cutover quiet point. +The sealed-adoption plan itself stays strict: it accepts live state only at the exact initial or sealed identities. +Once the normal cutover is fully applied and the post-install irreversible boundary is marked, bundle validation instead accepts exactly one further live state - a fully sealed adoption journal, the full quiet-point contract, intact retained sealed release trees, and every adoption-managed path, including the live registry, at its exact cutover-new identity pinned from the reconstructed cutover manifest - reported as the `runtime-switched` phase. +Any other combination, including a post-cutover registry without the marked boundary or with any drifted path, refuses exactly as before. +This pinned post-cutover acceptance exists because the documented order crosses the runtime switch before worker-state verification, so verification, finalize, and rollback attribution must remain provable on the switched machine. + ## Exact worker topology The registry contains eight explicit profiles, but only six profiles are Fleet workers. diff --git a/tests/fm-account-routing.test.sh b/tests/fm-account-routing.test.sh index 5f3d237a5fd..2111e86b15e 100755 --- a/tests/fm-account-routing.test.sh +++ b/tests/fm-account-routing.test.sh @@ -5078,7 +5078,8 @@ test_agent_fleet_lifecycle_calls_are_bounded() { if out=$(FM_FAKE_AF_SELECT_SLEEP=10 FM_ACCOUNT_CONTROL_TIMEOUT=1 run_spawn "$id" "$PROJ_DIR" --account-pool claude-crew); then status=0; else status=$?; fi elapsed=$(( $(date +%s) - started )) [ "$status" -eq 0 ] || fail "timed-out lease choice was not reconciled through recovery: $out" - [ "$elapsed" -lt 5 ] || fail "lease choice timeout was not bounded (elapsed ${elapsed}s)" + # Semantic: returns well under the 10s unbounded fake sleep; 8s absorbs full-sweep scheduling load (5s flaked in-sweep while passing standalone). + [ "$elapsed" -lt 8 ] || fail "lease choice timeout was not bounded (elapsed ${elapsed}s)" assert_grep 'lease recover ' "$AF_LOG" "timed-out lease choice did not reconcile ownership" rm -f "$CASE_DIR/endpoint-live" @@ -5087,7 +5088,8 @@ test_agent_fleet_lifecycle_calls_are_bounded() { if out=$(FM_FAKE_AF_RELEASE_SLEEP=10 FM_ACCOUNT_CONTROL_TIMEOUT=1 run_teardown "$id" --force 2>&1); then status=0; else status=$?; fi elapsed=$(( $(date +%s) - started )) [ "$status" -ne 0 ] || fail "ambiguous timed-out lease release unexpectedly completed teardown" - [ "$elapsed" -lt 5 ] || fail "lease release timeout was not bounded (elapsed ${elapsed}s)" + # Semantic: returns well under the 10s unbounded fake sleep; 8s absorbs full-sweep scheduling load (5s flaked in-sweep while passing standalone). + [ "$elapsed" -lt 8 ] || fail "lease release timeout was not bounded (elapsed ${elapsed}s)" assert_present "$HOME_DIR/state/$id.meta" "ambiguous lease release discarded retry metadata" pass "Agent Fleet lease mutations are bounded and ambiguous outcomes retain ownership state" } diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index c434477765b..72c3912839d 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -762,6 +762,7 @@ jq() { } SH out=$(PATH="$fakebin:$BASE_PATH" BASH_ENV="$bash_env" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_ACCOUNT_ROUTING_TEST_LAB=firstmate-account-routing-test-lab-v1 \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") assert_contains "$out" 'MISSING_MANUAL: agent-fleet (instructions: https://github.com/ruby-dlee/firstmate/blob/main/docs/configuration.md#agent-fleet-account-routing)' "enforce mode did not report manual Agent Fleet installation" assert_contains "$out" 'MISSING: jq (install: brew install jq # or the platform' "enforce mode did not report missing jq" @@ -774,6 +775,7 @@ SH add_real_jq "$fakebin" rm -f "$fakebin/agent-fleet" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_ACCOUNT_ROUTING_TEST_LAB=firstmate-account-routing-test-lab-v1 \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") assert_contains "$out" 'MISSING_MANUAL: agent-fleet (instructions: https://github.com/ruby-dlee/firstmate/blob/main/docs/configuration.md#agent-fleet-account-routing)' "account-routed dispatch profile did not report manual Agent Fleet installation" assert_contains "$out" 'CREW_DISPATCH: active config/crew-dispatch.json' "account dependency preflight suppressed dispatch validation" diff --git a/tests/test_bridge_sealed_adoption.py b/tests/test_bridge_sealed_adoption.py index e1760aa2d11..b1b89759a82 100644 --- a/tests/test_bridge_sealed_adoption.py +++ b/tests/test_bridge_sealed_adoption.py @@ -1,6 +1,8 @@ from __future__ import annotations +import contextlib import hashlib +import io import json import os import sys @@ -550,6 +552,40 @@ def quiet_then_change(value: adoption.Manifest) -> None: adoption.apply(manifest) self.assertEqual(fixture.registry.read_bytes(), racer) + def test_post_cutover_hint_is_cli_only_and_never_in_the_refusal(self) -> None: + fixture = Fixture() + self.addCleanup(fixture.close) + fixture.registry.write_bytes(b'version = 1\nmode = "post-cutover"\n') + fixture.registry.chmod(0o600) + + with self.assertRaises(adoption.AdoptionError) as caught: + adoption.plan(fixture.load()) + refusal = str(caught.exception) + self.assertIn(adoption.UNKNOWN_LIVE_REGISTRY_REFUSAL, refusal) + self.assertNotIn(adoption.POST_CUTOVER_CLI_HINT, refusal) + self.assertNotIn("prepare_bridge_cutover.py", refusal) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = adoption.main([str(fixture.manifest_path)]) + printed = stderr.getvalue() + self.assertEqual(code, 2) + self.assertIn(f"refused: {refusal}", printed) + self.assertIn(adoption.POST_CUTOVER_CLI_HINT, printed) + + def test_post_cutover_hint_is_not_printed_for_unrelated_refusals(self) -> None: + fixture = Fixture() + self.addCleanup(fixture.close) + fixture.backend.write_text("zellij\n", encoding="utf-8") + fixture.backend.chmod(0o600) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = adoption.main([str(fixture.manifest_path)]) + printed = stderr.getvalue() + self.assertEqual(code, 2) + self.assertNotIn(adoption.POST_CUTOVER_CLI_HINT, printed) + def test_link_change_at_exchange_syscall_is_swapped_back_and_preserved(self) -> None: fixture = Fixture() self.addCleanup(fixture.close) diff --git a/tests/test_bridge_worker_state_transaction.py b/tests/test_bridge_worker_state_transaction.py index 2d482027d4e..243f2ea333b 100644 --- a/tests/test_bridge_worker_state_transaction.py +++ b/tests/test_bridge_worker_state_transaction.py @@ -20,6 +20,7 @@ import bridge_worker_state_transaction as worker_state # noqa: E402 from tests.test_prepare_bridge_cutover import ( # noqa: E402 + DRIVER, CutoverPreparationFixture, prepare, ) @@ -151,6 +152,63 @@ def test_snapshot_verify_and_finalize_exact_six_workers(self) -> None: self.assertFalse(cleaned["snapshot_bytes_present"]) self.assertFalse(self.manifest.snapshot_path.exists()) + def _apply_runtime_switch(self) -> None: + driver = prepare._load_driver(DRIVER) + manifest_path = self.fixture.bundle_dir / "cutover.manifest.json" + driver.execute(driver.load_manifest(manifest_path), "forward") + driver.mark_post_install_irreversible_boundary( + driver.load_manifest(manifest_path) + ) + + def test_verify_and_finalize_pass_after_runtime_switch(self) -> None: + """Regression: the 2026-07-19 live 6b NO-GO. + + The documented cutover order applies the runtime switch (step 4) and + marks the post-install boundary (step 5) before worker-state + verification (6b) and finalize (6c). Every pre-existing test ran + worker-state at the pre-switch baseline, so the sealed-adoption quiet + point refusing the legitimately advanced live registry was first + observed on the real machine. This test runs the exact live order. + """ + + self._apply_runtime_switch() + worker_state.begin(self.manifest) + self._materialize_provisioned_workers() + verified = worker_state.verify_provisioned(self.manifest) + self.assertEqual(verified["phase"], "provision_verified") + self.assertTrue(verified["rollback_available"]) + self._write_identity_bundles() + finalized = worker_state.finalize(self.manifest) + self.assertEqual(finalized["phase"], "complete") + self.assertTrue(finalized["worker_state_ready"]) + + def test_rollback_attributes_identity_drift_after_runtime_switch(self) -> None: + """Regression: the armed rollback trap behind the 2026-07-19 NO-GO. + + Once a provider identity bundle exists and drifts from the snapshot, + ``rollback`` must attribute the drift through ``_candidate_api`` - the + exact loader the sealed-adoption quiet point was refusing post-switch. + Rollback must keep working in that state, not die at the moment + identity state is what needs restoring. + """ + + self._apply_runtime_switch() + self._write_identity_bundles() + worker_state.begin(self.manifest) + self._materialize_provisioned_workers() + self.manifest.identity_bundles["codex"].write_text( + '{"schema":1,"provider":"codex","fresh":true}', encoding="utf-8" + ) + rolled = worker_state.rollback(self.manifest) + self.assertEqual(rolled["phase"], "rolled_back") + # Attributed drift admits the rollback, which restores the snapshot. + self.assertEqual( + json.loads( + self.manifest.identity_bundles["codex"].read_text(encoding="utf-8") + ), + {"schema": 1, "provider": "codex"}, + ) + def test_restartable_rollback_restores_exact_absence(self) -> None: worker_state.begin(self.manifest) self._materialize_provisioned_workers() diff --git a/tests/test_prepare_bridge_cutover.py b/tests/test_prepare_bridge_cutover.py index f0eb624b7c8..a840322ce63 100644 --- a/tests/test_prepare_bridge_cutover.py +++ b/tests/test_prepare_bridge_cutover.py @@ -2169,6 +2169,99 @@ def test_bundle_becomes_runtime_switch_ready_only_after_sealed_adoption(self) -> self.assertTrue(result["runtime_switch_ready"]) self.assertFalse(result["cutover_ready"]) + def _apply_runtime_switch( + self, mark_boundary: bool = True + ) -> tuple[types.ModuleType, Path]: + bundle = json.loads( + (self.fixture.bundle_dir / "bundle.json").read_text(encoding="utf-8") + ) + adoption_driver = prepare._load_adoption_driver() + adoption_driver.apply( + adoption_driver.load_manifest(Path(bundle["adoption_manifest_path"])) + ) + driver = prepare._load_driver(DRIVER) + manifest_path = Path(bundle["manifest_path"]) + driver.execute(driver.load_manifest(manifest_path), "forward") + if mark_boundary: + driver.mark_post_install_irreversible_boundary( + driver.load_manifest(manifest_path) + ) + return driver, manifest_path + + def test_validate_accepts_post_cutover_state_only_with_boundary_marked(self) -> None: + self.fixture.prepare() + driver, manifest_path = self._apply_runtime_switch(mark_boundary=False) + with self.assertRaisesRegex( + prepare.PreparationError, + "sealed-adoption state is invalid: .*" + r"\(post-cutover probe: main cutover is not fully applied past the " + "marked post-install irreversible boundary\\)", + ): + prepare.validate_bundle(self.fixture.bundle_dir / "bundle.json", DRIVER) + + driver.mark_post_install_irreversible_boundary( + driver.load_manifest(manifest_path) + ) + result = prepare.validate_bundle(self.fixture.bundle_dir / "bundle.json", DRIVER) + + self.assertEqual(result["cutover_phase"], "runtime-switched") + self.assertFalse(result["runtime_switch_ready"]) + self.assertFalse(result["cutover_ready"]) + # The accepted live-registry set stays pinned to exactly the three + # bundle-recorded identities; post-cutover the live file is the new one. + self.assertEqual( + prepare._sha256(self.fixture.live), result["new_registry_sha256"] + ) + + def test_validate_refuses_tampered_live_registry_after_runtime_switch(self) -> None: + self.fixture.prepare() + self._apply_runtime_switch() + payload = self.fixture.live.read_bytes() + self.fixture.live.write_bytes(payload + b"# drift\n") + self.fixture.live.chmod(0o600) + with self.assertRaisesRegex(prepare.PreparationError, "unknown SHA-256"): + prepare.validate_bundle(self.fixture.bundle_dir / "bundle.json", DRIVER) + + def test_validate_refuses_candidate_registry_before_adoption(self) -> None: + self.fixture.prepare() + self.fixture.live.write_bytes( + (self.fixture.bundle_dir / "registry.new.toml").read_bytes() + ) + self.fixture.live.chmod(0o600) + with self.assertRaisesRegex(prepare.PreparationError, "unknown SHA-256"): + prepare.validate_bundle(self.fixture.bundle_dir / "bundle.json", DRIVER) + + def test_validate_refuses_partially_reverted_state_after_runtime_switch(self) -> None: + self.fixture.prepare() + self._apply_runtime_switch() + current = self.fixture.agent_root / "current" + os.unlink(current) + os.symlink("releases/0.1.5-old", current) + with self.assertRaisesRegex( + prepare.PreparationError, + "sealed-adoption state is invalid: .*" + r"\(post-cutover probe: observed old/new states are not a valid " + "transaction prefix", + ): + prepare.validate_bundle(self.fixture.bundle_dir / "bundle.json", DRIVER) + + def test_post_cutover_plan_refuses_registry_pin_without_sha256(self) -> None: + self.fixture.prepare() + driver, manifest_path = self._apply_runtime_switch() + bundle = json.loads( + (self.fixture.bundle_dir / "bundle.json").read_text(encoding="utf-8") + ) + adoption_driver = prepare._load_adoption_driver() + loaded_adoption = adoption_driver.load_manifest( + Path(bundle["adoption_manifest_path"]) + ) + pins = prepare._post_cutover_pins(driver, driver.load_manifest(manifest_path)) + pins[str(loaded_adoption.registry_operation.path)] = {"kind": "file"} + with self.assertRaisesRegex( + adoption_driver.AdoptionError, "requires a registry file pin" + ): + adoption_driver.post_cutover_plan(loaded_adoption, pins) + def test_validate_refuses_extra_trusted_project(self) -> None: self.fixture.prepare() extra = self.fixture.root / "other-project" diff --git a/tools/bridge-cutover/bridge_sealed_adoption.py b/tools/bridge-cutover/bridge_sealed_adoption.py index 15fdfb000ce..a8d9c8421ca 100644 --- a/tools/bridge-cutover/bridge_sealed_adoption.py +++ b/tools/bridge-cutover/bridge_sealed_adoption.py @@ -35,6 +35,11 @@ MAX_MANIFEST_BYTES = 1_000_000 MAX_JOURNAL_BYTES = 1_000_000 EXPECTED_OPERATION_NAMES = ("quota-current", "agent-fleet-current") +UNKNOWN_LIVE_REGISTRY_REFUSAL = "live registry has unknown SHA-256: " +POST_CUTOVER_CLI_HINT = ( + "if the normal cutover has fully applied, validate through the bundle: " + "prepare_bridge_cutover.py validate accepts the pinned post-cutover state" +) class AdoptionError(RuntimeError): @@ -681,14 +686,19 @@ def load_manifest(path_value: str | os.PathLike[str]) -> Manifest: ) -def _validate_disabled_registry(manifest: Manifest) -> str: +def _validate_disabled_registry( + manifest: Manifest, post_cutover_sha256: str | None = None +) -> str: operation = manifest.registry_operation payload = _read_stable_bytes( operation.path, "live adoption registry", operation.mode ) digest = hashlib.sha256(payload).hexdigest() - if digest not in {operation.initial_sha256, operation.sealed_sha256}: - raise AdoptionError(f"live registry has unknown SHA-256: {digest}") + accepted = {operation.initial_sha256, operation.sealed_sha256} + if post_cutover_sha256 is not None: + accepted.add(post_cutover_sha256) + if digest not in accepted: + raise AdoptionError(f"{UNKNOWN_LIVE_REGISTRY_REFUSAL}{digest}") try: raw = tomllib.loads(payload.decode("utf-8")) except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: @@ -720,9 +730,13 @@ def _validate_disabled_registry(manifest: Manifest) -> str: return digest -def _validate_quiet_point(manifest: Manifest) -> None: +def _validate_quiet_point( + manifest: Manifest, post_cutover_registry_sha256: str | None = None +) -> str: quiet = manifest.quiet_point - _validate_disabled_registry(manifest) + registry_digest = _validate_disabled_registry( + manifest, post_cutover_sha256=post_cutover_registry_sha256 + ) backend_payload = _read_stable_bytes(quiet.backend_path, "backend selector") backend_digest = hashlib.sha256(backend_payload).hexdigest() if backend_digest != quiet.backend_sha256: @@ -827,6 +841,7 @@ def _validate_quiet_point(manifest: Manifest) -> None: raise AdoptionError( f"Fleet process token is active at quiet point: {token}: pid {pid}" ) + return registry_digest def _observe_operation(operation: Operation) -> str: @@ -1398,6 +1413,94 @@ def plan(manifest: Manifest) -> dict[str, Any]: } +def _observe_post_cutover_operation( + operation: Operation, pin: Mapping[str, str] +) -> None: + if isinstance(operation, SealedLinkOperation): + if pin.get("kind") != "link" or not pin.get("target"): + raise AdoptionError( + f"post-cutover pin for {operation.name} is not a link pin" + ) + try: + info = os.lstat(operation.path) + except FileNotFoundError as exc: + raise AdoptionError(f"adoption link is missing: {operation.path}") from exc + if not stat.S_ISLNK(info.st_mode) or os.readlink(operation.path) != pin["target"]: + raise AdoptionError( + f"{operation.name} is not at the exact pinned post-cutover target" + ) + return + label = ( + "post-cutover Agent Fleet front door" + if isinstance(operation, FrontDoorOperation) + else "post-cutover live registry" + ) + if pin.get("kind") != "file" or not pin.get("sha256"): + raise AdoptionError( + f"post-cutover pin for {operation.path} is not a file pin" + ) + digest = _sha256(operation.path, label, operation.mode) + if digest != pin["sha256"]: + raise AdoptionError( + f"{label} is not at the exact pinned post-cutover SHA-256: {digest}" + ) + + +def post_cutover_plan( + manifest: Manifest, pins: Mapping[str, Mapping[str, str]] +) -> dict[str, Any]: + """Assess a sealed adoption superseded by the fully applied main cutover. + + The strict ``plan`` accepts live state only at the exact initial or sealed + identities, so it refuses once the normal cutover has legitimately moved + every adoption-managed path to its candidate identity. This assessment + accepts exactly one further state, pinned by the caller from the exact + cutover manifest: a fully sealed adoption journal, the full quiet-point + contract with the live registry at the pinned candidate SHA-256, intact + retained sealed release trees, and every adoption-managed live path at its + exact pinned post-cutover identity. Anything else refuses. It never + mutates state and is never a substitute for adoption-time validation. + """ + + with _lock(manifest): + registry_pin = pins.get(str(manifest.registry_operation.path)) + if ( + not isinstance(registry_pin, Mapping) + or registry_pin.get("kind") != "file" + or not registry_pin.get("sha256") + ): + raise AdoptionError("post-cutover assessment requires a registry file pin") + pinned_registry_sha256 = str(registry_pin["sha256"]) + registry_digest = _validate_quiet_point( + manifest, post_cutover_registry_sha256=pinned_registry_sha256 + ) + if registry_digest != pinned_registry_sha256: + raise AdoptionError( + "post-cutover live registry is not at the exact pinned " + f"post-cutover SHA-256: {registry_digest}" + ) + journal = _load_journal(manifest) + if not (journal and journal.get("sealed")): + raise AdoptionError( + "post-cutover assessment requires a fully sealed adoption journal" + ) + for operation in manifest.link_operations: + _validate_sealed_operation(operation) + for operation in manifest.operations: + pin = pins.get(str(operation.path)) + if not isinstance(pin, Mapping): + raise AdoptionError( + f"no post-cutover pin for adoption path: {operation.path}" + ) + _observe_post_cutover_operation(operation, pin) + return { + "mode": "post-cutover-plan", + "transaction_id": manifest.transaction_id, + "sealed": True, + "superseded_by_cutover": True, + } + + def recover( manifest: Manifest, boundaries: BoundaryController | None = None, @@ -1504,6 +1607,13 @@ def main(argv: Sequence[str] | None = None) -> int: return 75 except (AdoptionError, OSError) as exc: print(f"refused: {exc}", file=sys.stderr) + if ( + not args.apply + and not args.recover + and isinstance(exc, AdoptionError) + and UNKNOWN_LIVE_REGISTRY_REFUSAL in str(exc) + ): + print(POST_CUTOVER_CLI_HINT, file=sys.stderr) return 2 diff --git a/tools/bridge-cutover/prepare_bridge_cutover.py b/tools/bridge-cutover/prepare_bridge_cutover.py index 0069ec0632c..f5602aee656 100644 --- a/tools/bridge-cutover/prepare_bridge_cutover.py +++ b/tools/bridge-cutover/prepare_bridge_cutover.py @@ -5447,26 +5447,34 @@ def validate_bundle(bundle_path: Path, driver_path: Path) -> dict[str, Any]: adoption_driver = _load_adoption_driver() try: loaded_adoption = adoption_driver.load_manifest(adoption_path) - adoption_plan = adoption_driver.plan(loaded_adoption) except adoption_driver.AdoptionError as exc: raise PreparationError(f"sealed-adoption state is invalid: {exc}") from exc - adoption_prefix = adoption_plan.get("sealed_prefix") - adoption_sealed = adoption_plan.get("sealed") is True - if adoption_prefix == 0 and not adoption_sealed: - cutover_phase = "sealed-adoption-pending" - elif adoption_prefix == 4 and adoption_sealed: - planned = driver.plan(loaded) - if _plan_prefix(planned) != 0 or planned.get( - "post_install_irreversible_boundary" - ): + try: + adoption_plan = adoption_driver.plan(loaded_adoption) + except adoption_driver.AdoptionError as exc: + cutover_phase = _superseded_adoption_phase( + driver, loaded, adoption_driver, loaded_adoption, exc + ) + else: + adoption_prefix = adoption_plan.get("sealed_prefix") + adoption_sealed = adoption_plan.get("sealed") is True + if adoption_prefix == 0 and not adoption_sealed: + cutover_phase = "sealed-adoption-pending" + elif adoption_prefix == 4 and adoption_sealed: + planned = driver.plan(loaded) + if _plan_prefix(planned) != 0 or planned.get( + "post_install_irreversible_boundary" + ): + raise PreparationError( + "main cutover is not at the exact sealed rollback baseline" + ) + cutover_phase = "runtime-switch-ready" + elif adoption_plan.get("recovery_required"): + cutover_phase = "sealed-adoption-recovery-required" + else: raise PreparationError( - "main cutover is not at the exact sealed rollback baseline" + "sealed-adoption state/journal combination is invalid" ) - cutover_phase = "runtime-switch-ready" - elif adoption_plan.get("recovery_required"): - cutover_phase = "sealed-adoption-recovery-required" - else: - raise PreparationError("sealed-adoption state/journal combination is invalid") expected_topology = _topology_summary() if bundle["topology"] != expected_topology: raise PreparationError("bundle topology summary is not exact") @@ -5645,6 +5653,61 @@ def _assert_prefix(driver: ModuleType, manifest_path: Path, expected: int) -> No ) +def _post_cutover_pins(driver: ModuleType, loaded: Any) -> dict[str, dict[str, str]]: + pins: dict[str, dict[str, str]] = {} + for operation in loaded.operations: + if isinstance(operation, driver.SymlinkOperation): + pins[str(operation.path)] = { + "kind": "link", + "target": operation.new_target, + } + else: + pins[str(operation.path)] = { + "kind": "file", + "sha256": operation.new_sha256, + } + return pins + + +def _superseded_adoption_phase( + driver: ModuleType, + loaded: Any, + adoption_driver: ModuleType, + loaded_adoption: Any, + refusal: Exception, +) -> str: + """Resolve the one accepted post-cutover phase after a strict adoption refusal. + + The sealed-adoption plan intentionally accepts live state only at its exact + initial or sealed identities, so it refuses once the normal cutover has + legitimately advanced the machine. That refusal stands unless the main + cutover is provably complete - every operation applied and the post-install + irreversible boundary marked - and the sealed adoption passes the pinned + post-cutover assessment against the exact cutover-new identities. + """ + + invalid = f"sealed-adoption state is invalid: {refusal}" + try: + planned = driver.plan(loaded) + fully_applied = _plan_prefix(planned) == len(planned["states"]) + except (driver.CutoverError, PreparationError) as exc: + raise PreparationError(f"{invalid} (post-cutover probe: {exc})") from refusal + if not fully_applied or not planned.get("post_install_irreversible_boundary"): + raise PreparationError( + f"{invalid} (post-cutover probe: main cutover is not fully applied " + "past the marked post-install irreversible boundary)" + ) from refusal + try: + adoption_driver.post_cutover_plan( + loaded_adoption, _post_cutover_pins(driver, loaded) + ) + except adoption_driver.AdoptionError as exc: + raise PreparationError( + f"sealed-adoption state is invalid post-cutover: {exc}" + ) from exc + return "runtime-switched" + + def _plan_prefix(result: Mapping[str, Any]) -> int: states = result.get("states") if not isinstance(states, list) or not states: