From 86dd2f943bc5c88ce7d734033543e95b812c9a5b Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 11 Aug 2026 19:02:01 +0100 Subject: [PATCH] Make program-changing updates atomic --- .agents/skills/boatstack-autoplan/SKILL.md | 6 +- .agents/skills/boatstack-run/SKILL.md | 6 +- .agents/skills/boatstack-update/SKILL.md | 19 +- .claude/skills/boatstack-autoplan/SKILL.md | 6 +- .claude/skills/boatstack-run/SKILL.md | 6 +- .claude/skills/boatstack-update/SKILL.md | 19 +- .cursor/commands/boatstack-autoplan.md | 6 +- .cursor/commands/boatstack-run.md | 6 +- .cursor/commands/boatstack-update.md | 19 +- .gemini/skills/boatstack-autoplan/SKILL.md | 6 +- .gemini/skills/boatstack-run/SKILL.md | 6 +- .gemini/skills/boatstack-update/SKILL.md | 19 +- .github/tests/test_repository_contract.py | 174 +++- README.md | 7 +- boatstack/cmd/boatstack-helper/main.go | 58 +- boatstack/control/control_test.go | 4 +- boatstack/core/system_test.go | 2 +- boatstack/core/transitions.json | 122 ++- boatstack/examples/control_program_test.go | 2 +- boatstack/flow/standard/completeness_test.go | 2 +- boatstack/internal/effects/artifacts.go | 2 +- boatstack/internal/effects/driver.go | 85 +- boatstack/internal/effects/host_skills.go | 15 +- .../internal/effects/host_skills_test.go | 6 + .../internal/effects/integration_test.go | 37 +- boatstack/internal/effects/io.go | 29 + boatstack/internal/effects/journal.go | 23 +- boatstack/internal/effects/locker.go | 28 +- boatstack/internal/effects/locker_test.go | 37 + boatstack/internal/effects/prepared.go | 8 +- boatstack/internal/effects/prepared_test.go | 30 + boatstack/internal/effects/recovery.go | 19 +- boatstack/internal/effects/state_reducer.go | 17 +- boatstack/internal/kernel/durable/state.go | 9 + .../internal/kernel/engine/engine_test.go | 32 + boatstack/internal/kernel/model/health.go | 15 + boatstack/internal/kernel/ports/ports.go | 2 + .../internal/kernel/protocol/admission.go | 72 +- .../kernel/protocol/program_change.go | 18 + .../kernel/protocol/program_change_test.go | 26 + boatstack/internal/kernel/protocol/receipt.go | 76 +- .../internal/kernel/supervisor/supervisor.go | 17 +- boatstack/internal/plant/observer.go | 59 +- boatstack/internal/surfaces/protocol.go | 12 + boatstack/kernel.go | 35 +- boatstack/sdk/sdk.go | 1 + docs/architecture/boatstack-v2-kernel.md | 27 +- .../boatstack-v2-locus-liveness.json | 744 +++++++++++++++++- .../boatstack-v2-locus-safety.json | 744 +++++++++++++++++- .../boatstack-v2-transition-catalog.md | 67 +- .../boatstack-v2-transition-catalog.mmd | 262 +++--- docs/generated-files.md | 2 +- docs/public-claims.json | 2 +- install.ps1 | 10 +- install.sh | 17 +- ...11-atomic-program-update-reconciliation.md | 3 + 56 files changed, 2725 insertions(+), 358 deletions(-) create mode 100644 boatstack/internal/kernel/model/health.go create mode 100644 boatstack/internal/kernel/protocol/program_change.go create mode 100644 boatstack/internal/kernel/protocol/program_change_test.go create mode 100644 release-notes/2026-08-11-atomic-program-update-reconciliation.md diff --git a/.agents/skills/boatstack-autoplan/SKILL.md b/.agents/skills/boatstack-autoplan/SKILL.md index 2573814..e80e461 100644 --- a/.agents/skills/boatstack-autoplan/SKILL.md +++ b/.agents/skills/boatstack-autoplan/SKILL.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.agents/skills/boatstack-run/SKILL.md b/.agents/skills/boatstack-run/SKILL.md index 09a0a22..10358c0 100644 --- a/.agents/skills/boatstack-run/SKILL.md +++ b/.agents/skills/boatstack-run/SKILL.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.agents/skills/boatstack-update/SKILL.md b/.agents/skills/boatstack-update/SKILL.md index fba8993..d5e337e 100644 --- a/.agents/skills/boatstack-update/SKILL.md +++ b/.agents/skills/boatstack-update/SKILL.md @@ -5,7 +5,7 @@ description: Apply a checksum-verified Boatstack update. Use only when the user # Boatstack Update -Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. +Select `installation.update` or, after exact human acceptance of program drift, `installation.reconcile-update`. This trigger does not reclassify or advance a product delivery. Run `boatstack status --repo . --format json` once for observation. An authority-free `FRONTIER` from status is diagnostic only and cannot terminate this selected operation. @@ -21,8 +21,21 @@ For this operation, request only checksum-verified installation authority. Do no request or materialize repository, provider, publication, product-delivery, or merge authority. Installation receipts cannot be reused to broaden this scope. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +If the candidate reports exact compiled-program drift, preserve the healthy old +launcher and present the prior program fingerprint, candidate program +fingerprint, and program-delta fingerprint. Do not accept the delta implicitly. +After explicit human acceptance, rerun the same checksum-bound update with +`--accept-program-change` so the Kernel uses the single atomic +`installation.reconcile-update` boundary. If the update has an interrupted local +transaction and `recovery.rollback` is permitted, carry the same human authority +through that rollback, preserve its complete receipt, and retry once from the +restored healthy old state. Never acquire repository authority to escape an +update recovery frontier. + +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.claude/skills/boatstack-autoplan/SKILL.md b/.claude/skills/boatstack-autoplan/SKILL.md index 2573814..e80e461 100644 --- a/.claude/skills/boatstack-autoplan/SKILL.md +++ b/.claude/skills/boatstack-autoplan/SKILL.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.claude/skills/boatstack-run/SKILL.md b/.claude/skills/boatstack-run/SKILL.md index 09a0a22..10358c0 100644 --- a/.claude/skills/boatstack-run/SKILL.md +++ b/.claude/skills/boatstack-run/SKILL.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.claude/skills/boatstack-update/SKILL.md b/.claude/skills/boatstack-update/SKILL.md index fba8993..d5e337e 100644 --- a/.claude/skills/boatstack-update/SKILL.md +++ b/.claude/skills/boatstack-update/SKILL.md @@ -5,7 +5,7 @@ description: Apply a checksum-verified Boatstack update. Use only when the user # Boatstack Update -Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. +Select `installation.update` or, after exact human acceptance of program drift, `installation.reconcile-update`. This trigger does not reclassify or advance a product delivery. Run `boatstack status --repo . --format json` once for observation. An authority-free `FRONTIER` from status is diagnostic only and cannot terminate this selected operation. @@ -21,8 +21,21 @@ For this operation, request only checksum-verified installation authority. Do no request or materialize repository, provider, publication, product-delivery, or merge authority. Installation receipts cannot be reused to broaden this scope. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +If the candidate reports exact compiled-program drift, preserve the healthy old +launcher and present the prior program fingerprint, candidate program +fingerprint, and program-delta fingerprint. Do not accept the delta implicitly. +After explicit human acceptance, rerun the same checksum-bound update with +`--accept-program-change` so the Kernel uses the single atomic +`installation.reconcile-update` boundary. If the update has an interrupted local +transaction and `recovery.rollback` is permitted, carry the same human authority +through that rollback, preserve its complete receipt, and retry once from the +restored healthy old state. Never acquire repository authority to escape an +update recovery frontier. + +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.cursor/commands/boatstack-autoplan.md b/.cursor/commands/boatstack-autoplan.md index 2573814..e80e461 100644 --- a/.cursor/commands/boatstack-autoplan.md +++ b/.cursor/commands/boatstack-autoplan.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.cursor/commands/boatstack-run.md b/.cursor/commands/boatstack-run.md index 09a0a22..10358c0 100644 --- a/.cursor/commands/boatstack-run.md +++ b/.cursor/commands/boatstack-run.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.cursor/commands/boatstack-update.md b/.cursor/commands/boatstack-update.md index fba8993..d5e337e 100644 --- a/.cursor/commands/boatstack-update.md +++ b/.cursor/commands/boatstack-update.md @@ -5,7 +5,7 @@ description: Apply a checksum-verified Boatstack update. Use only when the user # Boatstack Update -Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. +Select `installation.update` or, after exact human acceptance of program drift, `installation.reconcile-update`. This trigger does not reclassify or advance a product delivery. Run `boatstack status --repo . --format json` once for observation. An authority-free `FRONTIER` from status is diagnostic only and cannot terminate this selected operation. @@ -21,8 +21,21 @@ For this operation, request only checksum-verified installation authority. Do no request or materialize repository, provider, publication, product-delivery, or merge authority. Installation receipts cannot be reused to broaden this scope. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +If the candidate reports exact compiled-program drift, preserve the healthy old +launcher and present the prior program fingerprint, candidate program +fingerprint, and program-delta fingerprint. Do not accept the delta implicitly. +After explicit human acceptance, rerun the same checksum-bound update with +`--accept-program-change` so the Kernel uses the single atomic +`installation.reconcile-update` boundary. If the update has an interrupted local +transaction and `recovery.rollback` is permitted, carry the same human authority +through that rollback, preserve its complete receipt, and retry once from the +restored healthy old state. Never acquire repository authority to escape an +update recovery frontier. + +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.gemini/skills/boatstack-autoplan/SKILL.md b/.gemini/skills/boatstack-autoplan/SKILL.md index 2573814..e80e461 100644 --- a/.gemini/skills/boatstack-autoplan/SKILL.md +++ b/.gemini/skills/boatstack-autoplan/SKILL.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.gemini/skills/boatstack-run/SKILL.md b/.gemini/skills/boatstack-run/SKILL.md index 09a0a22..10358c0 100644 --- a/.gemini/skills/boatstack-run/SKILL.md +++ b/.gemini/skills/boatstack-run/SKILL.md @@ -31,8 +31,10 @@ from file presence, authentication, or prior conversation. If the source remains unverifiable, record it as conclusively rejected and fail closed; do not retry it again for the same receipt. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.gemini/skills/boatstack-update/SKILL.md b/.gemini/skills/boatstack-update/SKILL.md index fba8993..d5e337e 100644 --- a/.gemini/skills/boatstack-update/SKILL.md +++ b/.gemini/skills/boatstack-update/SKILL.md @@ -5,7 +5,7 @@ description: Apply a checksum-verified Boatstack update. Use only when the user # Boatstack Update -Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. +Select `installation.update` or, after exact human acceptance of program drift, `installation.reconcile-update`. This trigger does not reclassify or advance a product delivery. Run `boatstack status --repo . --format json` once for observation. An authority-free `FRONTIER` from status is diagnostic only and cannot terminate this selected operation. @@ -21,8 +21,21 @@ For this operation, request only checksum-verified installation authority. Do no request or materialize repository, provider, publication, product-delivery, or merge authority. Installation receipts cannot be reused to broaden this scope. -Begin each cycle with an untargeted authority-bearing `next`. Apply only the -stable transition ID from the immediately preceding prescription and only its +If the candidate reports exact compiled-program drift, preserve the healthy old +launcher and present the prior program fingerprint, candidate program +fingerprint, and program-delta fingerprint. Do not accept the delta implicitly. +After explicit human acceptance, rerun the same checksum-bound update with +`--accept-program-change` so the Kernel uses the single atomic +`installation.reconcile-update` boundary. If the update has an interrupted local +transaction and `recovery.rollback` is permitted, carry the same human authority +through that rollback, preserve its complete receipt, and retry once from the +restored healthy old state. Never acquire repository authority to escape an +update recovery frontier. + +Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` +identifies the next transition but is not permission to apply it: bind only its +declared parameters and re-resolve that exact transition. Apply only the stable +transition ID from the immediately preceding `PRESCRIBED` result and only its declared parameters. Preserve the complete apply response and stderr, including admission, receipt, postcondition, error, recovery, and transaction fields. Re-resolve with the same context after every complete receipt. diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 6adfc2d..ac67b1c 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -23,14 +23,27 @@ def setUpClass(cls) -> None: cls.helper = Path(cls.build.name) / ( "boatstack-helper.exe" if os.name == "nt" else "boatstack-helper" ) - result = subprocess.run( - ["go", "build", "-o", str(cls.helper), "./cmd/boatstack-helper"], - cwd=RUNTIME, - text=True, - capture_output=True, + cls.old_helper = Path(cls.build.name) / ( + "boatstack-helper-old.exe" if os.name == "nt" else "boatstack-helper-old" ) - if result.returncode != 0: - raise RuntimeError(result.stdout + result.stderr) + for output, version, source in ( + (cls.helper, "v0.7.contract-new", "b" * 40), + (cls.old_helper, "v0.7.contract-old", "a" * 40), + ): + ldflags = " ".join( + ( + f"-X github.com/operatorstack/boatstack/boatstack.Version={version}", + f"-X github.com/operatorstack/boatstack/boatstack.SourceCommit={source}", + ) + ) + result = subprocess.run( + ["go", "build", "-ldflags", ldflags, "-o", str(output), "./cmd/boatstack-helper"], + cwd=RUNTIME, + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stdout + result.stderr) @classmethod def tearDownClass(cls) -> None: @@ -149,7 +162,7 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) mappings = { "boatstack-autoplan": "`approved-plan` terminal", "boatstack-run": "`open-or-updated-pr` terminal", - "boatstack-update": "`installation.update` transition", + "boatstack-update": "`installation.reconcile-update`", } for name, mapping in mappings.items(): self.assertIn(f"name: {name}", skills[name]) @@ -161,7 +174,7 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) "every `next`, `apply`, `recover`, and re-resolution", "requested authority sources separately from currently\nmaterialized authority receipts", "untargeted authority-bearing `next`", - "immediately preceding prescription", + "immediately preceding `PRESCRIBED`", "complete apply response and stderr", "authority-bearing `FRONTIER`", "every requested authority source is materialized\nor conclusively rejected against the post-receipt state", @@ -186,6 +199,15 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) update, ) self.assertNotIn("repository-policy source remains requested", update) + for contract in ( + "preserve the healthy old\nlauncher", + "program-delta fingerprint", + "Do not accept the delta implicitly", + "`--accept-program-change`", + "single atomic\n`installation.reconcile-update` boundary", + "carry the same human authority\nthrough that rollback", + ): + self.assertIn(contract, update) self.assertIn("Untargeted resolution selects\nonly a transition that advances the configured goal", readme) self.assertIn("exactly three operation skills", readme) @@ -285,6 +307,7 @@ def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: "status", "next", "next-status", "apply", "recover", "doctor", "events", "catalog", "guard", "rpc", "retro", "version", "init", "update", "attach", "detach", "hydrate-runtime", "configure", + "reconcile-update", "goal-configure", "plan-create", "plan-validate", "plan-approve", "plan-activate", "plan-amend", "workspace-cut", "workspace-sync", "workspace-cleanup", "workspace-reap", "record-build", "record-test", @@ -315,8 +338,8 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> response = json.loads(self.run_helper("catalog").stdout) transitions = response["catalog"] - self.assertEqual(len(transitions), 62) - self.assertEqual(len({item["id"] for item in transitions}), 62) + self.assertEqual(len(transitions), 63) + self.assertEqual(len({item["id"] for item in transitions}), 63) self.assertEqual( {item["class"] for item in transitions}, {"authority", "owned-local", "owned-external", "recovery", "observed-external"}, @@ -352,7 +375,7 @@ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> checked = (REPO / "docs" / "architecture" / name).read_text() self.assertEqual(rendered, checked) model = json.loads(checked) - self.assertEqual(len(model["events"]), 62) + self.assertEqual(len(model["events"]), 63) self.assertEqual( {event["id"] for event in model["events"]}, {item["id"] for item in transitions}, @@ -422,7 +445,7 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout ) self.assertTrue(doctor["doctor"]["healthy"]) - self.assertEqual(doctor["doctor"]["transition_count"], 62) + self.assertEqual(doctor["doctor"]["transition_count"], 63) self.assertEqual(doctor["snapshot"]["runtime"]["value"], "verified") goal = ( @@ -471,16 +494,139 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - transitions = {json.loads(line)["transition_id"] for line in events} self.assertTrue({"installation.initialize", "engagement.begin", "installation.update"}.issubset(transitions)) + def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None: + # control-law: accepted-program-delta-atomically-installs-runtime-launcher-and-program + if os.name == "nt": + self.skipTest("the repository contract job exercises the POSIX installer") + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + repository = root / "repo" + install_dir = root / "bin" + state_root = root / "state" + repository.mkdir() + self.init_repository(repository) + env = dict(os.environ) + env.update( + { + "BOATSTACK_REPO": str(repository), + "BOATSTACK_BINARY": str(self.old_helper), + "BOATSTACK_BINARY_SHA256": hashlib.sha256(self.old_helper.read_bytes()).hexdigest(), + "BOATSTACK_INSTALL_DIR": str(install_dir), + "BOATSTACK_CONFIG": str(CONFIG), + "BOATSTACK_STATE_ROOT": str(state_root), + "BOATSTACK_ACTOR": "contract", + "BOATSTACK_VERSION": "contract-old", + } + ) + self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) + launcher = install_dir / "boatstack" + old_target = os.readlink(launcher) + state_path = next((repository / ".git" / "boatstack").rglob("state.json")) + state_before = state_path.read_bytes() + + env.update( + { + "BOATSTACK_MODE": "update", + "BOATSTACK_BINARY": str(self.helper), + "BOATSTACK_BINARY_SHA256": hashlib.sha256(self.helper.read_bytes()).hexdigest(), + "BOATSTACK_VERSION": "contract-new", + } + ) + candidate_status = json.loads( + self.run_command( + self.helper, "status", "--repo", repository, env=env + ).stdout + ) + prior_program = candidate_status["snapshot"]["recorded_program_fingerprint"] + split_reconciliation = self.run_command( + self.helper, + "apply", + "--repo", + repository, + "--transition", + "catalog.reconcile", + "--human", + "contract", + "--goal-id", + "bootstrap", + "--goal-kind", + "approved-plan", + "--delivery", + "bootstrap", + "--param", + f"prior_program_fingerprint={prior_program}", + "--param", + "accept_obligation_change=true", + env=env, + expected=1, + ) + self.assertIn( + "catalog reconciliation cannot activate a different runtime", + split_reconciliation.stdout + split_reconciliation.stderr, + ) + self.assertEqual(os.readlink(launcher), old_target) + self.assertEqual(state_path.read_bytes(), state_before) + rejected = self.run_command( + "bash", REPO / "install.sh", cwd=repository, env=env, expected=1 + ) + self.assertIn( + "compiled control program drift requires explicit reconciliation", + rejected.stdout + rejected.stderr, + ) + refusal = json.loads(rejected.stdout) + change = refusal["program_change"] + self.assertEqual(change["prior_program_fingerprint"], prior_program) + self.assertEqual( + change["candidate_program_fingerprint"], + refusal["snapshot"]["program_fingerprint"], + ) + self.assertRegex(change["program_delta_fingerprint"], r"^[0-9a-f]{64}$") + self.assertEqual(change["required_transition"], "installation.reconcile-update") + self.assertEqual(change["acceptance_flag"], "--accept-program-change") + self.assertEqual(os.readlink(launcher), old_target) + self.assertEqual(state_path.read_bytes(), state_before) + + env["BOATSTACK_ACCEPT_PROGRAM_CHANGE"] = "true" + self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) + self.assertIn("contract-new", os.readlink(launcher)) + doctor = json.loads( + self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout + ) + self.assertTrue(doctor["doctor"]["healthy"]) + self.assertTrue(doctor["doctor"]["runtime_healthy"]) + self.assertTrue(doctor["doctor"]["update_ready"]) + self.assertFalse(doctor["doctor"]["recovery_required"]) + self.assertEqual(doctor["snapshot"]["engagement"]["value"], "dormant") + + receipt_path = next(state_root.rglob("receipts.jsonl")) + receipts = [json.loads(line) for line in receipt_path.read_text().splitlines()] + update = next( + receipt + for receipt in receipts + if receipt["transition_id"] == "installation.reconcile-update" + ) + self.assertTrue(update["program_change_accepted"]) + self.assertRegex(update["prior_program_fingerprint"], r"^[0-9a-f]{64}$") + self.assertRegex(update["program_fingerprint"], r"^[0-9a-f]{64}$") + self.assertRegex(update["program_delta_fingerprint"], r"^[0-9a-f]{64}$") + candidate = install_dir / os.readlink(launcher) + self.assertEqual( + update["runtime_fingerprint"], hashlib.sha256(candidate.read_bytes()).hexdigest() + ) + self.assertEqual(update["runtime_source_revision"], "b" * 40) + def test_installers_are_checksum_first_and_kernel_owned(self) -> None: shell = (REPO / "install.sh").read_text() powershell = (REPO / "install.ps1").read_text() for expected in ( "BOATSTACK_BINARY_SHA256", "sha256sum", "shasum -a 256", - '"$runtime" init', '"$runtime" update', + '"$runtime" init', "update_arguments=(", '"$runtime" "${update_arguments[@]}"', + "BOATSTACK_ACCEPT_PROGRAM_CHANGE", "--accept-program-change", ): self.assertIn(expected, shell) for expected in ( "BOATSTACK_BINARY_SHA256", "Get-FileHash", "$Runtime init", "$Runtime update", + "BOATSTACK_ACCEPT_PROGRAM_CHANGE", "--accept-program-change", ): self.assertIn(expected, powershell) self.assertNotIn("--repair", shell) diff --git a/README.md b/README.md index f1b8988..c130e1f 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ boatstack apply --repo . --transition --format json ``` - `status`, `next`, `doctor`, `catalog`, and `events` are read-only. -- `apply` and `recover` request stable transition IDs from the 62-event +- `apply` and `recover` request stable transition IDs from the 63-event executable catalog. - Friendly aliases such as `plan-create`, `plan-approve`, `workspace-cut`, `record-test`, and `publish-pr` map to those IDs. @@ -106,7 +106,10 @@ coding host: - `boatstack-autoplan` reaches an approved plan. - `boatstack-run` drives delivery through a normal PR open or update and never grants merge authority. -- `boatstack-update` runs the checksum-verified installation update path. +- `boatstack-update` runs the checksum-verified installation update path. A + control-program change preserves the old launcher until a human accepts the + exact prior-to-candidate program delta; Boatstack then activates the runtime, + launcher, managed skills, and program state through one recoverable transition. Codex and compatible Agent Skills hosts show `$boatstack-autoplan`, `$boatstack-run`, and `$boatstack-update`. Claude Code and Gemini CLI receive diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 9bb05e2..d53776d 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -22,6 +22,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/surfaces" ) @@ -34,21 +35,22 @@ func (s *stringList) Set(value string) error { } type commandOptions struct { - repository string - format string - goalID string - goalKind string - deliveryID string - flowID string - transitionID string - idempotencyKey string - humanActor string - repositoryPolicy bool - parameters stringList - authorityReceipts stringList - follow bool - host string - command string + repository string + format string + goalID string + goalKind string + deliveryID string + flowID string + transitionID string + idempotencyKey string + humanActor string + repositoryPolicy bool + acceptProgramChange bool + parameters stringList + authorityReceipts stringList + follow bool + host string + command string } func main() { @@ -90,6 +92,12 @@ func run(arguments []string) error { return err } response, handleErr := kernel.Handle(context.Background(), request) + if command == "update" && options.acceptProgramChange && handleErr != nil && response.ProgramChange != nil && response.Decision != nil && + response.Decision.Kind == supervisor.DecisionUnresolved && response.Decision.Reason == supervisor.ReasonProgramDrift { + request.TransitionID = "installation.reconcile-update" + request.Parameters = append(request.Parameters, protocol.Parameter{Name: "accept_obligation_change", Value: "true"}).Canonical() + response, handleErr = kernel.Handle(context.Background(), request) + } if command == "events" && options.follow { if options.format != "jsonl" { return fmt.Errorf("events --follow requires --format jsonl") @@ -167,7 +175,7 @@ func runRetrospective(arguments []string) error { func classifyCommand(command string) (surfaces.Operation, catalog.TransitionID, map[string]string, error) { aliases := map[string]catalog.TransitionID{ - "init": "installation.initialize", "update": "installation.update", "attach": "repository.attach", "detach": "repository.detach", + "init": "installation.initialize", "update": "installation.update", "reconcile-update": "installation.reconcile-update", "attach": "repository.attach", "detach": "repository.detach", "hydrate-runtime": "runtime.hydrate", "configure": "configuration.mutate", "goal-configure": "goal.configure", "plan-create": "plan.create", "plan-validate": "plan.validate", "plan-approve": "plan.approve", "plan-activate": "plan.activate", "plan-amend": "plan.amend", "workspace-cut": "workspace.cut", "workspace-sync": "workspace.sync", "workspace-cleanup": "workspace.cleanup", "workspace-reap": "workspace.reap", @@ -218,6 +226,7 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.StringVar(&options.idempotencyKey, "idempotency-key", "", "exact prior admission idempotency key for safe replay") flags.StringVar(&options.humanActor, "human", "", "explicit command-scoped human authority actor") flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from the V2 project configuration") + flags.BoolVar(&options.acceptProgramChange, "accept-program-change", false, "explicitly accept the exact prior-to-candidate control-program delta during update") flags.Var(&options.parameters, "param", "transition parameter name=value (repeatable)") flags.Var(&options.authorityReceipts, "authority-receipt", "authority receipt JSON path (repeatable)") flags.BoolVar(&options.follow, "follow", false, "follow passive process events (events with jsonl only)") @@ -234,10 +243,16 @@ func parseOptions(command string, arguments []string, transition catalog.Transit if err := populateInitParameters(&options); err != nil { return commandOptions{}, err } - case "update", "hydrate-runtime": + case "update", "reconcile-update", "hydrate-runtime": if err := populateRuntimeParameters(&options); err != nil { return commandOptions{}, err } + if command == "reconcile-update" { + if !options.acceptProgramChange { + return commandOptions{}, fmt.Errorf("reconcile-update requires explicit --accept-program-change") + } + options.parameters = append(options.parameters, "accept_obligation_change=true") + } case "correct-pr": if err := populateFileFingerprint(&options, "body_path", "body_sha256"); err != nil { return commandOptions{}, err @@ -534,14 +549,19 @@ func renderResponse(response surfaces.Response, format string) error { case "text": if response.Error != "" { fmt.Println("UNRESOLVED:", response.Error) + if response.ProgramChange != nil { + fmt.Printf("program_change prior=%s candidate=%s delta=%s transition=%s accept=%s\n", + response.ProgramChange.PriorProgramFingerprint, response.ProgramChange.CandidateProgramFingerprint, + response.ProgramChange.ProgramDeltaFingerprint, response.ProgramChange.RequiredTransition, response.ProgramChange.AcceptanceFlag) + } return nil } if response.Doctor != nil { - fmt.Printf("healthy=%t kernel=%s core=%s@%s flow=%s@%s core_transitions=%d flow_transitions=%d extension_transitions=%d transitions=%d program=%s drift=%t snapshot=%s\n%s\n", + fmt.Printf("healthy=%t kernel=%s core=%s@%s flow=%s@%s core_transitions=%d flow_transitions=%d extension_transitions=%d transitions=%d program=%s drift=%t runtime_healthy=%t update_ready=%t recovery_required=%t snapshot=%s\n%s\n", response.Doctor.Healthy, response.Doctor.KernelVersion, response.Doctor.CoreSystemID, response.Doctor.CoreSystemVersion, response.Doctor.PrimaryFlowID, response.Doctor.PrimaryFlowVersion, response.Doctor.CoreTransitionCount, response.Doctor.FlowTransitionCount, response.Doctor.ExtensionTransitionCount, response.Doctor.TransitionCount, - response.Doctor.ProgramFingerprint, response.Doctor.UnresolvedProgramDrift, response.Doctor.Snapshot, response.Doctor.Detail) + response.Doctor.ProgramFingerprint, response.Doctor.UnresolvedProgramDrift, response.Doctor.RuntimeHealthy, response.Doctor.UpdateReady, response.Doctor.RecoveryRequired, response.Doctor.Snapshot, response.Doctor.Detail) return nil } if response.Decision != nil { diff --git a/boatstack/control/control_test.go b/boatstack/control/control_test.go index 3ce2af1..414bbb9 100644 --- a/boatstack/control/control_test.go +++ b/boatstack/control/control_test.go @@ -34,7 +34,7 @@ func TestStandardProgramHasExplicitStableComposition(t *testing.T) { t.Fatalf("identical compilation drifted: %s != %s", one.Fingerprint(), two.Fingerprint()) } summary := one.Summary() - if summary.CoreTransitionCount != 32 || summary.FlowTransitionCount != 30 || summary.ExtensionTransitionCount != 0 || summary.TotalTransitionCount != 62 { + if summary.CoreTransitionCount != 33 || summary.FlowTransitionCount != 30 || summary.ExtensionTransitionCount != 0 || summary.TotalTransitionCount != 63 { t.Fatalf("compiled counts = %+v", summary) } counts := map[string]int{} @@ -44,7 +44,7 @@ func TestStandardProgramHasExplicitStableComposition(t *testing.T) { t.Fatalf("transition lost compiled ownership: %+v", transition) } } - if counts["core-system"] != 32 || counts["primary-flow"] != 30 || counts["extension"] != 0 { + if counts["core-system"] != 33 || counts["primary-flow"] != 30 || counts["extension"] != 0 { t.Fatalf("origin counts = %#v", counts) } } diff --git a/boatstack/core/system_test.go b/boatstack/core/system_test.go index 8ffd57c..db3dfb8 100644 --- a/boatstack/core/system_test.go +++ b/boatstack/core/system_test.go @@ -14,7 +14,7 @@ func TestManifestOwnsOnlyOperationalCapabilities(t *testing.T) { if err != nil { t.Fatal(err) } - if manifest.ID != core.ID || manifest.Version != core.Version || len(manifest.Transitions) != 32 { + if manifest.ID != core.ID || manifest.Version != core.Version || len(manifest.Transitions) != 33 { t.Fatalf("CoreSystem identity/count = %s@%s/%d", manifest.ID, manifest.Version, len(manifest.Transitions)) } for _, transition := range manifest.Transitions { diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index f12414d..1817655 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -2277,13 +2277,20 @@ "selection_class": "EXPLICIT_ONLY", "class": "owned-local", "source_phases": [ + "DORMANT", "OBSERVED", - "ACTIVE" + "ACTIVE", + "FRONTIER", + "TERMINAL", + "ABANDONED" ], "target_phases": [ + "DORMANT", "OBSERVED", "ACTIVE", - "TERMINAL" + "FRONTIER", + "TERMINAL", + "ABANDONED" ], "goal_kinds": [ "approved-plan", @@ -2359,6 +2366,7 @@ "known" ], "values": [ + "absent", "verified", "stale", "invalid", @@ -2402,17 +2410,8 @@ ], "values": [ "nonterminal", - "stale" - ] - }, - { - "facet": "engagement", - "statuses": [ - "known" - ], - "values": [ - "command", - "active" + "stale", + "established" ] }, { @@ -2474,6 +2473,103 @@ "policy": {}, "priority": 65 }, + { + "id": "installation.reconcile-update", + "version": 1, + "origin": {"kind": "", "id": "", "version": "", "manifest_fingerprint": ""}, + "owner": "", + "selection_class": "EXPLICIT_ONLY", + "class": "owned-local", + "source_phases": ["DORMANT", "OBSERVED", "ACTIVE", "FRONTIER", "TERMINAL", "ABANDONED"], + "target_phases": ["DORMANT", "OBSERVED", "ACTIVE", "FRONTIER", "TERMINAL", "ABANDONED"], + "goal_kinds": [ + "approved-plan", + "verified-implementation", + "open-or-updated-pr", + "merged-delivery", + "safely-abandoned" + ], + "required_identity": [ + "repository-id", + "git-common-id", + "worktree-id", + "ref", + "controller-id", + "invoking-path", + "runtime-path", + "runtime-fingerprint", + "topology", + "host", + "correlation-id" + ], + "authority": ["human"], + "required_evidence": [ + "invocation-context", + "snapshot-fingerprint", + "goal", + "facet:runtime", + "facet:program", + "facet:recovery", + "facet:transaction", + "facet:terminal", + "facet:goal", + "facet:configuration" + ], + "owned_resources": ["installation"], + "effect": "installation.reconcile-update", + "local_effects": ["installation.reconcile-update"], + "idempotent": true, + "parameters": [ + {"name": "source_revision", "required": true, "secret": false}, + {"name": "runtime_path", "required": true, "secret": false}, + {"name": "runtime_sha256", "required": true, "secret": false}, + {"name": "accept_obligation_change", "required": true, "secret": false} + ], + "prescription": { + "operation": "installation.reconcile-update", + "authority_prompt": "Explicitly accept the exact prior-to-candidate program delta before activation.", + "expected_postcondition": "predicate:target-phase:installation.reconcile-update" + }, + "source_predicate": "predicate:source-phase:installation.reconcile-update", + "source_conditions": [ + { + "facet": "runtime", + "statuses": ["known"], + "values": ["absent", "verified", "stale", "invalid", "conflicting", "wrong-source", "partially-published"] + }, + {"facet": "program", "statuses": ["known"], "values": ["drift"]}, + {"facet": "recovery", "statuses": ["known"], "values": ["none"]}, + {"facet": "transaction", "statuses": ["known"], "values": ["none"]}, + {"facet": "terminal", "statuses": ["known"], "values": ["nonterminal", "stale", "established"]}, + {"facet": "goal", "statuses": ["known"]}, + {"facet": "configuration", "statuses": ["known"], "values": ["verified"]} + ], + "admission_predicate": "predicate:exact-admission:installation.reconcile-update", + "target_predicate": "predicate:target-phase:installation.reconcile-update", + "target_conditions": [ + {"facet": "runtime", "statuses": ["known"], "values": ["verified"]}, + {"facet": "program", "statuses": ["known"], "values": ["current"]} + ], + "verifier": "verifier:fresh-observation:installation.reconcile-update", + "interruption": { + "points": ["after-lock", "after-stage", "after-effect", "before-receipt"], + "partial_state": ["journal-begun", "effect-staged", "effect-possibly-installed", "postcondition-unreceipted"], + "detection": "pending-journal-plus-fresh-canonical-observation", + "resume_contract": "never-activate-a-partial-program-update", + "rollback_contract": "restore-runtime-launcher-program-state-and-generated-artifacts-together", + "compensation_contract": "not-required-for-owned-local-effects", + "recovery": "recovery.rollback", + "recovery_authority": "declared-by:recovery.rollback", + "resumption_predicate": "recovery-contract-for:installation.reconcile-update" + }, + "reversibility": "reversible", + "terminal_effect": "preserves-the-existing-delivery-and-engagement-state", + "privacy_classification": "metadata-only", + "telemetry_classification": "transition-receipt", + "cost_class": "declared-neutral", + "policy": {"reconciles_program": true}, + "priority": 1 + }, { "id": "catalog.reconcile", "version": 1, diff --git a/boatstack/examples/control_program_test.go b/boatstack/examples/control_program_test.go index 7ad9d59..78fd21c 100644 --- a/boatstack/examples/control_program_test.go +++ b/boatstack/examples/control_program_test.go @@ -24,7 +24,7 @@ func Example_standardFlowWithReleaseNoteExtension() { summary := program.Summary() fmt.Printf("%s + %s + %s: %d transitions\n", summary.Core.ID, summary.Flow.ID, summary.Extensions[0].ID, summary.TotalTransitionCount) // Output: - // boatstack.core + boatstack.standard + boatstack.release-note: 63 transitions + // boatstack.core + boatstack.standard + boatstack.release-note: 64 transitions } func Example_sdkCustomKernel() { diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 85a1383..b4c3660 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -49,7 +49,7 @@ func TestEveryControllingFacetAndEventIsClassifiedByTheRuntimeCatalog(t *testing t.Errorf("controlling facet %s is absent from executable predicates", facet) } } - want := map[string]int{"invocation-engagement": 6, "installation-runtime-configuration": 8, "catalog": 1, "goal-plan": 9, "workspace": 8, "gate-evidence-delivery": 8, "publication": 6, "recovery": 3, "external": 13} + want := map[string]int{"invocation-engagement": 6, "installation-runtime-configuration": 9, "catalog": 1, "goal-plan": 9, "workspace": 8, "gate-evidence-delivery": 8, "publication": 6, "recovery": 3, "external": 13} for family, count := range want { if families[family] != count { t.Errorf("family %s=%d, want %d", family, families[family], count) diff --git a/boatstack/internal/effects/artifacts.go b/boatstack/internal/effects/artifacts.go index 42fb056..a847135 100644 --- a/boatstack/internal/effects/artifacts.go +++ b/boatstack/internal/effects/artifacts.go @@ -281,7 +281,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, err } } - if transition.ID == "configuration.initialize" || transition.ID == "configuration.mutate" || transition.ID == "installation.initialize" || transition.ID == "installation.update" { + if transition.ID == "configuration.initialize" || transition.ID == "configuration.mutate" || transition.ID == "installation.initialize" || transition.ID == "installation.update" || transition.ID == "installation.reconcile-update" { hostMutations, hostErr := prepareHostSkillMutations(layout.RepositoryRoot, state.EnabledHosts) if hostErr != nil { return nil, hostErr diff --git a/boatstack/internal/effects/driver.go b/boatstack/internal/effects/driver.go index 82999d7..7ca16f5 100644 --- a/boatstack/internal/effects/driver.go +++ b/boatstack/internal/effects/driver.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "strings" "time" @@ -78,6 +79,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if state.ProgramFingerprint != "" && state.ProgramFingerprint != admission.ProgramFingerprint && !transition.Policy.ReconcilesProgram { return nil, fmt.Errorf("compiled control program drifted; explicit program reconciliation is required") } + if transition.ID == "catalog.reconcile" && (state.RuntimePath != admission.Invocation.RuntimePath || state.RuntimeFingerprint != admission.Invocation.RuntimeFingerprint) { + return nil, fmt.Errorf("catalog reconciliation cannot activate a different runtime; use installation.reconcile-update") + } if err := verifyWorkspaceBranchParameter(state, admission, transition.ID); err != nil { return nil, err } @@ -94,6 +98,15 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if err := applyStateTransition(&next, admission, transition); err != nil { return nil, err } + var launcherMutation *ports.ResourceMutation + if transition.ID == "installation.initialize" || transition.ID == "installation.update" || transition.ID == "installation.reconcile-update" { + mutation, launcherPath, launcherFingerprint, launcherErr := prepareLauncherMutation(admission) + if launcherErr != nil { + return nil, launcherErr + } + next.LauncherPath, next.LauncherFingerprint = launcherPath, launcherFingerprint + launcherMutation = &mutation + } next.Revision++ next.UpdatedAt = d.clock.Now().UTC() var verificationInvocation *model.InvocationContext @@ -141,6 +154,9 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if err != nil { return nil, err } + if launcherMutation != nil { + mutations = append(mutations, *launcherMutation) + } statePath := layout.StatePath stateInstallLast := true if transition.ID == "repository.attach" { @@ -369,7 +385,7 @@ func loadDurableState(path string, invocation model.InvocationContext, now time. } func verifyRuntimeParameters(admission protocol.Admission, transition catalog.Transition) error { - if transition.ID != "runtime.hydrate" && transition.ID != "runtime.replace" && transition.ID != "runtime.reconcile" && transition.ID != "installation.initialize" && transition.ID != "installation.update" { + if transition.ID != "runtime.hydrate" && transition.ID != "runtime.replace" && transition.ID != "runtime.reconcile" && transition.ID != "installation.initialize" && transition.ID != "installation.update" && transition.ID != "installation.reconcile-update" { return nil } runtimePath, _ := admission.Parameters.Get("runtime_path") @@ -391,9 +407,76 @@ func verifyRuntimeParameters(admission protocol.Admission, transition catalog.Tr if actual := sha256Bytes(raw); actual != expected { return fmt.Errorf("declared runtime fingerprint mismatch: got %s", actual) } + if strings.HasPrefix(string(transition.ID), "installation.") && (runtimePath != admission.Invocation.RuntimePath || expected != admission.Invocation.RuntimeFingerprint) { + return fmt.Errorf("installation runtime must be the exact candidate process that owns admission") + } return nil } +func prepareLauncherMutation(admission protocol.Admission) (ports.ResourceMutation, string, string, error) { + runtimePath, _ := admission.Parameters.Get("runtime_path") + launcherPath := filepath.Join(filepath.Dir(runtimePath), "boatstack") + if runtime.GOOS == "windows" { + launcherPath += ".cmd" + body := []byte("@echo off\r\n\"" + runtimePath + "\" %*\r\n") + mutation, err := mutationFor(launcherPath, body, 0o700, false, false) + return mutation, launcherPath, sha256Bytes(body), err + } + target := filepath.Base(runtimePath) + mutation, err := mutationForSymlink(launcherPath, target, false) + return mutation, launcherPath, sha256Bytes([]byte("symlink\x00" + target)), err +} + +func mutationForSymlink(path, target string, installLast bool) (ports.ResourceMutation, error) { + if !filepath.IsAbs(path) || target == "" { + return ports.ResourceMutation{}, fmt.Errorf("managed symlink requires an absolute path and target") + } + mutation := ports.ResourceMutation{Path: path, TargetLink: target, Mode: 0o700, InstallLast: installLast} + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return mutation, nil + } + if err != nil { + return ports.ResourceMutation{}, err + } + mutation.PriorExists = true + if info.Mode()&os.ModeSymlink != 0 { + mutation.PriorLink, err = os.Readlink(path) + return mutation, err + } + if !info.Mode().IsRegular() { + return ports.ResourceMutation{}, fmt.Errorf("managed launcher is neither a regular file nor symlink: %s", path) + } + mutation.Prior, err = os.ReadFile(path) + mutation.Mode = uint32(info.Mode().Perm()) + return mutation, err +} + +func mutationForExactResource(path string, target []byte, targetLink string, mode os.FileMode, installLast, deleteResource bool) (ports.ResourceMutation, error) { + if !filepath.IsAbs(path) || (targetLink != "" && deleteResource) { + return ports.ResourceMutation{}, fmt.Errorf("managed resource target is invalid: %s", path) + } + mutation := ports.ResourceMutation{Path: path, Target: target, TargetLink: targetLink, Mode: uint32(mode.Perm()), InstallLast: installLast, Delete: deleteResource} + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return mutation, nil + } + if err != nil { + return ports.ResourceMutation{}, err + } + mutation.PriorExists = true + if info.Mode()&os.ModeSymlink != 0 { + mutation.PriorLink, err = os.Readlink(path) + return mutation, err + } + if !info.Mode().IsRegular() { + return ports.ResourceMutation{}, fmt.Errorf("managed resource is neither a regular file nor symlink: %s", path) + } + mutation.Prior, err = os.ReadFile(path) + mutation.Mode = uint32(info.Mode().Perm()) + return mutation, err +} + func mutationFor(path string, target []byte, mode os.FileMode, installLast, deleteResource bool) (ports.ResourceMutation, error) { if !filepath.IsAbs(path) { return ports.ResourceMutation{}, fmt.Errorf("managed resource path is not absolute: %s", path) diff --git a/boatstack/internal/effects/host_skills.go b/boatstack/internal/effects/host_skills.go index 438dab5..b069470 100644 --- a/boatstack/internal/effects/host_skills.go +++ b/boatstack/internal/effects/host_skills.go @@ -45,7 +45,7 @@ var hostSkillModes = []hostSkillMode{ { Slug: "boatstack-update", DisplayName: "Boatstack Update", Description: "Apply a checksum-verified Boatstack update.", - Target: "the `installation.update` transition", + Target: "`installation.update` or, after exact human acceptance of program drift, `installation.reconcile-update`", Extra: "This trigger does not reclassify or advance a product delivery.", AuthorityContract: updateAuthorityContract, }, @@ -67,7 +67,18 @@ again for the same receipt.` const updateAuthorityContract = `For this operation, request only checksum-verified installation authority. Do not request or materialize repository, provider, publication, product-delivery, or -merge authority. Installation receipts cannot be reused to broaden this scope.` +merge authority. Installation receipts cannot be reused to broaden this scope. + +If the candidate reports exact compiled-program drift, preserve the healthy old +launcher and present the prior program fingerprint, candidate program +fingerprint, and program-delta fingerprint. Do not accept the delta implicitly. +After explicit human acceptance, rerun the same checksum-bound update with +` + "`--accept-program-change`" + ` so the Kernel uses the single atomic +` + "`installation.reconcile-update`" + ` boundary. If the update has an interrupted local +transaction and ` + "`recovery.rollback`" + ` is permitted, carry the same human authority +through that rollback, preserve its complete receipt, and retry once from the +restored healthy old state. Never acquire repository authority to escape an +update recovery frontier.` func renderHostSkill(mode hostSkillMode) []byte { return []byte(fmt.Sprintf(`--- diff --git a/boatstack/internal/effects/host_skills_test.go b/boatstack/internal/effects/host_skills_test.go index 76cc293..c86c54f 100644 --- a/boatstack/internal/effects/host_skills_test.go +++ b/boatstack/internal/effects/host_skills_test.go @@ -88,6 +88,12 @@ func TestHostSkillProjectionPreservesDeferredRepositoryAuthority(t *testing.T) { for _, contract := range []string{ "request only checksum-verified installation authority", "Do not\nrequest or materialize repository, provider, publication, product-delivery, or\nmerge authority", + "preserve the healthy old\nlauncher", + "program-delta fingerprint", + "Do not accept the delta implicitly", + "`--accept-program-change`", + "single atomic\n`installation.reconcile-update` boundary", + "carry the same human authority\nthrough that rollback", } { if !strings.Contains(value, contract) { t.Fatalf("%s is missing update authority boundary %q", path, contract) diff --git a/boatstack/internal/effects/integration_test.go b/boatstack/internal/effects/integration_test.go index 005cf6d..0ddadf4 100644 --- a/boatstack/internal/effects/integration_test.go +++ b/boatstack/internal/effects/integration_test.go @@ -229,8 +229,8 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing } } -func TestProgramDriftRequiresExplicitCatalogReconciliation(t *testing.T) { - // control-law: frozen-program-cannot-change-without-exact-human-reconciliation +func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { + // control-law: frozen-program-and-runtime-change-only-through-one-exact-human-admission ctx := context.Background() repository := testRepository(t) externalRoot := t.TempDir() @@ -307,8 +307,11 @@ func TestProgramDriftRequiresExplicitCatalogReconciliation(t *testing.T) { } request := surfaces.Request{ SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-drift-reconcile", - FlowID: "flow-program-drift", Goal: goal, TransitionID: "catalog.reconcile", - Parameters: protocol.Parameters{{Name: "prior_program_fingerprint", Value: oldProgram.Fingerprint()}, {Name: "accept_obligation_change", Value: "true"}}, + FlowID: "flow-program-drift", Goal: goal, TransitionID: "installation.reconcile-update", + Parameters: protocol.Parameters{ + {Name: "source_revision", Value: "program-new"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "accept_obligation_change", Value: "true"}, + }, } frontier, err := newKernel.Handle(ctx, request) if err == nil || frontier.Decision == nil || frontier.Decision.Kind != supervisor.DecisionFrontier { @@ -319,22 +322,40 @@ func TestProgramDriftRequiresExplicitCatalogReconciliation(t *testing.T) { t.Fatal("authority-free reconciliation mutated durable state") } request.Authority = human - request.Parameters[1].Value = "false" + request.Parameters[3].Value = "false" if _, err := newKernel.Handle(ctx, request); err == nil { - t.Fatal("reconciliation without explicit obligation acceptance succeeded") + t.Fatal("program-changing update without explicit obligation acceptance succeeded") } afterInvalid, _ := os.ReadFile(layout.StatePath) if !bytes.Equal(before, afterInvalid) { t.Fatal("invalid reconciliation mutated durable state") } - request.Parameters[1].Value = "true" + request.Parameters[3].Value = "true" reconciled, err := newKernel.Handle(ctx, request) if err != nil { t.Fatal(err) } - if reconciled.Receipt == nil || reconciled.Receipt.ProgramFingerprint != newProgram.Fingerprint() || reconciled.Snapshot == nil || reconciled.Snapshot.Program.Value != model.ProgramCurrent { + if reconciled.Receipt == nil || reconciled.Receipt.ProgramFingerprint != newProgram.Fingerprint() || + reconciled.Receipt.PriorProgramFingerprint != oldProgram.Fingerprint() || reconciled.Receipt.ProgramDeltaFingerprint == "" || + !reconciled.Receipt.ProgramChangeAccepted || reconciled.Receipt.RuntimeFingerprint != digestBytes(runtimeRaw) || + reconciled.Receipt.RuntimeSourceRevision != "program-new" || reconciled.Snapshot == nil || + reconciled.Snapshot.Program.Value != model.ProgramCurrent || reconciled.Snapshot.Phase.Value != model.PhaseObserved { t.Fatalf("reconciliation did not establish exact program identity: %#v", reconciled) } + afterSuccess, err := os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + if _, err := newKernel.Handle(ctx, request); err == nil { + t.Fatal("already reconciled program delta was accepted a second time") + } + afterReplay, err := os.ReadFile(layout.StatePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(afterSuccess, afterReplay) { + t.Fatal("rejected repeated reconciliation mutated durable state") + } } func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *testing.T) { diff --git a/boatstack/internal/effects/io.go b/boatstack/internal/effects/io.go index aff065f..e8701ad 100644 --- a/boatstack/internal/effects/io.go +++ b/boatstack/internal/effects/io.go @@ -59,6 +59,35 @@ func atomicWrite(path string, value []byte, mode os.FileMode) error { return syncDirectory(directory) } +func atomicSymlink(path, target string) error { + if !filepath.IsAbs(path) || target == "" { + return fmt.Errorf("effect symlink requires an absolute path and non-empty target") + } + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(directory, ".boatstack-v2-link-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + if err := temporary.Close(); err != nil { + return err + } + if err := os.Remove(temporaryPath); err != nil { + return err + } + defer func() { _ = os.Remove(temporaryPath) }() + if err := os.Symlink(target, temporaryPath); err != nil { + return err + } + if err := replaceFile(temporaryPath, path); err != nil { + return err + } + return syncDirectory(directory) +} + func readAllIfExists(path string) ([]byte, bool, os.FileMode, error) { file, err := os.Open(path) if err != nil { diff --git a/boatstack/internal/effects/journal.go b/boatstack/internal/effects/journal.go index c5e63f5..0e329b8 100644 --- a/boatstack/internal/effects/journal.go +++ b/boatstack/internal/effects/journal.go @@ -30,16 +30,17 @@ func NewJournal(resolver ports.InvocationResolver, clock ports.Clock) (*Journal, } type journalRecord struct { - SchemaVersion int `json:"schema_version"` - Admission protocol.Admission `json:"admission"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionClass catalog.EventClass `json:"transition_class"` - Status string `json:"status"` - Mutations []ports.ResourceMutation `json:"mutations,omitempty"` - Reason string `json:"reason,omitempty"` - ReceiptID string `json:"receipt_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + SchemaVersion int `json:"schema_version"` + Admission protocol.Admission `json:"admission"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionClass catalog.EventClass `json:"transition_class"` + ReconcilesProgram bool `json:"reconciles_program,omitempty"` + Status string `json:"status"` + Mutations []ports.ResourceMutation `json:"mutations,omitempty"` + Reason string `json:"reason,omitempty"` + ReceiptID string `json:"receipt_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func journalName(id, suffix string) (string, error) { @@ -72,7 +73,7 @@ func (j *Journal) Begin(ctx context.Context, admission protocol.Admission, trans return statErr } now := j.clock.Now().UTC() - record := journalRecord{SchemaVersion: 2, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, Status: "begun", CreatedAt: now, UpdatedAt: now} + record := journalRecord{SchemaVersion: 2, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, ReconcilesProgram: transition.Policy.ReconcilesProgram, Status: "begun", CreatedAt: now, UpdatedAt: now} raw, err := encodeJSON(record) if err != nil { return err diff --git a/boatstack/internal/effects/locker.go b/boatstack/internal/effects/locker.go index 4863686..bd86fe9 100644 --- a/boatstack/internal/effects/locker.go +++ b/boatstack/internal/effects/locker.go @@ -63,30 +63,37 @@ func (l Locker) Acquire(ctx context.Context, invocation model.InvocationContext, unique = append(unique, name) } } - held := &heldLocks{} + paths := make([]string, 0, len(unique)+1) for _, name := range unique { - path := filepath.Join(layout.LockRoot, name+".lock") + paths = append(paths, filepath.Join(layout.LockRoot, name+".lock")) + } + if containsString(unique, "installation") { + paths = append(paths, filepath.Join(filepath.Dir(invocation.RuntimePath), ".boatstack-installation.lock")) + } + sort.Strings(paths) + held := &heldLocks{} + for _, path := range paths { file, openErr := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) if openErr != nil { _ = held.Release() - return nil, fmt.Errorf("open lock %s: %w", name, openErr) + return nil, fmt.Errorf("open lock %s: %w", path, openErr) } if lockErr := lockFile(file); lockErr != nil { _ = file.Close() _ = held.Release() - return nil, fmt.Errorf("acquire lock %s: %w", name, lockErr) + return nil, fmt.Errorf("acquire lock %s: %w", path, lockErr) } if truncateErr := file.Truncate(0); truncateErr != nil { _ = unlockFile(file) _ = file.Close() _ = held.Release() - return nil, fmt.Errorf("truncate lock %s: %w", name, truncateErr) + return nil, fmt.Errorf("truncate lock %s: %w", path, truncateErr) } if _, seekErr := file.Seek(0, 0); seekErr != nil { _ = unlockFile(file) _ = file.Close() _ = held.Release() - return nil, fmt.Errorf("seek lock %s: %w", name, seekErr) + return nil, fmt.Errorf("seek lock %s: %w", path, seekErr) } _, writeErr := fmt.Fprintf(file, "%s\n%s\n", invocation.Correlation, invocation.Ref) syncErr := file.Sync() @@ -103,3 +110,12 @@ func (l Locker) Acquire(ctx context.Context, invocation model.InvocationContext, } return held, nil } + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/boatstack/internal/effects/locker_test.go b/boatstack/internal/effects/locker_test.go index 3ef00f2..51f0c6b 100644 --- a/boatstack/internal/effects/locker_test.go +++ b/boatstack/internal/effects/locker_test.go @@ -48,3 +48,40 @@ func TestKernelLockUsesProcessScopedHandleNotFilePresence(t *testing.T) { t.Fatal(err) } } + +func TestInstallationLockCoordinatesRepositoriesSharingOneLauncher(t *testing.T) { + // control-law: shared-launcher-installation-has-one-cross-repository-writer + resolver, err := plant.NewResolver(t.TempDir()) + if err != nil { + t.Fatal(err) + } + firstRepository := recoveryRepository(t) + secondRepository := recoveryRepository(t) + firstInvocation, err := resolver.ResolveInvocation(context.Background(), firstRepository, "cli", "first-installation") + if err != nil { + t.Fatal(err) + } + secondInvocation, err := resolver.ResolveInvocation(context.Background(), secondRepository, "cli", "second-installation") + if err != nil { + t.Fatal(err) + } + locker, err := NewLocker(resolver) + if err != nil { + t.Fatal(err) + } + first, err := locker.Acquire(context.Background(), firstInvocation, []string{"installation"}) + if err != nil { + t.Fatal(err) + } + defer first.Release() + if _, err := locker.Acquire(context.Background(), secondInvocation, []string{"installation"}); err == nil { + t.Fatal("two repositories concurrently acquired their shared launcher installation") + } + independent, err := locker.Acquire(context.Background(), secondInvocation, []string{"state"}) + if err != nil { + t.Fatalf("unrelated repository state lock was coupled to installation: %v", err) + } + if err := independent.Release(); err != nil { + t.Fatal(err) + } +} diff --git a/boatstack/internal/effects/prepared.go b/boatstack/internal/effects/prepared.go index fed30e8..93bca00 100644 --- a/boatstack/internal/effects/prepared.go +++ b/boatstack/internal/effects/prepared.go @@ -58,6 +58,8 @@ func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error if os.IsNotExist(err) { err = nil } + } else if mutation.TargetLink != "" { + err = atomicSymlink(mutation.Path, mutation.TargetLink) } else { err = atomicWrite(mutation.Path, mutation.Target, os.FileMode(mutation.Mode)) } @@ -73,7 +75,11 @@ func (p *preparedEffect) Rollback(context.Context) error { var rollbackErrors []error for index := len(p.applied) - 1; index >= 0; index-- { mutation := p.applied[index] - if mutation.PriorExists { + if mutation.PriorLink != "" { + if err := atomicSymlink(mutation.Path, mutation.PriorLink); err != nil { + rollbackErrors = append(rollbackErrors, err) + } + } else if mutation.PriorExists { if err := atomicWrite(mutation.Path, mutation.Prior, os.FileMode(mutation.Mode)); err != nil { rollbackErrors = append(rollbackErrors, err) } diff --git a/boatstack/internal/effects/prepared_test.go b/boatstack/internal/effects/prepared_test.go index bfd4a51..90a1efd 100644 --- a/boatstack/internal/effects/prepared_test.go +++ b/boatstack/internal/effects/prepared_test.go @@ -41,3 +41,33 @@ func TestPreparedEffectNeverAcceptsMixedEpochAtWriteBoundary(t *testing.T) { t.Fatalf("mixed epoch survived rollback: first=%q state=%q", firstValue, stateValue) } } + +func TestPreparedEffectRollsBackManagedLauncherSymlink(t *testing.T) { + // control-law: interrupted-program-update-restores-the-exact-prior-launcher + root := t.TempDir() + launcher := filepath.Join(root, "boatstack") + if err := os.Symlink("boatstack-old", launcher); err != nil { + t.Fatal(err) + } + blocker := filepath.Join(root, "not-a-directory") + if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil { + t.Fatal(err) + } + prepared := &preparedEffect{mutations: []ports.ResourceMutation{ + {Path: launcher, PriorExists: true, PriorLink: "boatstack-old", TargetLink: "boatstack-new", Mode: 0o700}, + {Path: filepath.Join(blocker, "state.json"), Target: []byte("new-state"), Mode: 0o600, InstallLast: true}, + }} + if _, err := prepared.Execute(context.Background()); err == nil { + t.Fatal("injected post-launcher failure was accepted") + } + if err := prepared.Rollback(context.Background()); err != nil { + t.Fatal(err) + } + target, err := os.Readlink(launcher) + if err != nil { + t.Fatal(err) + } + if target != "boatstack-old" { + t.Fatalf("launcher rollback target = %q, want boatstack-old", target) + } +} diff --git a/boatstack/internal/effects/recovery.go b/boatstack/internal/effects/recovery.go index c72cacc..12d9e56 100644 --- a/boatstack/internal/effects/recovery.go +++ b/boatstack/internal/effects/recovery.go @@ -33,20 +33,24 @@ func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.Controll } mutations := make([]ports.ResourceMutation, 0, len(record.Mutations)+2) for _, original := range record.Mutations { - if err := validateRecoveryPath(layout, original.Path); err != nil { + if err := validateRecoveryPath(layout, record.Admission, original.Path); err != nil { return nil, err } var target []byte + var targetLink string deleteResource := false if resume { target = original.Target + targetLink = original.TargetLink deleteResource = original.Delete + } else if original.PriorLink != "" { + targetLink = original.PriorLink } else if original.PriorExists { target = original.Prior } else { deleteResource = true } - mutation, mutationErr := mutationFor(original.Path, target, os.FileMode(original.Mode), original.InstallLast, deleteResource) + mutation, mutationErr := mutationForExactResource(original.Path, target, targetLink, os.FileMode(original.Mode), original.InstallLast, deleteResource) if mutationErr != nil { return nil, mutationErr } @@ -93,7 +97,7 @@ func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout po mutations := make([]ports.ResourceMutation, 0, len(record.Mutations)+2) for _, original := range record.Mutations { - if err := validateRecoveryPath(layout, original.Path); err != nil { + if err := validateRecoveryPath(layout, record.Admission, original.Path); err != nil { return nil, err } var target []byte @@ -203,7 +207,7 @@ func prepareJournalClosureFromRecord(pendingPath string, record journalRecord, o return []ports.ResourceMutation{archive, removePending}, nil } -func validateRecoveryPath(layout ports.ControllerLayout, path string) error { +func validateRecoveryPath(layout ports.ControllerLayout, admission protocol.Admission, path string) error { if !filepath.IsAbs(path) { return fmt.Errorf("interrupted transaction contains a non-absolute resource path") } @@ -216,5 +220,12 @@ func validateRecoveryPath(layout ports.ControllerLayout, path string) error { return nil } } + if runtimePath, ok := admission.Parameters.Get("runtime_path"); ok { + root := filepath.Dir(runtimePath) + relative, err := filepath.Rel(root, path) + if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return nil + } + } return fmt.Errorf("interrupted transaction resource escapes managed roots: %s", path) } diff --git a/boatstack/internal/effects/state_reducer.go b/boatstack/internal/effects/state_reducer.go index 5e0d784..dfcaacf 100644 --- a/boatstack/internal/effects/state_reducer.go +++ b/boatstack/internal/effects/state_reducer.go @@ -35,12 +35,27 @@ func applyStateTransition(state *durable.State, admission protocol.Admission, tr state.Phase = model.PhaseObserved case "repository.detach": state.Phase, state.Engagement = model.PhaseDormant, model.EngagementDormant - case "runtime.hydrate", "runtime.replace", "installation.update": + case "runtime.hydrate", "runtime.replace": state.Runtime = model.RuntimeVerified state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") state.RuntimePath, _ = admission.Parameters.Get("runtime_path") state.RuntimeSource, _ = admission.Parameters.Get("source_revision") state.Phase = settledPhase(*state) + case "installation.update": + state.Runtime = model.RuntimeVerified + state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") + state.RuntimePath, _ = admission.Parameters.Get("runtime_path") + state.RuntimeSource, _ = admission.Parameters.Get("source_revision") + case "installation.reconcile-update": + accepted, _ := admission.Parameters.Get("accept_obligation_change") + if accepted != "true" || admission.PriorProgramFingerprint == "" || admission.ProgramDeltaFingerprint == "" || state.ProgramFingerprint != admission.PriorProgramFingerprint { + return fmt.Errorf("reconciled installation update must bind and explicitly accept the exact prior-to-candidate program delta") + } + state.ProgramFingerprint = admission.ProgramFingerprint + state.Runtime = model.RuntimeVerified + state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") + state.RuntimePath, _ = admission.Parameters.Get("runtime_path") + state.RuntimeSource, _ = admission.Parameters.Get("source_revision") case "runtime.reconcile": state.Runtime, state.Recovery, state.Transaction = model.RuntimeVerified, model.RecoveryNone, model.TransactionNone state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") diff --git a/boatstack/internal/kernel/durable/state.go b/boatstack/internal/kernel/durable/state.go index 64e2624..e4d4a24 100644 --- a/boatstack/internal/kernel/durable/state.go +++ b/boatstack/internal/kernel/durable/state.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "path/filepath" "sort" "time" @@ -51,6 +52,8 @@ type State struct { RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` RuntimePath string `json:"runtime_path,omitempty"` RuntimeSource string `json:"runtime_source_revision,omitempty"` + LauncherPath string `json:"launcher_path,omitempty"` + LauncherFingerprint string `json:"launcher_fingerprint,omitempty"` PlanFingerprint string `json:"plan_fingerprint,omitempty"` WorkspaceBranch string `json:"workspace_branch,omitempty"` WorkspacePath string `json:"workspace_path,omitempty"` @@ -109,6 +112,12 @@ func (s State) Validate() error { if s.Runtime == model.RuntimeVerified && (s.RuntimeFingerprint == "" || s.RuntimePath == "" || s.RuntimeSource == "") { return fmt.Errorf("verified runtime requires path, source revision, and fingerprint") } + if (s.LauncherPath == "") != (s.LauncherFingerprint == "") { + return fmt.Errorf("managed launcher requires path and fingerprint together") + } + if s.LauncherPath != "" && !filepath.IsAbs(s.LauncherPath) { + return fmt.Errorf("managed launcher path must be absolute") + } if s.Configuration == model.ConfigurationVerified && s.ConfigFingerprint == "" { return fmt.Errorf("verified configuration requires a fingerprint") } diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go index 9c4e56b..39fecdc 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -405,6 +405,38 @@ func TestSyntheticStartVerifyTerminalContractNeedsNoStandardFlowFacet(t *testing } } +func TestExactPermittedRecoveryRemainsReachableAcrossProgramDrift(t *testing.T) { + // control-law: interrupted-program-update-can-rollback-under-either-program-epoch + observed := recoveryObservation("program-change-pending") + observed.RecordedProgramFingerprint = strings.Repeat("a", 64) + evidence := observed.Phase.Evidence[0] + recovery := observed.RecoveryInfo.Value + recovery.Permitted = []string{"test.recover"} + observed.RecoveryInfo = model.Known(recovery, evidence) + snapshot, err := model.CanonicalizeForProgram(observed, syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + if snapshot.Program.Value != model.ProgramDrift { + t.Fatalf("program state = %s, want drift", snapshot.Program.Value) + } + control := supervisor.New(testRegistry(t), syntheticGoalContracts(t)) + decision := control.Resolve(snapshot, snapshot.Goal.Value, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "test.recover") + if decision.Kind != supervisor.DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "test.recover" { + t.Fatalf("permitted recovery across drift = %+v", decision) + } + recovery.Permitted = []string{"recovery.escalate"} + observed.RecoveryInfo = model.Known(recovery, evidence) + snapshot, err = model.CanonicalizeForProgram(observed, syntheticProgramFingerprint) + if err != nil { + t.Fatal(err) + } + blocked := control.Resolve(snapshot, snapshot.Goal.Value, catalog.AuthoritySet{catalog.AuthorityRepository: true}, "test.recover") + if blocked.Kind != supervisor.DecisionUnresolved { + t.Fatalf("unpermitted recovery crossed program drift: %+v", blocked) + } +} + func TestIdempotencyReceiptCannotHideUncommittedRecoveryJournal(t *testing.T) { // control-law: receipt-before-journal-commit-is-not-a-clean-replay now := time.Unix(30, 0).UTC() diff --git a/boatstack/internal/kernel/model/health.go b/boatstack/internal/kernel/model/health.go new file mode 100644 index 0000000..77d3a91 --- /dev/null +++ b/boatstack/internal/kernel/model/health.go @@ -0,0 +1,15 @@ +package model + +// OperationalHealth is a read-only projection of canonical facts used by +// diagnostics. It cannot admit or execute a transition. +type OperationalHealth struct { + RuntimeVerified bool + RecoveryRequired bool +} + +func ProjectOperationalHealth(snapshot Snapshot) OperationalHealth { + return OperationalHealth{ + RuntimeVerified: snapshot.Runtime.Status == FactKnown && snapshot.Runtime.Value == RuntimeVerified, + RecoveryRequired: snapshot.Phase.Value == PhaseRecovery || snapshot.Recovery.Value != RecoveryNone || snapshot.Transaction.Value != TransactionNone, + } +} diff --git a/boatstack/internal/kernel/ports/ports.go b/boatstack/internal/kernel/ports/ports.go index 0c80bb5..f9653ef 100644 --- a/boatstack/internal/kernel/ports/ports.go +++ b/boatstack/internal/kernel/ports/ports.go @@ -78,6 +78,8 @@ type ResourceMutation struct { Path string `json:"path"` Prior []byte `json:"prior,omitempty"` Target []byte `json:"target,omitempty"` + PriorLink string `json:"prior_link,omitempty"` + TargetLink string `json:"target_link,omitempty"` PriorExists bool `json:"prior_exists"` Mode uint32 `json:"mode"` InstallLast bool `json:"install_last,omitempty"` diff --git a/boatstack/internal/kernel/protocol/admission.go b/boatstack/internal/kernel/protocol/admission.go index 0f3ebef..082322a 100644 --- a/boatstack/internal/kernel/protocol/admission.go +++ b/boatstack/internal/kernel/protocol/admission.go @@ -12,23 +12,25 @@ import ( const AdmissionSchemaVersion = 2 type Admission struct { - SchemaVersion int `json:"schema_version"` - ID string `json:"id"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionVersion int `json:"transition_version"` - ProgramFingerprint string `json:"program_fingerprint"` - SnapshotFingerprint string `json:"snapshot_fingerprint"` - SourceRevision string `json:"source_revision,omitempty"` - WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` - SourcePhase model.ProtocolPhase `json:"source_phase"` - Invocation model.InvocationContext `json:"invocation"` - Goal model.Goal `json:"goal"` - Authority AuthorityBundle `json:"authority"` - Parameters Parameters `json:"parameters,omitempty"` - Evidence []string `json:"evidence"` - IdempotencyKey string `json:"idempotency_key"` - IssuedAt time.Time `json:"issued_at"` - ExpiresAt time.Time `json:"expires_at"` + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionVersion int `json:"transition_version"` + ProgramFingerprint string `json:"program_fingerprint"` + PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` + SnapshotFingerprint string `json:"snapshot_fingerprint"` + SourceRevision string `json:"source_revision,omitempty"` + WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"` + SourcePhase model.ProtocolPhase `json:"source_phase"` + Invocation model.InvocationContext `json:"invocation"` + Goal model.Goal `json:"goal"` + Authority AuthorityBundle `json:"authority"` + Parameters Parameters `json:"parameters,omitempty"` + Evidence []string `json:"evidence"` + IdempotencyKey string `json:"idempotency_key"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` } func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition, authority AuthorityBundle, parameters Parameters, now time.Time, lifetime time.Duration) (Admission, error) { @@ -45,6 +47,14 @@ func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.T SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Goal: goal, Authority: authority.canonical(), Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(), } + if snapshot.RecordedProgramFingerprint != "" && snapshot.RecordedProgramFingerprint != snapshot.ProgramFingerprint { + a.PriorProgramFingerprint = snapshot.RecordedProgramFingerprint + delta, err := ProgramDeltaFingerprint(snapshot.RecordedProgramFingerprint, snapshot.ProgramFingerprint) + if err != nil { + return Admission{}, err + } + a.ProgramDeltaFingerprint = delta + } key, err := contentID("idem-", struct { Transition catalog.TransitionID `json:"transition"` Snapshot string `json:"snapshot"` @@ -131,6 +141,19 @@ func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, tra if a.ProgramFingerprint != snapshot.ProgramFingerprint { return fmt.Errorf("admission %q is bound to a different control program", a.ID) } + expectedPrior := "" + if snapshot.RecordedProgramFingerprint != "" && snapshot.RecordedProgramFingerprint != snapshot.ProgramFingerprint { + expectedPrior = snapshot.RecordedProgramFingerprint + } + if a.PriorProgramFingerprint != expectedPrior { + return fmt.Errorf("admission %q is bound to a different prior control program", a.ID) + } + if a.PriorProgramFingerprint != "" { + delta, err := ProgramDeltaFingerprint(a.PriorProgramFingerprint, a.ProgramFingerprint) + if err != nil || delta != a.ProgramDeltaFingerprint { + return fmt.Errorf("admission %q has an invalid program delta binding", a.ID) + } + } if snapshot.Phase.Status != model.FactKnown || a.SourcePhase != snapshot.Phase.Value { return fmt.Errorf("admission %q is bound to a different source phase", a.ID) } @@ -275,6 +298,21 @@ func (a Admission) ValidateIdentity() error { if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || len(a.ProgramFingerprint) != 64 || a.SnapshotFingerprint == "" || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) { return fmt.Errorf("admission: invalid schema, identity, source, or lifetime") } + if (a.PriorProgramFingerprint == "") != (a.ProgramDeltaFingerprint == "") { + return fmt.Errorf("admission has incomplete program delta identity") + } + if a.PriorProgramFingerprint != "" { + delta, err := ProgramDeltaFingerprint(a.PriorProgramFingerprint, a.ProgramFingerprint) + if err != nil || delta != a.ProgramDeltaFingerprint { + return fmt.Errorf("admission has invalid program delta identity") + } + } + if a.TransitionID == "installation.reconcile-update" { + accepted, _ := a.Parameters.Get("accept_obligation_change") + if a.PriorProgramFingerprint == "" || accepted != "true" { + return fmt.Errorf("reconciled installation admission lacks exact program acceptance") + } + } if err := a.Invocation.Validate(true); err != nil { return err } diff --git a/boatstack/internal/kernel/protocol/program_change.go b/boatstack/internal/kernel/protocol/program_change.go new file mode 100644 index 0000000..268bab5 --- /dev/null +++ b/boatstack/internal/kernel/protocol/program_change.go @@ -0,0 +1,18 @@ +package protocol + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// ProgramDeltaFingerprint binds one exact persisted control program to one +// exact candidate control program. It identifies the accepted obligation +// delta without granting authority to apply it. +func ProgramDeltaFingerprint(prior, candidate string) (string, error) { + if len(prior) != 64 || len(candidate) != 64 || prior == candidate { + return "", fmt.Errorf("program delta requires distinct 64-character prior and candidate fingerprints") + } + digest := sha256.Sum256([]byte("boatstack-program-delta-v1\x00" + prior + "\x00" + candidate)) + return hex.EncodeToString(digest[:]), nil +} diff --git a/boatstack/internal/kernel/protocol/program_change_test.go b/boatstack/internal/kernel/protocol/program_change_test.go new file mode 100644 index 0000000..387d717 --- /dev/null +++ b/boatstack/internal/kernel/protocol/program_change_test.go @@ -0,0 +1,26 @@ +package protocol + +import "testing" + +func TestProgramDeltaFingerprintBindsDirectionAndExactEndpoints(t *testing.T) { + // control-law: program-change-authority-binds-the-exact-directed-delta + oldProgram := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + newProgram := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + delta, err := ProgramDeltaFingerprint(oldProgram, newProgram) + if err != nil { + t.Fatal(err) + } + reversed, err := ProgramDeltaFingerprint(newProgram, oldProgram) + if err != nil { + t.Fatal(err) + } + if len(delta) != 64 || delta == reversed { + t.Fatalf("directed program delta fingerprints = %q and %q", delta, reversed) + } + if _, err := ProgramDeltaFingerprint(oldProgram, oldProgram); err == nil { + t.Fatal("identical program endpoints produced a change fingerprint") + } + if _, err := ProgramDeltaFingerprint("short", newProgram); err == nil { + t.Fatal("malformed prior program identity was accepted") + } +} diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go index 6f41b57..453e999 100644 --- a/boatstack/internal/kernel/protocol/receipt.go +++ b/boatstack/internal/kernel/protocol/receipt.go @@ -20,29 +20,34 @@ const ( ) type TransitionReceipt struct { - SchemaVersion int `json:"schema_version"` - ID string `json:"id"` - FlowID string `json:"flow_id"` - Sequence uint64 `json:"sequence"` - TransitionID catalog.TransitionID `json:"transition_id"` - TransitionVersion int `json:"transition_version"` - ProgramFingerprint string `json:"program_fingerprint"` - AdmissionID string `json:"admission_id"` - GoalID string `json:"goal_id"` - GoalKind model.GoalKind `json:"goal_kind"` - DeliveryID string `json:"delivery_id"` - SourceFingerprint string `json:"source_fingerprint"` - TargetFingerprint string `json:"target_fingerprint"` - AuthorityClasses []string `json:"authority_classes"` - IdempotencyKey string `json:"idempotency_key"` - Verifier string `json:"verifier"` - Outcome Outcome `json:"outcome"` - Recovery catalog.TransitionID `json:"recovery,omitempty"` - Terminal model.TerminalStatus `json:"terminal"` - StartedAt time.Time `json:"started_at"` - CompletedAt time.Time `json:"completed_at"` - DurationNanoseconds int64 `json:"duration_nanoseconds"` - FailureClass string `json:"failure_class,omitempty"` + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + FlowID string `json:"flow_id"` + Sequence uint64 `json:"sequence"` + TransitionID catalog.TransitionID `json:"transition_id"` + TransitionVersion int `json:"transition_version"` + ProgramFingerprint string `json:"program_fingerprint"` + PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` + ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` + RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` + RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` + AdmissionID string `json:"admission_id"` + GoalID string `json:"goal_id"` + GoalKind model.GoalKind `json:"goal_kind"` + DeliveryID string `json:"delivery_id"` + SourceFingerprint string `json:"source_fingerprint"` + TargetFingerprint string `json:"target_fingerprint"` + AuthorityClasses []string `json:"authority_classes"` + IdempotencyKey string `json:"idempotency_key"` + Verifier string `json:"verifier"` + Outcome Outcome `json:"outcome"` + Recovery catalog.TransitionID `json:"recovery,omitempty"` + Terminal model.TerminalStatus `json:"terminal"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` + DurationNanoseconds int64 `json:"duration_nanoseconds"` + FailureClass string `json:"failure_class,omitempty"` } func NewReceipt(flowID string, sequence uint64, admission Admission, transition catalog.Transition, target model.Snapshot, startedAt, completedAt time.Time, outcome Outcome, failureClass string) (TransitionReceipt, error) { @@ -69,6 +74,16 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition StartedAt: startedAt.UTC(), CompletedAt: completedAt.UTC(), DurationNanoseconds: completedAt.Sub(startedAt).Nanoseconds(), FailureClass: failureClass, } + receipt.PriorProgramFingerprint = admission.PriorProgramFingerprint + receipt.ProgramDeltaFingerprint = admission.ProgramDeltaFingerprint + if accepted, ok := admission.Parameters.Get("accept_obligation_change"); ok && accepted == "true" { + receipt.ProgramChangeAccepted = true + } + receipt.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") + receipt.RuntimeSourceRevision, _ = admission.Parameters.Get("source_revision") + if transition.Policy.ReconcilesProgram && receipt.RuntimeFingerprint == "" { + receipt.RuntimeFingerprint = admission.Invocation.RuntimeFingerprint + } identity := receipt identity.ID = "" var err error @@ -91,6 +106,21 @@ func (r TransitionReceipt) Validate() error { default: return fmt.Errorf("receipt has invalid outcome %q", r.Outcome) } + if (r.PriorProgramFingerprint == "") != (r.ProgramDeltaFingerprint == "") { + return fmt.Errorf("receipt has incomplete program delta identity") + } + if r.PriorProgramFingerprint != "" { + delta, err := ProgramDeltaFingerprint(r.PriorProgramFingerprint, r.ProgramFingerprint) + if err != nil || delta != r.ProgramDeltaFingerprint { + return fmt.Errorf("receipt has invalid program delta identity") + } + } + if r.ProgramChangeAccepted && (r.PriorProgramFingerprint == "" || len(r.RuntimeFingerprint) != 64) { + return fmt.Errorf("receipt accepts a program change without exact delta and runtime identity") + } + if r.TransitionID == "installation.reconcile-update" && (!r.ProgramChangeAccepted || r.PriorProgramFingerprint == "" || len(r.RuntimeFingerprint) != 64 || r.RuntimeSourceRevision == "") { + return fmt.Errorf("reconciled installation receipt lacks exact program and runtime identity") + } identity := r want := identity.ID identity.ID = "" diff --git a/boatstack/internal/kernel/supervisor/supervisor.go b/boatstack/internal/kernel/supervisor/supervisor.go index c12b79b..fc776cb 100644 --- a/boatstack/internal/kernel/supervisor/supervisor.go +++ b/boatstack/internal/kernel/supervisor/supervisor.go @@ -18,6 +18,7 @@ const ( DecisionBlocked DecisionKind = "BLOCKED" DecisionRefused DecisionKind = "REFUSED" DecisionUnresolved DecisionKind = "UNRESOLVED" + ReasonProgramDrift = "compiled control program drift requires explicit reconciliation" ) type Decision struct { @@ -53,8 +54,8 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority } if snapshot.Program.Value == model.ProgramDrift { transition, ok := s.registry.Lookup(requested) - if !ok || !transition.Policy.ReconcilesProgram { - base.Kind, base.Reason = DecisionUnresolved, "compiled control program drift requires explicit reconciliation" + if !ok || (!transition.Policy.ReconcilesProgram && !permittedProgramDriftRecovery(snapshot, transition)) { + base.Kind, base.Reason = DecisionUnresolved, ReasonProgramDrift return base } } @@ -155,6 +156,18 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority return base } +func permittedProgramDriftRecovery(snapshot model.Snapshot, transition catalog.Transition) bool { + if transition.Class != catalog.EventRecovery || snapshot.Phase.Status != model.FactKnown || snapshot.Phase.Value != model.PhaseRecovery || snapshot.RecoveryInfo.Status != model.FactKnown { + return false + } + for _, permitted := range snapshot.RecoveryInfo.Value.Permitted { + if permitted == string(transition.ID) { + return true + } + } + return false +} + func targetAlreadySatisfied(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition) bool { if transition.Policy.RechecksExternalState { return false diff --git a/boatstack/internal/plant/observer.go b/boatstack/internal/plant/observer.go index a7f901c..ba28f7f 100644 --- a/boatstack/internal/plant/observer.go +++ b/boatstack/internal/plant/observer.go @@ -109,6 +109,16 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) runtimeState = model.RuntimeWrongSource } } + if state.LauncherPath != "" { + launcherEvidence, launcherFingerprint, launcherExists, launcherErr := observeLauncher(state.LauncherPath, now) + if launcherErr != nil { + return model.Observation{}, launcherErr + } + runtimeEvidence = append(runtimeEvidence, launcherEvidence) + if !launcherExists || launcherFingerprint != state.LauncherFingerprint { + runtimeState = model.RuntimePartiallyPublished + } + } verification := state.Verification if state.SourceRevision != "" && head != "" && state.SourceRevision != head { verification = model.VerificationStale @@ -163,7 +173,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) } recordedProgramFingerprint := state.ProgramFingerprint if pending.ProgramFingerprint != "" { - if recordedProgramFingerprint != "" && recordedProgramFingerprint != pending.ProgramFingerprint { + if recordedProgramFingerprint != "" && recordedProgramFingerprint != pending.ProgramFingerprint && !pending.ReconcilesProgram { return model.Observation{}, fmt.Errorf("durable state and pending transaction bind different control programs") } recordedProgramFingerprint = pending.ProgramFingerprint @@ -219,6 +229,36 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) }, nil } +func observeLauncher(path string, now time.Time) (model.Evidence, string, bool, error) { + evidence := model.Evidence{Source: "launcher:" + path, ObservedAt: now} + info, err := os.Lstat(path) + if os.IsNotExist(err) { + evidence.Fingerprint = hashBytes([]byte("absent:" + path)) + return evidence, "", false, nil + } + if err != nil { + return model.Evidence{}, "", false, err + } + var fingerprint string + if info.Mode()&os.ModeSymlink != 0 { + target, readErr := os.Readlink(path) + if readErr != nil { + return model.Evidence{}, "", false, readErr + } + fingerprint = hashBytes([]byte("symlink\x00" + target)) + } else if info.Mode().IsRegular() { + raw, readErr := os.ReadFile(path) + if readErr != nil { + return model.Evidence{}, "", false, readErr + } + fingerprint = hashBytes(raw) + } else { + return model.Evidence{}, "", false, fmt.Errorf("managed launcher is neither a regular file nor symlink: %s", path) + } + evidence.Fingerprint = fingerprint + return evidence, fingerprint, true, nil +} + func (o Observer) highRiskChange(ctx context.Context, repository, defaultBranch string, patterns []string) (bool, error) { if len(patterns) == 0 { return false, nil @@ -546,12 +586,13 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta } type pendingJournalHeader struct { - SchemaVersion int `json:"schema_version"` - TransitionID string `json:"transition_id"` - TransitionClass string `json:"transition_class"` - Status string `json:"status"` - Reason string `json:"reason"` - Admission struct { + SchemaVersion int `json:"schema_version"` + TransitionID string `json:"transition_id"` + TransitionClass string `json:"transition_class"` + ReconcilesProgram bool `json:"reconciles_program"` + Status string `json:"status"` + Reason string `json:"reason"` + Admission struct { ID string `json:"id"` ProgramFingerprint string `json:"program_fingerprint"` SourcePhase model.ProtocolPhase `json:"source_phase"` @@ -573,6 +614,7 @@ type pendingJournalSet struct { Transaction model.TransactionContext TransactionState model.TransactionState ProgramFingerprint string + ReconcilesProgram bool } type pendingJournalRecord struct { @@ -654,6 +696,7 @@ func pendingJournalEvidence(root, ignoreAdmissionID string, now time.Time) (pend set := pendingJournalSet{ Found: true, Evidence: []model.Evidence{evidence}, TransactionState: transactionState, ProgramFingerprint: header.Admission.ProgramFingerprint, + ReconcilesProgram: header.ReconcilesProgram, Recovery: model.RecoveryContext{TransactionID: header.Admission.ID, Cause: cause, SourcePhase: header.Admission.SourcePhase, Permitted: permitted, BudgetRemaining: budget, Resumption: header.Admission.SourcePhase}, Transaction: model.TransactionContext{ID: header.Admission.ID, TransitionID: header.TransitionID, Status: header.Status, ResourceDigests: resourceDigests, ExternalPossible: external}, } @@ -747,6 +790,8 @@ func recoveryContract(transitionID string, external, staged bool, budget int) [] return []string{"publication.reconcile", "recovery.escalate"} } switch transitionID { + case "installation.reconcile-update": + return []string{"recovery.rollback", "recovery.escalate"} case "runtime.hydrate", "runtime.replace", "installation.update", "installation.initialize": return []string{"runtime.reconcile", "recovery.rollback", "recovery.escalate"} case "configuration.initialize", "configuration.mutate": diff --git a/boatstack/internal/surfaces/protocol.go b/boatstack/internal/surfaces/protocol.go index 36818a8..e9cde25 100644 --- a/boatstack/internal/surfaces/protocol.go +++ b/boatstack/internal/surfaces/protocol.go @@ -101,10 +101,21 @@ type DoctorReport struct { EnabledExtensions []string `json:"enabled_extensions,omitempty"` ProgramFingerprint string `json:"program_fingerprint"` UnresolvedProgramDrift bool `json:"unresolved_program_drift"` + RuntimeHealthy bool `json:"runtime_healthy"` + UpdateReady bool `json:"update_ready"` + RecoveryRequired bool `json:"recovery_required"` Snapshot string `json:"snapshot,omitempty"` Detail string `json:"detail"` } +type ProgramChange struct { + PriorProgramFingerprint string `json:"prior_program_fingerprint"` + CandidateProgramFingerprint string `json:"candidate_program_fingerprint"` + ProgramDeltaFingerprint string `json:"program_delta_fingerprint"` + RequiredTransition catalog.TransitionID `json:"required_transition"` + AcceptanceFlag string `json:"acceptance_flag"` +} + type Response struct { SchemaVersion int `json:"schema_version"` Operation Operation `json:"operation"` @@ -117,6 +128,7 @@ type Response struct { Catalog []catalog.Transition `json:"catalog,omitempty"` Events []map[string]any `json:"events,omitempty"` Doctor *DoctorReport `json:"doctor,omitempty"` + ProgramChange *ProgramChange `json:"program_change,omitempty"` Guard *supervisor.GuardDecision `json:"guard,omitempty"` Error string `json:"error,omitempty"` } diff --git a/boatstack/kernel.go b/boatstack/kernel.go index fe18b47..2206955 100644 --- a/boatstack/kernel.go +++ b/boatstack/kernel.go @@ -114,6 +114,7 @@ func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces. if resolution.Snapshot.Fingerprint != "" { response.Snapshot = &resolution.Snapshot } + response.ProgramChange = programChangeFor(response.Snapshot) if resolveErr != nil { response.Error = resolveErr.Error() return response, resolveErr @@ -139,6 +140,7 @@ func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces. if result.Receipt.ID != "" { response.Receipt = &result.Receipt } + response.ProgramChange = programChangeFor(response.Snapshot) response.Replayed = result.Replayed if applyErr != nil { response.Error = applyErr.Error() @@ -173,12 +175,27 @@ func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces. return response, canonicalErr } response.Snapshot = &snapshot + response.ProgramChange = programChangeFor(response.Snapshot) + health := model.ProjectOperationalHealth(snapshot) report.UnresolvedProgramDrift = snapshot.Program.Status != model.FactKnown || snapshot.Program.Value == model.ProgramDrift - report.Healthy = k.registry.Len() == summary.TotalTransitionCount && !report.UnresolvedProgramDrift + report.RuntimeHealthy = health.RuntimeVerified + report.RecoveryRequired = health.RecoveryRequired + for _, transitionID := range []catalog.TransitionID{"installation.update", "installation.reconcile-update"} { + if transition, ok := k.registry.Lookup(transitionID); ok && transition.SourceMatches(snapshot) { + report.UpdateReady = true + } + } + report.Healthy = k.registry.Len() == summary.TotalTransitionCount && !report.UnresolvedProgramDrift && report.RuntimeHealthy && report.UpdateReady && !report.RecoveryRequired report.Snapshot = snapshot.Fingerprint report.Detail = "Kernel, observation, and compiled control program are valid" if report.UnresolvedProgramDrift { - report.Detail = "compiled control program drift requires explicit reconciliation" + report.Detail = supervisor.ReasonProgramDrift + } else if report.RecoveryRequired { + report.Detail = "an interrupted transaction requires exact recovery" + } else if !report.RuntimeHealthy { + report.Detail = "runtime or managed launcher is not verified" + } else if !report.UpdateReady { + report.Detail = "no structurally admissible installation update continuation" } response.Doctor = &report return response, nil @@ -214,6 +231,20 @@ func (k Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces. } } +func programChangeFor(snapshot *model.Snapshot) *surfaces.ProgramChange { + if snapshot == nil || snapshot.Program.Status != model.FactKnown || snapshot.Program.Value != model.ProgramDrift { + return nil + } + delta, err := protocol.ProgramDeltaFingerprint(snapshot.RecordedProgramFingerprint, snapshot.ProgramFingerprint) + if err != nil { + return nil + } + return &surfaces.ProgramChange{ + PriorProgramFingerprint: snapshot.RecordedProgramFingerprint, CandidateProgramFingerprint: snapshot.ProgramFingerprint, + ProgramDeltaFingerprint: delta, RequiredTransition: "installation.reconcile-update", AcceptanceFlag: "--accept-program-change", + } +} + func (k Kernel) deriveRepositoryAuthority(ctx context.Context, invocation model.InvocationContext, bundle protocol.AuthorityBundle) (protocol.AuthorityBundle, error) { for _, receipt := range bundle.Receipts { if receipt.Class == catalog.AuthorityRepository { diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go index cbc2522..0b13156 100644 --- a/boatstack/sdk/sdk.go +++ b/boatstack/sdk/sdk.go @@ -34,6 +34,7 @@ const ( type Request = surfaces.Request type Response = surfaces.Response type DoctorReport = surfaces.DoctorReport +type ProgramChange = surfaces.ProgramChange type Goal = model.Goal type GoalKind = model.GoalKind diff --git a/docs/architecture/boatstack-v2-kernel.md b/docs/architecture/boatstack-v2-kernel.md index f782f3a..b689709 100644 --- a/docs/architecture/boatstack-v2-kernel.md +++ b/docs/architecture/boatstack-v2-kernel.md @@ -163,15 +163,15 @@ must fail closed until explicit reconciliation. ### Compiled transition ownership -The current 62-event Standard distribution is classified from compiled +The current 63-event Standard distribution is classified from compiled component declarations: | Owner | Families | Count | | --- | --- | ---: | -| CoreSystem | `engagement.*`, `invocation.*`, `repository.*`, `runtime.*`, `configuration.*`, `installation.*`, `catalog.*`, `goal.*`, `recovery.*`, `external.*` | 32 | +| CoreSystem | `engagement.*`, `invocation.*`, `repository.*`, `runtime.*`, `configuration.*`, `installation.*`, `catalog.*`, `goal.*`, `recovery.*`, `external.*` | 33 | | StandardFlow | `plan.*`, `workspace.*`, `gate.*`, `evidence.*`, `delivery.*`, `publication.*` | 30 | | Extensions in the default distribution | none | 0 | -| **Compiled total** | one registry | **62** | +| **Compiled total** | one registry | **63** | The CoreSystem ownership of `external.*` declares the event vocabulary and observation boundary; StandardFlow consumes the bounded publication and @@ -489,14 +489,14 @@ gain event authority merely because they are commands. ## 7. Transition registry -The compiled Standard distribution contains **62 semantic events**. This count +The compiled Standard distribution contains **63 semantic events**. This count is generated from CoreSystem and StandardFlow declaration bytes and must remain synchronized with this table. | Family | Count | Required IDs | | --- | ---: | --- | | Invocation and engagement | 6 | `engagement.begin`, `engagement.renew`, `engagement.release`, `invocation.rebind`, `repository.attach`, `repository.detach` | -| Installation, runtime, configuration | 8 | `runtime.hydrate`, `runtime.replace`, `runtime.reconcile`, `configuration.initialize`, `configuration.mutate`, `configuration.reconcile`, `installation.initialize`, `installation.update` | +| Installation, runtime, configuration | 9 | `runtime.hydrate`, `runtime.replace`, `runtime.reconcile`, `configuration.initialize`, `configuration.mutate`, `configuration.reconcile`, `installation.initialize`, `installation.update`, `installation.reconcile-update` | | Catalog identity | 1 | `catalog.reconcile` | | Goal and plan | 9 | `goal.configure`, `plan.create`, `plan.validate`, `plan.approve`, `plan.activate`, `plan.amend`, `plan.approve-amendment`, `plan.invalidate`, `plan.abandon` | | Workspace | 8 | `workspace.cut`, `workspace.sync`, `workspace.activate`, `workspace.publish`, `workspace.cleanup`, `workspace.reap`, `workspace.abandon`, `workspace.reconcile` | @@ -731,7 +731,7 @@ SurfaceRequest { SurfaceResponse { schema_version, operation, goal?, snapshot?, decision?, admission?, receipt?, - replayed?, catalog?, events?, doctor?, guard?, error? + replayed?, catalog?, events?, doctor?, program_change?, guard?, error? } ``` @@ -836,9 +836,9 @@ Capability analysis records three separate dispositions without modifying Locus: The executable registry now deterministically generates the checked [safety model](boatstack-v2-locus-safety.json) and -[liveness model](boatstack-v2-locus-liveness.json). Both contain exactly the 62 +[liveness model](boatstack-v2-locus-liveness.json). Both contain exactly the 63 runtime events. The liveness abstraction expands the declared phase predicates -to 427 inferred stable-phase edges over eight reachable phases; the safety +to 496 inferred stable-phase edges over eight reachable phases; the safety model adds one guarded counterfactual edge and `UNADMITTED_EFFECT` state. Repository and Go tests reject byte drift or an alphabet mismatch. @@ -848,13 +848,18 @@ Observed Locus runs over those generated artifacts produced: | --- | --- | --- | | `verification.trace-refinement` | the programmable ControlProgram protocol refines the preimplementation Kernel protocol with no distinguishing trace | accepted finite-model result; advisory claim | | `verification.conservative-feature-extension` | the reference release-note extension is conservative across all six checks with no violation | accepted bounded-extension result; advisory claim | -| `verification.safety-reachability` | `UNADMITTED_EFFECT` is unreachable; result `res-6e8d6372eea4a7aaf2fcfa8ce5fcc91271b1f99a208f34006ba952d9825343a1` | accepted finite-model result; advisory claim | -| `verification.guard-essentiality` | `exact-admission` is essential; removing it admits `DORMANT --publication.execute--> UNADMITTED_EFFECT`; result `res-10a40fec10fa47086e1e5ef83ee3434cffb461e30166961e4cc18d08812f2caa` | accepted finite-model result; advisory claim | -| `control.nonblockingness` | all eight reachable stable phases are coreachable; no blocking states; result `res-a6a465d2e50cd830e0b95777f631ca9254d475757a6941c4d6922b2b7ff701f4` | accepted finite-model result; advisory claim | +| `verification.safety-reachability` | `UNADMITTED_EFFECT` is unreachable; result `res-4dadb41740df7808f42ad554647e0f7d8cb3c3af1967df50a39be255e49d228b` | accepted finite-model result; advisory claim | +| `verification.guard-essentiality` | `exact-admission` is essential; removing it admits `DORMANT --publication.execute--> UNADMITTED_EFFECT`; result `res-90a51acda26e2c32b523ca9ca0024ab5c9bba4388c9737701f3e098f0fa83802` | accepted finite-model result; advisory claim | +| `control.nonblockingness` | all eight reachable stable phases are coreachable; no blocking states; result `res-31b004b60ddf87a2941192591639c18ac0eb7be3bd83fd38cb3a73a58cb2f07b` | accepted finite-model result; advisory claim | | `practice.zca-projection` | both shipped slices cover all nine declared facets and all 14 bounded Go-module sites | accepted source-bound projection; no runtime authority granted | | declared-slice completeness | every declared event-completeness obligation and the conservative-extension facet obligation were accepted as complete | closes the modeled source, writer, command, lifecycle, reducer, and generated-artifact inventories | The content-addressed Locus results and derivations are archived in Observatory. +The current generated-catalog derivations are +`drv-51b05a959b98e8bb395f681a7588c0eecd3566ac9373e3e48d011b4b332ce4b9` +for safety/guard essentiality and +`drv-602cda5444f193f68e69531f4cb8ba8c6ca019f4295661bd670919dd88356847` +for nonblockingness. They remain advisory because each model deliberately names facts outside its bounded source slice rather than treating them as assumptions. diff --git a/docs/architecture/boatstack-v2-locus-liveness.json b/docs/architecture/boatstack-v2-locus-liveness.json index 31796d9..6db6055 100644 --- a/docs/architecture/boatstack-v2-locus-liveness.json +++ b/docs/architecture/boatstack-v2-locus-liveness.json @@ -250,6 +250,12 @@ "observable": true, "basis": "observed" }, + { + "id": "installation.reconcile-update", + "controllable": true, + "observable": true, + "basis": "observed" + }, { "id": "installation.update", "controllable": true, @@ -3590,9 +3596,86 @@ ], "basis": "inferred" }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.reconcile-update", "to": "OBSERVED", "evidence": [ 0, @@ -3603,7 +3686,7 @@ }, { "from": "OBSERVED", - "event": "installation.update", + "event": "installation.reconcile-update", "to": "ACTIVE", "evidence": [ 0, @@ -3614,7 +3697,18 @@ }, { "from": "OBSERVED", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.reconcile-update", "to": "TERMINAL", "evidence": [ 0, @@ -3623,9 +3717,31 @@ ], "basis": "inferred" }, + { + "from": "OBSERVED", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "ACTIVE", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.reconcile-update", "to": "OBSERVED", "evidence": [ 0, @@ -3636,7 +3752,7 @@ }, { "from": "ACTIVE", - "event": "installation.update", + "event": "installation.reconcile-update", "to": "ACTIVE", "evidence": [ 0, @@ -3647,7 +3763,150 @@ }, { "from": "ACTIVE", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", "to": "TERMINAL", "evidence": [ 0, @@ -3656,6 +3915,479 @@ ], "basis": "inferred" }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", "event": "invocation.rebind", diff --git a/docs/architecture/boatstack-v2-locus-safety.json b/docs/architecture/boatstack-v2-locus-safety.json index 5d0a7e3..950254f 100644 --- a/docs/architecture/boatstack-v2-locus-safety.json +++ b/docs/architecture/boatstack-v2-locus-safety.json @@ -253,6 +253,12 @@ "observable": true, "basis": "observed" }, + { + "id": "installation.reconcile-update", + "controllable": true, + "observable": true, + "basis": "observed" + }, { "id": "installation.update", "controllable": true, @@ -3593,9 +3599,86 @@ ], "basis": "inferred" }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.reconcile-update", "to": "OBSERVED", "evidence": [ 0, @@ -3606,7 +3689,7 @@ }, { "from": "OBSERVED", - "event": "installation.update", + "event": "installation.reconcile-update", "to": "ACTIVE", "evidence": [ 0, @@ -3617,7 +3700,18 @@ }, { "from": "OBSERVED", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.reconcile-update", "to": "TERMINAL", "evidence": [ 0, @@ -3626,9 +3720,31 @@ ], "basis": "inferred" }, + { + "from": "OBSERVED", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "ACTIVE", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.reconcile-update", "to": "OBSERVED", "evidence": [ 0, @@ -3639,7 +3755,7 @@ }, { "from": "ACTIVE", - "event": "installation.update", + "event": "installation.reconcile-update", "to": "ACTIVE", "evidence": [ 0, @@ -3650,7 +3766,150 @@ }, { "from": "ACTIVE", - "event": "installation.update", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", "to": "TERMINAL", "evidence": [ 0, @@ -3659,6 +3918,479 @@ ], "basis": "inferred" }, + { + "from": "TERMINAL", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.reconcile-update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "DORMANT", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "OBSERVED", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ACTIVE", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "FRONTIER", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "TERMINAL", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "DORMANT", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "OBSERVED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "ACTIVE", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "FRONTIER", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "TERMINAL", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, + { + "from": "ABANDONED", + "event": "installation.update", + "to": "ABANDONED", + "evidence": [ + 0, + 1, + 4 + ], + "basis": "inferred" + }, { "from": "OBSERVED", "event": "invocation.rebind", diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md index 4bac11a..2b42308 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-v2-transition-catalog.md @@ -1,44 +1,45 @@ # Boatstack compiled transition catalog -Registry size: **62** transitions. Event classes: authority 9; owned-local 31; owned-external 2; recovery 7; observed-external 13. +Registry size: **63** transitions. Event classes: authority 9; owned-local 32; owned-external 2; recovery 7; observed-external 13. Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `workspace`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `goal`. | Transition | Origin | Owner | Selection | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Verifier | Recovery | Cost | |---|---|---|---|---|---|---|---|---|---|---|---|---| -| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | -| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | -| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | -| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED / TERMINAL / ABANDONED | human | `prior_program_fingerprint*`, `accept_obligation_change*` | `catalog-identity` | `verifier:fresh-observation:catalog.reconcile` | `recovery.resume` | `declared-neutral` | +| `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | +| `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | +| `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | | `delivery.slice.advance` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | -| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | GOAL_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | -| `engagement.release` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | -| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | +| `engagement.begin` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | +| `engagement.release` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | +| `engagement.renew` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | | `evidence.approval.revoke` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | | `evidence.visual.attach` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | -| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | -| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | -| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | -| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | -| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | -| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | -| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | -| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | -| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | -| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | -| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | -| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | -| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | +| `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | +| `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | +| `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | +| `external.files-changed` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.files-changed` | `-` | `declared-neutral` | +| `external.head-changed` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `verifier:fresh-observation:external.head-changed` | `-` | `declared-neutral` | +| `external.host-interrupted` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | `verifier:fresh-observation:external.host-interrupted` | `-` | `declared-neutral` | +| `external.lease-expired` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | `verifier:fresh-observation:external.lease-expired` | `-` | `declared-neutral` | +| `external.pr-closed` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | `verifier:fresh-observation:external.pr-closed` | `-` | `declared-neutral` | +| `external.pr-merged` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-merged` | `-` | `declared-neutral` | +| `external.pr-opened` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-opened` | `-` | `declared-neutral` | +| `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | +| `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | +| `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | | `gate.build.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | | `gate.change.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | | `gate.journey.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | | `gate.review.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | | `gate.test.record` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | -| `goal.configure` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `goal_kind*`, `delivery_id*` | `goal` | `verifier:fresh-observation:goal.configure` | `recovery.resume` | `declared-neutral` | -| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | -| `installation.update` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | -| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | +| `goal.configure` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `goal_kind*`, `delivery_id*` | `goal` | `verifier:fresh-observation:goal.configure` | `recovery.resume` | `declared-neutral` | +| `installation.initialize` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | +| `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | +| `installation.update` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | +| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | | `plan.abandon` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | | `plan.activate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | | `plan.amend` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | @@ -53,14 +54,14 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `publication.observe` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | | `publication.preview` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | | `publication.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | -| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | -| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | -| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | -| `repository.attach` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | -| `repository.detach` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | -| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | -| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | -| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`8f5b0163c0b62c1020b1d11208d08bb247de3727ed6dda6f2a41a12b2efd35e2` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | +| `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | +| `recovery.resume` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | +| `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | +| `repository.attach` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED | OBSERVED | human | `topology*`, `config_authority*` | `repository-binding` | `verifier:fresh-observation:repository.attach` | `recovery.resume` | `declared-neutral` | +| `repository.detach` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | - | `repository-binding` | `verifier:fresh-observation:repository.detach` | `recovery.resume` | `declared-neutral` | +| `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | +| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | +| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`5f63de0d16895f5f45cfbb85779bcadc4243b81bf336440e4516824d74f4aed8` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | | `workspace.abandon` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | | `workspace.activate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | | `workspace.cleanup` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | diff --git a/docs/architecture/boatstack-v2-transition-catalog.mmd b/docs/architecture/boatstack-v2-transition-catalog.mmd index 164403a..0b3b950 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.mmd +++ b/docs/architecture/boatstack-v2-transition-catalog.mmd @@ -33,54 +33,55 @@ flowchart TB t17["gate.review.record
owned-local"] t18["gate.test.record
owned-local"] t19["installation.initialize
owned-local"] - t20["installation.update
owned-local"] - t21["invocation.rebind
owned-local"] - t22["plan.activate
owned-local"] - t23["plan.amend
owned-local"] - t24["plan.create
owned-local"] - t25["plan.invalidate
owned-local"] - t26["plan.validate
owned-local"] - t27["publication.observe
owned-local"] - t28["publication.preview
owned-local"] - t29["repository.attach
owned-local"] - t30["repository.detach
owned-local"] - t31["runtime.hydrate
owned-local"] - t32["runtime.replace
owned-local"] - t33["workspace.abandon
owned-local"] - t34["workspace.activate
owned-local"] - t35["workspace.cleanup
owned-local"] - t36["workspace.cut
owned-local"] - t37["workspace.publish
owned-local"] - t38["workspace.reap
owned-local"] - t39["workspace.sync
owned-local"] + t20["installation.reconcile-update
owned-local"] + t21["installation.update
owned-local"] + t22["invocation.rebind
owned-local"] + t23["plan.activate
owned-local"] + t24["plan.amend
owned-local"] + t25["plan.create
owned-local"] + t26["plan.invalidate
owned-local"] + t27["plan.validate
owned-local"] + t28["publication.observe
owned-local"] + t29["publication.preview
owned-local"] + t30["repository.attach
owned-local"] + t31["repository.detach
owned-local"] + t32["runtime.hydrate
owned-local"] + t33["runtime.replace
owned-local"] + t34["workspace.abandon
owned-local"] + t35["workspace.activate
owned-local"] + t36["workspace.cleanup
owned-local"] + t37["workspace.cut
owned-local"] + t38["workspace.publish
owned-local"] + t39["workspace.reap
owned-local"] + t40["workspace.sync
owned-local"] end subgraph owned_external["owned-external"] - t40["publication.correct
owned-external"] - t41["publication.execute
owned-external"] + t41["publication.correct
owned-external"] + t42["publication.execute
owned-external"] end subgraph recovery["recovery"] - t42["configuration.reconcile
recovery"] - t43["publication.reconcile
recovery"] - t44["recovery.escalate
recovery"] - t45["recovery.resume
recovery"] - t46["recovery.rollback
recovery"] - t47["runtime.reconcile
recovery"] - t48["workspace.reconcile
recovery"] + t43["configuration.reconcile
recovery"] + t44["publication.reconcile
recovery"] + t45["recovery.escalate
recovery"] + t46["recovery.resume
recovery"] + t47["recovery.rollback
recovery"] + t48["runtime.reconcile
recovery"] + t49["workspace.reconcile
recovery"] end subgraph observed_external["observed-external"] - t49["external.branch-changed
observed-external"] - t50["external.ci-completed
observed-external"] - t51["external.configuration-drifted
observed-external"] - t52["external.files-changed
observed-external"] - t53["external.head-changed
observed-external"] - t54["external.host-interrupted
observed-external"] - t55["external.lease-expired
observed-external"] - t56["external.pr-closed
observed-external"] - t57["external.pr-merged
observed-external"] - t58["external.pr-opened
observed-external"] - t59["external.pr-updated
observed-external"] - t60["external.provider-unavailable
observed-external"] - t61["external.runtime-disappeared
observed-external"] + t50["external.branch-changed
observed-external"] + t51["external.ci-completed
observed-external"] + t52["external.configuration-drifted
observed-external"] + t53["external.files-changed
observed-external"] + t54["external.head-changed
observed-external"] + t55["external.host-interrupted
observed-external"] + t56["external.lease-expired
observed-external"] + t57["external.pr-closed
observed-external"] + t58["external.pr-merged
observed-external"] + t59["external.pr-opened
observed-external"] + t60["external.pr-updated
observed-external"] + t61["external.provider-unavailable
observed-external"] + t62["external.runtime-disappeared
observed-external"] end p_DORMANT --> t00 p_OBSERVED --> t00 @@ -164,113 +165,125 @@ flowchart TB p_DORMANT --> t19 p_OBSERVED --> t19 t19 --> p_OBSERVED + p_DORMANT --> t20 p_OBSERVED --> t20 p_ACTIVE --> t20 + p_FRONTIER --> t20 + p_TERMINAL --> t20 + p_ABANDONED --> t20 + t20 --> p_DORMANT t20 --> p_OBSERVED t20 --> p_ACTIVE + t20 --> p_FRONTIER t20 --> p_TERMINAL + t20 --> p_ABANDONED + p_DORMANT --> t21 p_OBSERVED --> t21 - p_UNRESOLVED --> t21 + p_ACTIVE --> t21 + p_FRONTIER --> t21 + p_TERMINAL --> t21 + p_ABANDONED --> t21 + t21 --> p_DORMANT t21 --> p_OBSERVED + t21 --> p_ACTIVE + t21 --> p_FRONTIER + t21 --> p_TERMINAL + t21 --> p_ABANDONED p_OBSERVED --> t22 - p_ACTIVE --> t22 - t22 --> p_ACTIVE + p_UNRESOLVED --> t22 + t22 --> p_OBSERVED + p_OBSERVED --> t23 p_ACTIVE --> t23 - p_FRONTIER --> t23 t23 --> p_ACTIVE - p_OBSERVED --> t24 p_ACTIVE --> t24 + p_FRONTIER --> t24 t24 --> p_ACTIVE - p_ACTIVE --> t25 p_OBSERVED --> t25 - t25 --> p_FRONTIER - p_OBSERVED --> t26 + p_ACTIVE --> t25 + t25 --> p_ACTIVE p_ACTIVE --> t26 - t26 --> p_ACTIVE + p_OBSERVED --> t26 t26 --> p_FRONTIER p_OBSERVED --> t27 p_ACTIVE --> t27 - p_RECOVERY --> t27 - p_UNRESOLVED --> t27 t27 --> p_ACTIVE - t27 --> p_TERMINAL t27 --> p_FRONTIER - t27 --> p_UNRESOLVED + p_OBSERVED --> t28 p_ACTIVE --> t28 + p_RECOVERY --> t28 + p_UNRESOLVED --> t28 t28 --> p_ACTIVE - p_DORMANT --> t29 - p_OBSERVED --> t29 - t29 --> p_OBSERVED + t28 --> p_TERMINAL + t28 --> p_FRONTIER + t28 --> p_UNRESOLVED + p_ACTIVE --> t29 + t29 --> p_ACTIVE p_DORMANT --> t30 p_OBSERVED --> t30 - p_FRONTIER --> t30 - t30 --> p_DORMANT + t30 --> p_OBSERVED + p_DORMANT --> t31 p_OBSERVED --> t31 - p_RECOVERY --> t31 - p_UNRESOLVED --> t31 - t31 --> p_OBSERVED - t31 --> p_ACTIVE - t31 --> p_TERMINAL + p_FRONTIER --> t31 + t31 --> p_DORMANT p_OBSERVED --> t32 p_RECOVERY --> t32 + p_UNRESOLVED --> t32 t32 --> p_OBSERVED + t32 --> p_ACTIVE t32 --> p_TERMINAL - p_ACTIVE --> t33 - p_FRONTIER --> t33 - t33 --> p_ABANDONED - p_OBSERVED --> t34 + p_OBSERVED --> t33 + p_RECOVERY --> t33 + t33 --> p_OBSERVED + t33 --> p_TERMINAL p_ACTIVE --> t34 - t34 --> p_ACTIVE + p_FRONTIER --> t34 + t34 --> p_ABANDONED p_OBSERVED --> t35 p_ACTIVE --> t35 - p_TERMINAL --> t35 - p_ABANDONED --> t35 - t35 --> p_OBSERVED - t35 --> p_TERMINAL - t35 --> p_ABANDONED + t35 --> p_ACTIVE p_OBSERVED --> t36 p_ACTIVE --> t36 - t36 --> p_ACTIVE + p_TERMINAL --> t36 + p_ABANDONED --> t36 + t36 --> p_OBSERVED + t36 --> p_TERMINAL + t36 --> p_ABANDONED + p_OBSERVED --> t37 p_ACTIVE --> t37 t37 --> p_ACTIVE - p_OBSERVED --> t38 - p_TERMINAL --> t38 - p_ABANDONED --> t38 - t38 --> p_OBSERVED - t38 --> p_TERMINAL - t38 --> p_ABANDONED - p_ACTIVE --> t39 - t39 --> p_ACTIVE - t39 --> p_FRONTIER - p_OBSERVED --> t40 + p_ACTIVE --> t38 + t38 --> p_ACTIVE + p_OBSERVED --> t39 + p_TERMINAL --> t39 + p_ABANDONED --> t39 + t39 --> p_OBSERVED + t39 --> p_TERMINAL + t39 --> p_ABANDONED p_ACTIVE --> t40 - p_TERMINAL --> t40 t40 --> p_ACTIVE - t40 --> p_RECOVERY + t40 --> p_FRONTIER + p_OBSERVED --> t41 p_ACTIVE --> t41 + p_TERMINAL --> t41 t41 --> p_ACTIVE t41 --> p_RECOVERY - p_RECOVERY --> t42 - p_UNRESOLVED --> t42 - t42 --> p_OBSERVED - t42 --> p_FRONTIER - t42 --> p_TERMINAL + p_ACTIVE --> t42 + t42 --> p_ACTIVE + t42 --> p_RECOVERY p_RECOVERY --> t43 p_UNRESOLVED --> t43 - t43 --> p_ACTIVE - t43 --> p_TERMINAL + t43 --> p_OBSERVED t43 --> p_FRONTIER - t43 --> p_UNRESOLVED + t43 --> p_TERMINAL p_RECOVERY --> t44 p_UNRESOLVED --> t44 + t44 --> p_ACTIVE + t44 --> p_TERMINAL t44 --> p_FRONTIER + t44 --> p_UNRESOLVED p_RECOVERY --> t45 - t45 --> p_DORMANT - t45 --> p_OBSERVED - t45 --> p_ACTIVE + p_UNRESOLVED --> t45 t45 --> p_FRONTIER - t45 --> p_TERMINAL - t45 --> p_ABANDONED p_RECOVERY --> t46 t46 --> p_DORMANT t46 --> p_OBSERVED @@ -279,25 +292,25 @@ flowchart TB t46 --> p_TERMINAL t46 --> p_ABANDONED p_RECOVERY --> t47 - p_UNRESOLVED --> t47 + t47 --> p_DORMANT t47 --> p_OBSERVED + t47 --> p_ACTIVE t47 --> p_FRONTIER t47 --> p_TERMINAL + t47 --> p_ABANDONED p_RECOVERY --> t48 p_UNRESOLVED --> t48 - t48 --> p_DORMANT t48 --> p_OBSERVED - t48 --> p_ACTIVE t48 --> p_FRONTIER t48 --> p_TERMINAL - t48 --> p_ABANDONED - p_DORMANT --> t49 - p_OBSERVED --> t49 - p_ACTIVE --> t49 p_RECOVERY --> t49 - p_FRONTIER --> t49 p_UNRESOLVED --> t49 + t49 --> p_DORMANT t49 --> p_OBSERVED + t49 --> p_ACTIVE + t49 --> p_FRONTIER + t49 --> p_TERMINAL + t49 --> p_ABANDONED p_DORMANT --> t50 p_OBSERVED --> t50 p_ACTIVE --> t50 @@ -305,8 +318,6 @@ flowchart TB p_FRONTIER --> t50 p_UNRESOLVED --> t50 t50 --> p_OBSERVED - t50 --> p_ACTIVE - t50 --> p_TERMINAL p_DORMANT --> t51 p_OBSERVED --> t51 p_ACTIVE --> t51 @@ -314,7 +325,8 @@ flowchart TB p_FRONTIER --> t51 p_UNRESOLVED --> t51 t51 --> p_OBSERVED - t51 --> p_UNRESOLVED + t51 --> p_ACTIVE + t51 --> p_TERMINAL p_DORMANT --> t52 p_OBSERVED --> t52 p_ACTIVE --> t52 @@ -322,6 +334,7 @@ flowchart TB p_FRONTIER --> t52 p_UNRESOLVED --> t52 t52 --> p_OBSERVED + t52 --> p_UNRESOLVED p_DORMANT --> t53 p_OBSERVED --> t53 p_ACTIVE --> t53 @@ -335,23 +348,21 @@ flowchart TB p_RECOVERY --> t54 p_FRONTIER --> t54 p_UNRESOLVED --> t54 - t54 --> p_RECOVERY + t54 --> p_OBSERVED p_DORMANT --> t55 p_OBSERVED --> t55 p_ACTIVE --> t55 p_RECOVERY --> t55 p_FRONTIER --> t55 p_UNRESOLVED --> t55 - t55 --> p_DORMANT - t55 --> p_FRONTIER + t55 --> p_RECOVERY p_DORMANT --> t56 p_OBSERVED --> t56 p_ACTIVE --> t56 p_RECOVERY --> t56 p_FRONTIER --> t56 p_UNRESOLVED --> t56 - t56 --> p_OBSERVED - t56 --> p_ACTIVE + t56 --> p_DORMANT t56 --> p_FRONTIER p_DORMANT --> t57 p_OBSERVED --> t57 @@ -361,7 +372,7 @@ flowchart TB p_UNRESOLVED --> t57 t57 --> p_OBSERVED t57 --> p_ACTIVE - t57 --> p_TERMINAL + t57 --> p_FRONTIER p_DORMANT --> t58 p_OBSERVED --> t58 p_ACTIVE --> t58 @@ -386,13 +397,22 @@ flowchart TB p_RECOVERY --> t60 p_FRONTIER --> t60 p_UNRESOLVED --> t60 - t60 --> p_UNRESOLVED - t60 --> p_RECOVERY + t60 --> p_OBSERVED + t60 --> p_ACTIVE + t60 --> p_TERMINAL p_DORMANT --> t61 p_OBSERVED --> t61 p_ACTIVE --> t61 p_RECOVERY --> t61 p_FRONTIER --> t61 p_UNRESOLVED --> t61 - t61 --> p_OBSERVED + t61 --> p_UNRESOLVED t61 --> p_RECOVERY + p_DORMANT --> t62 + p_OBSERVED --> t62 + p_ACTIVE --> t62 + p_RECOVERY --> t62 + p_FRONTIER --> t62 + p_UNRESOLVED --> t62 + t62 --> p_OBSERVED + t62 --> p_RECOVERY diff --git a/docs/generated-files.md b/docs/generated-files.md index ba08f98..f95c810 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -44,7 +44,7 @@ architecture artifacts: | `docs/architecture/boatstack-v2-locus-liveness.json` | `boatstack-helper catalog --format locus-liveness` | Repository and Go tests compare every checked byte with a fresh render and -require both Locus alphabets to equal all 62 executable catalog transitions. +require both Locus alphabets to equal all 63 executable catalog transitions. The StandardFlow graph contains exactly the 30 transitions whose compiled origin is the primary flow. The Locus phase graph is intentionally conservative: it expands each declared diff --git a/docs/public-claims.json b/docs/public-claims.json index 4bb608c..13cfbe5 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -138,7 +138,7 @@ }, { "id": "formal-live-system-closure", - "public_claim": "The generated 62-event stable-phase abstraction satisfies the checked safety and liveness properties; executable tests separately bind catalog completeness, facets, reducer branches, operating-system behavior, and provider outcomes.", + "public_claim": "The generated 63-event stable-phase abstraction satisfies the checked safety and liveness properties; executable tests separately bind catalog completeness, facets, reducer branches, operating-system behavior, and provider outcomes.", "status": "advisory", "readable_evidence": "architecture/boatstack-v2-kernel.md#17-test-and-formal-property-strategy", "implementation": [ diff --git a/install.ps1 b/install.ps1 index 39a5c3d..888cf2f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -77,17 +77,17 @@ try { } & $Runtime init --repo $Repository --human $Actor --param "config_path=$ConfigSource" --format text } else { + $AcceptProgramChange = @() + if ($env:BOATSTACK_ACCEPT_PROGRAM_CHANGE -eq "true") { + $AcceptProgramChange = @("--accept-program-change") + } & $Runtime update --repo $Repository --human $Actor ` --param "runtime_path=$Runtime" ` - --param "runtime_sha256=$Actual" --format text + --param "runtime_sha256=$Actual" @AcceptProgramChange --format json } if ($LASTEXITCODE -ne 0) { throw "Boatstack kernel rejected installation" } $Launcher = Join-Path $InstallDir "boatstack.cmd" - $LauncherTemporary = "$Launcher.tmp" - "@echo off`r`n`"$Runtime`" %*`r`n" | Set-Content -LiteralPath $LauncherTemporary -Encoding ascii - Move-Item -LiteralPath $LauncherTemporary -Destination $Launcher -Force - Write-Host "Boatstack V2 installed at $Runtime" Write-Host "Review and commit $Repository\.boatstack\project.json and the generated host skills" Write-Host "Run: $Launcher doctor --repo `"$Repository`" --format text" diff --git a/install.sh b/install.sh index 8971456..1e8d203 100755 --- a/install.sh +++ b/install.sh @@ -85,15 +85,18 @@ if [[ "$mode" == install ]]; then fi "$runtime" init --repo "$repository" --human "$actor" --param "config_path=$config_source" --format text else - "$runtime" update --repo "$repository" --human "$actor" \ - --param "runtime_path=$runtime" \ - --param "runtime_sha256=$actual" --format text + update_arguments=( + update --repo "$repository" --human "$actor" + --param "runtime_path=$runtime" + --param "runtime_sha256=$actual" + --format json + ) + if [[ "${BOATSTACK_ACCEPT_PROGRAM_CHANGE:-false}" == "true" ]]; then + update_arguments+=(--accept-program-change) + fi + "$runtime" "${update_arguments[@]}" fi -launcher_temp="$install_dir/.boatstack-launcher.$$" -ln -s "$(basename "$runtime")" "$launcher_temp" -mv -f "$launcher_temp" "$install_dir/boatstack" - echo "Boatstack V2 installed at $runtime" echo "Review and commit $repository/.boatstack/project.json and the generated host skills" echo "Run: $install_dir/boatstack doctor --repo $repository --format text" diff --git a/release-notes/2026-08-11-atomic-program-update-reconciliation.md b/release-notes/2026-08-11-atomic-program-update-reconciliation.md new file mode 100644 index 0000000..2f331ca --- /dev/null +++ b/release-notes/2026-08-11-atomic-program-update-reconciliation.md @@ -0,0 +1,3 @@ +### Reconcile program-changing updates atomically + +Boatstack now binds explicit program-change acceptance to the exact prior program, candidate program, runtime, and launcher, then installs them through one recoverable transition. Updates that do not receive that acceptance preserve the healthy prior installation, and interrupted local updates can restore its exact launcher and managed state.