diff --git a/.agents/skills/boatstack-update/SKILL.md b/.agents/skills/boatstack-update/SKILL.md index d5e337ea..4aa8eff4 100644 --- a/.agents/skills/boatstack-update/SKILL.md +++ b/.agents/skills/boatstack-update/SKILL.md @@ -21,15 +21,15 @@ 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. -If the candidate reports exact compiled-program drift, preserve the healthy old -launcher and present the prior program fingerprint, candidate program +If the candidate reports exact compiled-program drift, preserve the healthy admitted +runtime 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 +restored healthy prior runtime. Never acquire repository authority to escape an update recovery frontier. Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` diff --git a/.claude/skills/boatstack-update/SKILL.md b/.claude/skills/boatstack-update/SKILL.md index d5e337ea..4aa8eff4 100644 --- a/.claude/skills/boatstack-update/SKILL.md +++ b/.claude/skills/boatstack-update/SKILL.md @@ -21,15 +21,15 @@ 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. -If the candidate reports exact compiled-program drift, preserve the healthy old -launcher and present the prior program fingerprint, candidate program +If the candidate reports exact compiled-program drift, preserve the healthy admitted +runtime 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 +restored healthy prior runtime. Never acquire repository authority to escape an update recovery frontier. Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` diff --git a/.cursor/commands/boatstack-update.md b/.cursor/commands/boatstack-update.md index d5e337ea..4aa8eff4 100644 --- a/.cursor/commands/boatstack-update.md +++ b/.cursor/commands/boatstack-update.md @@ -21,15 +21,15 @@ 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. -If the candidate reports exact compiled-program drift, preserve the healthy old -launcher and present the prior program fingerprint, candidate program +If the candidate reports exact compiled-program drift, preserve the healthy admitted +runtime 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 +restored healthy prior runtime. Never acquire repository authority to escape an update recovery frontier. Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` diff --git a/.gemini/skills/boatstack-update/SKILL.md b/.gemini/skills/boatstack-update/SKILL.md index d5e337ea..4aa8eff4 100644 --- a/.gemini/skills/boatstack-update/SKILL.md +++ b/.gemini/skills/boatstack-update/SKILL.md @@ -21,15 +21,15 @@ 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. -If the candidate reports exact compiled-program drift, preserve the healthy old -launcher and present the prior program fingerprint, candidate program +If the candidate reports exact compiled-program drift, preserve the healthy admitted +runtime 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 +restored healthy prior runtime. Never acquire repository authority to escape an update recovery frontier. Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index c5fc49bd..cd27eda5 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -5,6 +5,7 @@ import hashlib import json import os +import shutil import subprocess import tempfile import unittest @@ -40,6 +41,7 @@ def setUp(self) -> None: self.addCleanup(self.work.cleanup) root = Path(self.work.name) self.state_root = root / "state" + self.boatstack_home = root / "boatstack-home" self.repo = root / "repo" self.repo.mkdir() self._git(self.repo, "init", "-b", "main") @@ -48,6 +50,13 @@ def setUp(self) -> None: (self.repo / "README.md").write_text("# fixture\n") self._git(self.repo, "add", "README.md") self._git(self.repo, "commit", "-m", "fixture") + digest = hashlib.sha256(self.binary.read_bytes()).hexdigest() + version = self.run_helper("version").stdout.strip() + runtime_dir = self.boatstack_home / "runtimes" / f"{version}-{digest}" + runtime_dir.mkdir(parents=True) + runtime = runtime_dir / ("boatstack-runtime.exe" if os.name == "nt" else "boatstack-runtime") + shutil.copy2(self.binary, runtime) + runtime.chmod(0o755) def _git(self, repository: Path, *args: str) -> subprocess.CompletedProcess[str]: result = subprocess.run( @@ -59,6 +68,7 @@ def _git(self, repository: Path, *args: str) -> subprocess.CompletedProcess[str] def _env(self) -> dict[str, str]: env = dict(os.environ) env["BOATSTACK_STATE_ROOT"] = str(self.state_root) + env["BOATSTACK_HOME"] = str(self.boatstack_home) return env def run_helper( @@ -174,6 +184,7 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No "?? .agents/skills/boatstack-update/agents/openai.yaml", "?? .boatstack/host-skills.json", "?? .boatstack/project.json", + "?? .boatstack/runtime.json", "?? .claude/skills/boatstack-autoplan/SKILL.md", "?? .claude/skills/boatstack-run/SKILL.md", "?? .claude/skills/boatstack-update/SKILL.md", @@ -345,7 +356,7 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali "next", "--repo", self.repo, "--transition", "installation.initialize", *goal, *flow, *actor, "--param", f"source_revision={self._git(self.repo, 'rev-parse', 'HEAD').stdout.strip()}", - "--param", f"runtime_path={self.binary.resolve()}", + "--param", f"runtime_version={self.run_helper('version').stdout.strip()}", "--param", f"runtime_sha256={hashlib.sha256(self.binary.read_bytes()).hexdigest()}", "--param", f"config_path={config}", "--param", f"config_sha256={config_fingerprint}", diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 0472cda0..9d81c09d 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -32,8 +32,8 @@ def setUpClass(cls) -> None: ): ldflags = " ".join( ( - f"-X github.com/operatorstack/boatstack/boatstack.Version={version}", - f"-X github.com/operatorstack/boatstack/boatstack.SourceCommit={source}", + f"-X github.com/operatorstack/boatstack/boatstack/internal/buildinfo.Version={version}", + f"-X github.com/operatorstack/boatstack/boatstack/internal/buildinfo.SourceCommit={source}", ) ) result = subprocess.run( @@ -111,7 +111,7 @@ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: "boatstack-helper_windows_arm64.exe", ): self.assertIn(asset, release) - for symbol in ("boatstack.Version", "boatstack.SourceCommit", "boatstack.ChecksumsSHA256"): + for symbol in ("internal/buildinfo.Version", "internal/buildinfo.SourceCommit", "internal/buildinfo.ChecksumsSHA256"): self.assertIn(symbol, release) self.assertIn('source_commit="$(git rev-parse HEAD)"', release) self.assertIn("sha256sum", release) @@ -200,7 +200,7 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) ) self.assertNotIn("repository-policy source remains requested", update) for contract in ( - "preserve the healthy old\nlauncher", + "preserve the healthy admitted\nruntime", "program-delta fingerprint", "Do not accept the delta implicitly", "`--accept-program-change`", @@ -422,12 +422,12 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - install_dir = root / "bin" repository.mkdir() self.init_repository(repository) - digest = hashlib.sha256(self.helper.read_bytes()).hexdigest() + digest = hashlib.sha256(self.old_helper.read_bytes()).hexdigest() env = dict(os.environ) env.update( { "BOATSTACK_REPO": str(repository), - "BOATSTACK_BINARY": str(self.helper), + "BOATSTACK_BINARY": str(self.old_helper), "BOATSTACK_BINARY_SHA256": digest, "BOATSTACK_INSTALL_DIR": str(install_dir), "BOATSTACK_CONFIG": str(CONFIG), @@ -438,7 +438,8 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - ) self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) launcher = install_dir / "boatstack" - self.assertTrue(launcher.is_symlink()) + self.assertTrue(launcher.is_file()) + self.assertFalse(launcher.is_symlink()) self.assertTrue((repository / ".boatstack" / "project.json").is_file()) doctor = json.loads( @@ -487,21 +488,26 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - self.assertFalse(destructive["guard"]["allowed"]) env["BOATSTACK_MODE"] = "update" - env["BOATSTACK_VERSION"] = "contract-v2-next" + env["BOATSTACK_BINARY"] = str(self.helper) + env["BOATSTACK_BINARY_SHA256"] = hashlib.sha256(self.helper.read_bytes()).hexdigest() + env["BOATSTACK_ACCEPT_PROGRAM_CHANGE"] = "true" self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) updated = json.loads( self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout ) self.assertTrue(updated["doctor"]["healthy"]) - self.assertIn("contract-v2-next", updated["snapshot"]["invocation"]["runtime_path"]) + pin = json.loads((repository / ".boatstack" / "runtime.json").read_text()) + self.assertEqual(pin["version"], "v0.7.contract-new") + self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest()) + self.assertNotIn("path", pin) events = self.run_command( launcher, "events", "--repo", repository, "--format", "jsonl", env=env ).stdout.splitlines() transitions = {json.loads(line)["transition_id"] for line in events} - self.assertTrue({"installation.initialize", "engagement.begin", "installation.update"}.issubset(transitions)) + self.assertTrue({"installation.initialize", "engagement.begin", "installation.reconcile-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 + # control-law: accepted-program-delta-atomically-pins-runtime-and-program if os.name == "nt": self.skipTest("the repository contract job exercises the POSIX installer") with tempfile.TemporaryDirectory() as temp: @@ -526,7 +532,8 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No ) self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) launcher = install_dir / "boatstack" - old_target = os.readlink(launcher) + old_launcher = launcher.read_bytes() + old_pin = (repository / ".boatstack" / "runtime.json").read_bytes() state_path = next((repository / ".git" / "boatstack").rglob("state.json")) state_before = state_path.read_bytes() @@ -570,7 +577,8 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No "catalog reconciliation cannot activate a different runtime", split_reconciliation.stdout + split_reconciliation.stderr, ) - self.assertEqual(os.readlink(launcher), old_target) + self.assertEqual(launcher.read_bytes(), old_launcher) + self.assertEqual((repository / ".boatstack" / "runtime.json").read_bytes(), old_pin) self.assertEqual(state_path.read_bytes(), state_before) rejected = self.run_command( "bash", REPO / "install.sh", cwd=repository, env=env, expected=1 @@ -589,12 +597,16 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No 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(launcher.read_bytes(), old_launcher) + self.assertEqual((repository / ".boatstack" / "runtime.json").read_bytes(), old_pin) 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)) + pin = json.loads((repository / ".boatstack" / "runtime.json").read_text()) + self.assertEqual(pin["version"], "v0.7.contract-new") + self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest()) + self.assertNotIn("path", pin) doctor = json.loads( self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout ) @@ -615,9 +627,8 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No 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() + update["runtime_fingerprint"], hashlib.sha256(self.helper.read_bytes()).hexdigest() ) self.assertEqual(update["runtime_source_revision"], "b" * 40) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22d3bdc8..cb1fccc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: mkdir -p dist cd boatstack go build -trimpath \ - -ldflags "-s -w -X github.com/operatorstack/boatstack/boatstack.Version=$VERSION -X github.com/operatorstack/boatstack/boatstack.SourceCommit=$source_commit -X github.com/operatorstack/boatstack/boatstack.ChecksumsSHA256=per-asset-sidecar" \ + -ldflags "-s -w -X github.com/operatorstack/boatstack/boatstack/internal/buildinfo.Version=$VERSION -X github.com/operatorstack/boatstack/boatstack/internal/buildinfo.SourceCommit=$source_commit -X github.com/operatorstack/boatstack/boatstack/internal/buildinfo.ChecksumsSHA256=per-asset-sidecar" \ -o "../dist/$ASSET" ./cmd/boatstack-helper cd ../dist sha256sum "$ASSET" > "$ASSET.sha256" diff --git a/README.md b/README.md index c130e1fb..f87ac76e 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,12 @@ On Windows PowerShell: irm https://raw.githubusercontent.com/operatorstack/boatstack/main/install.ps1 | iex ``` -The installer verifies the release checksum, runs the registered -`installation.initialize` transition, and then installs the launcher. Review -and commit `.boatstack/project.json`, `.boatstack/host-skills.json`, and the -generated host skills; machine-local controller state stays outside the -worktree. +The installer verifies the release checksum, stores the runtime under its +immutable version and digest, runs `installation.initialize`, and installs a +stable dispatcher. Review and commit `.boatstack/project.json`, +`.boatstack/runtime.json`, `.boatstack/host-skills.json`, and the generated host +skills; machine-local controller state and runtime bytes stay outside the +worktree. See [Runtime selection](docs/runtime-selection.md). Run the independent health query: @@ -107,9 +108,10 @@ coding host: - `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. 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. + control-program change preserves the admitted runtime until a human accepts + the exact prior-to-candidate program delta; Boatstack then commits the new + repository pin, 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 1db995d8..9abfc235 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -19,10 +19,12 @@ import ( boatstack "github.com/operatorstack/boatstack/boatstack" "github.com/operatorstack/boatstack/boatstack/analysis" "github.com/operatorstack/boatstack/boatstack/distribution" + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" "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" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/surfaces" ) @@ -54,6 +56,14 @@ type commandOptions struct { } func main() { + if boatstackruntime.ShouldDispatch(os.Args[0]) { + code, err := boatstackruntime.Dispatch(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, "boatstack:", err) + os.Exit(1) + } + os.Exit(code) + } if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "boatstack:", err) os.Exit(1) @@ -339,22 +349,14 @@ func populateRuntimeParameters(options *commandOptions) error { if err != nil { return err } - runtimePath, hasRuntimePath := parameters.Get("runtime_path") - if !hasRuntimePath { - runtimePath, err = os.Executable() - if err != nil { - return err - } - runtimePath, err = filepath.Abs(runtimePath) - if err != nil { - return err - } - if resolved, resolveErr := filepath.EvalSymlinks(runtimePath); resolveErr == nil { - runtimePath = resolved - } - options.parameters = append(options.parameters, "runtime_path="+runtimePath) + if _, ok := parameters.Get("runtime_version"); !ok { + options.parameters = append(options.parameters, "runtime_version="+buildinfo.Version) } if _, ok := parameters.Get("runtime_sha256"); !ok { + runtimePath, executableErr := os.Executable() + if executableErr != nil { + return executableErr + } runtimeRaw, readErr := os.ReadFile(runtimePath) if readErr != nil { return readErr @@ -388,8 +390,8 @@ func populateFileFingerprint(options *commandOptions, pathName, fingerprintName } func buildRevision() string { - if boatstack.SourceCommit != "" && boatstack.SourceCommit != "unknown" { - return boatstack.SourceCommit + if buildinfo.SourceCommit != "" && buildinfo.SourceCommit != "unknown" { + return buildinfo.SourceCommit } if info, ok := debug.ReadBuildInfo(); ok { for _, setting := range info.Settings { @@ -462,7 +464,7 @@ func parseParameters(values []string) (protocol.Parameters, error) { func isPathParameter(name string) bool { switch name { - case "source_path", "runtime_path", "config_path", "destination", "evidence_path", "manifest_path", "body_path": + case "source_path", "config_path", "destination", "evidence_path", "manifest_path", "body_path": return true default: return false diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index f5107da1..dbaba564 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" "github.com/operatorstack/boatstack/boatstack/internal/surfaces" "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) @@ -66,9 +66,9 @@ func TestCorrectPRBindsExactBodyBytes(t *testing.T) { } func TestBuildRevisionPrefersReleaseEmbeddedSourceCommit(t *testing.T) { - prior := boatstack.SourceCommit - boatstack.SourceCommit = "exact-release-source" - t.Cleanup(func() { boatstack.SourceCommit = prior }) + prior := buildinfo.SourceCommit + buildinfo.SourceCommit = "exact-release-source" + t.Cleanup(func() { buildinfo.SourceCommit = prior }) if got := buildRevision(); got != "exact-release-source" { t.Fatalf("build revision = %q", got) } diff --git a/boatstack/core/transitions.json b/boatstack/core/transitions.json index 783d370d..023f63bd 100644 --- a/boatstack/core/transitions.json +++ b/boatstack/core/transitions.json @@ -33,6 +33,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -216,6 +217,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -400,6 +402,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -585,6 +588,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -736,6 +740,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -907,6 +912,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1068,6 +1074,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1102,7 +1109,7 @@ "secret": false }, { - "name": "runtime_path", + "name": "runtime_version", "required": true, "secret": false }, @@ -1248,6 +1255,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1283,7 +1291,7 @@ "secret": false }, { - "name": "runtime_path", + "name": "runtime_version", "required": true, "secret": false }, @@ -1430,6 +1438,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1461,7 +1470,7 @@ "secret": false }, { - "name": "runtime_path", + "name": "runtime_version", "required": true, "secret": false }, @@ -1584,6 +1593,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1760,6 +1770,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1963,6 +1974,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2103,6 +2115,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2137,7 +2150,7 @@ "secret": false }, { - "name": "runtime_path", + "name": "runtime_version", "required": true, "secret": false }, @@ -2306,6 +2319,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2344,7 +2358,7 @@ "secret": false }, { - "name": "runtime_path", + "name": "runtime_version", "required": true, "secret": false }, @@ -2497,6 +2511,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2522,7 +2537,7 @@ "idempotent": true, "parameters": [ {"name": "source_revision", "required": true, "secret": false}, - {"name": "runtime_path", "required": true, "secret": false}, + {"name": "runtime_version", "required": true, "secret": false}, {"name": "runtime_sha256", "required": true, "secret": false}, {"name": "accept_obligation_change", "required": true, "secret": false} ], @@ -2617,6 +2632,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2795,6 +2811,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2935,6 +2952,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3080,6 +3098,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3220,6 +3239,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3363,6 +3383,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3462,6 +3483,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3561,6 +3583,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3661,6 +3684,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3769,6 +3793,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3877,6 +3902,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3984,6 +4010,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4098,6 +4125,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4199,6 +4227,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4303,6 +4332,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4407,6 +4437,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4512,6 +4543,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4615,6 +4647,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index b4c36600..2507fa0c 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -142,11 +142,11 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t return true } importPath := imports[owner.Name] - if importPath == "os" && writerCalls[selector.Sel.Name] && !strings.HasPrefix(relative, "internal/effects/") { + if importPath == "os" && writerCalls[selector.Sel.Name] && !strings.HasPrefix(relative, "internal/effects/") && !strings.HasPrefix(relative, "internal/runtime/") { t.Errorf("managed writer os.%s escaped effects package in %s", selector.Sel.Name, relative) } if importPath == "os/exec" && (selector.Sel.Name == "Command" || selector.Sel.Name == "CommandContext") { - if relative != "internal/effects/command_boundary.go" && relative != "internal/plant/resolver.go" && relative != "extension/subprocess/subprocess.go" { + if relative != "internal/effects/command_boundary.go" && relative != "internal/plant/resolver.go" && relative != "extension/subprocess/subprocess.go" && relative != "internal/runtime/exec_windows.go" { t.Errorf("unclassified command boundary in %s", relative) } } @@ -267,6 +267,7 @@ func classifiedProductionFile(relative string) bool { strings.HasPrefix(relative, "flow/") || strings.HasPrefix(relative, "distribution/") || strings.HasPrefix(relative, "extension/") || strings.HasPrefix(relative, "internal/kernel/") || strings.HasPrefix(relative, "internal/plant/") || strings.HasPrefix(relative, "internal/effects/") || strings.HasPrefix(relative, "internal/surfaces/") || + strings.HasPrefix(relative, "internal/buildinfo/") || strings.HasPrefix(relative, "internal/runtime/") || strings.HasPrefix(relative, "internal/retromine/") || strings.HasPrefix(relative, "internal/testprogram/") || strings.HasPrefix(relative, "sdk/") || strings.HasPrefix(relative, "analysis/") } diff --git a/boatstack/flow/standard/supervisor_parity_test.go b/boatstack/flow/standard/supervisor_parity_test.go index bd352331..d1b4184d 100644 --- a/boatstack/flow/standard/supervisor_parity_test.go +++ b/boatstack/flow/standard/supervisor_parity_test.go @@ -30,7 +30,7 @@ func snapshotFor(t *testing.T, phase model.ProtocolPhase, terminal model.Termina e := model.Evidence{Source: "fixture", Fingerprint: "fixture", ObservedAt: time.Unix(10, 0).UTC()} o := model.Observation{ SchemaVersion: model.SnapshotSchemaVersion, - Invocation: model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/f", ControllerID: "ctl", InvokingPath: filepath.Join(t.TempDir(), "repo"), RuntimePath: filepath.Join(t.TempDir(), "runtime"), RuntimeFingerprint: "runtime", Topology: model.TopologyEmbedded, Host: "cli", Correlation: "c"}, + Invocation: model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/f", ControllerID: "ctl", InvokingPath: filepath.Join(t.TempDir(), "repo"), RuntimeVersion: "runtime-version", RuntimePath: filepath.Join(t.TempDir(), "runtime"), RuntimeFingerprint: "runtime", Topology: model.TopologyEmbedded, Host: "cli", Correlation: "c"}, Phase: model.Known(phase, e), Engagement: model.Known(model.EngagementActive, e), Delivery: model.Known(model.DeliveryActive, e), Workspace: model.Known(model.WorkspaceActive, e), Plan: model.Known(model.PlanValid, e), Configuration: model.Known(model.ConfigurationVerified, e), Runtime: model.Known(model.RuntimeVerified, e), diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json index a9553a80..2881cc44 100644 --- a/boatstack/flow/standard/transitions.json +++ b/boatstack/flow/standard/transitions.json @@ -32,6 +32,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -243,6 +244,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -439,6 +441,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -653,6 +656,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -848,6 +852,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1059,6 +1064,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1275,6 +1281,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1451,6 +1458,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1631,6 +1639,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -1844,6 +1853,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2047,6 +2057,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2245,6 +2256,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2450,6 +2462,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2662,6 +2675,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -2862,6 +2876,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3049,6 +3064,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3207,6 +3223,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3433,6 +3450,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3659,6 +3677,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -3893,6 +3912,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4118,6 +4138,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4344,6 +4365,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4568,6 +4590,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4743,6 +4766,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -4947,6 +4971,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -5176,6 +5201,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -5409,6 +5435,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -5621,6 +5648,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -5815,6 +5843,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", @@ -6044,6 +6073,7 @@ "ref", "controller-id", "invoking-path", + "runtime-version", "runtime-path", "runtime-fingerprint", "topology", diff --git a/boatstack/internal/buildinfo/buildinfo.go b/boatstack/internal/buildinfo/buildinfo.go new file mode 100644 index 00000000..987c5ba1 --- /dev/null +++ b/boatstack/internal/buildinfo/buildinfo.go @@ -0,0 +1,7 @@ +package buildinfo + +var ( + Version = "v2.0.0-dev" + SourceCommit = "unknown" + ChecksumsSHA256 = "development" +) diff --git a/boatstack/internal/effects/command_boundary.go b/boatstack/internal/effects/command_boundary.go index 41db8d0d..fa0bfd8e 100644 --- a/boatstack/internal/effects/command_boundary.go +++ b/boatstack/internal/effects/command_boundary.go @@ -16,6 +16,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" ) type NativeCommandRunner interface { @@ -198,7 +199,16 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio } neutralDirectory := filepath.Dir(state.WorkspacePath) gitPrefix := []string{"--git-dir", layout.GitCommonRoot} - if output, err := b.runner.CombinedOutput(ctx, neutralDirectory, "git", append(gitPrefix, "worktree", "remove", state.WorkspacePath)...); err != nil { + removeArguments := append(gitPrefix, "worktree", "remove") + managedOnly, managedErr := b.workspaceHasOnlyManagedRuntimePin(ctx, state) + if managedErr != nil { + return settled, managedErr + } + if managedOnly { + removeArguments = append(removeArguments, "--force") + } + removeArguments = append(removeArguments, state.WorkspacePath) + if output, err := b.runner.CombinedOutput(ctx, neutralDirectory, "git", removeArguments...); err != nil { return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil } branchMode := "-d" @@ -251,3 +261,34 @@ func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admissio } return settled, nil } + +func (b NativeBoundary) workspaceHasOnlyManagedRuntimePin(ctx context.Context, state durable.State) (bool, error) { + output, err := b.runner.CombinedOutput(ctx, state.WorkspacePath, "git", "status", "--porcelain", "--untracked-files=all") + if err != nil { + return false, fmt.Errorf("inspect workspace before cleanup: %s: %w", strings.TrimSpace(string(output)), err) + } + status := strings.TrimSpace(string(output)) + if status == "" { + return false, nil + } + if status != "?? .boatstack/runtime.json" { + return false, fmt.Errorf("workspace cleanup refuses product or unmanaged changes: %s", status) + } + raw, err := os.ReadFile(boatstackruntime.PinPath(state.WorkspacePath)) + if err != nil { + return false, err + } + pin, err := boatstackruntime.DecodePin(raw) + if err != nil { + return false, err + } + want := boatstackruntime.NewPin( + boatstackruntime.Identity{Version: state.RuntimeVersion, SHA256: state.RuntimeFingerprint, SourceRevision: state.RuntimeSource}, + state.ProgramFingerprint, + durable.StateSchemaVersion, + ) + if pin != want { + return false, fmt.Errorf("workspace runtime pin does not match governed state") + } + return true, nil +} diff --git a/boatstack/internal/effects/driver.go b/boatstack/internal/effects/driver.go index 7ca16f56..620decf2 100644 --- a/boatstack/internal/effects/driver.go +++ b/boatstack/internal/effects/driver.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "regexp" - "runtime" "strings" "time" @@ -15,6 +14,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" ) type CommandBoundary interface { @@ -79,7 +79,7 @@ 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) { + if transition.ID == "catalog.reconcile" && (state.RuntimeVersion != admission.Invocation.RuntimeVersion || 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 { @@ -98,15 +98,6 @@ 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 @@ -154,8 +145,18 @@ func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, trans if err != nil { return nil, err } - if launcherMutation != nil { - mutations = append(mutations, *launcherMutation) + if transitionSetsRuntimePin(transition.ID) || transition.ID == "catalog.reconcile" { + pinMutation, pinErr := prepareRuntimePinMutation(layout.RepositoryRoot, next) + if pinErr != nil { + return nil, pinErr + } + mutations = append(mutations, pinMutation) + } else if transition.ID == "workspace.cut" { + pinMutation, pinErr := prepareRuntimePinMutation(next.WorkspacePath, next) + if pinErr != nil { + return nil, pinErr + } + mutations = append(mutations, pinMutation) } statePath := layout.StatePath stateInstallLast := true @@ -388,68 +389,46 @@ func verifyRuntimeParameters(admission protocol.Admission, transition catalog.Tr 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") - if !filepath.IsAbs(runtimePath) { - return fmt.Errorf("declared runtime path must be absolute") + version, _ := admission.Parameters.Get("runtime_version") + expected, _ := admission.Parameters.Get("runtime_sha256") + revision, _ := admission.Parameters.Get("source_revision") + identity := boatstackruntime.Identity{Version: version, SHA256: expected, SourceRevision: revision} + if err := identity.Validate(); err != nil { + return fmt.Errorf("declared runtime identity: %w", err) } - resolved, err := filepath.EvalSymlinks(runtimePath) + home, err := boatstackruntime.Home("") if err != nil { - return fmt.Errorf("resolve declared runtime: %w", err) - } - if filepath.Clean(runtimePath) != resolved { - return fmt.Errorf("declared runtime path must be canonical: got %s, want %s", runtimePath, resolved) + return err } - expected, _ := admission.Parameters.Get("runtime_sha256") - raw, err := os.ReadFile(runtimePath) + expectedPath, err := boatstackruntime.ExecutablePath(home, identity) if err != nil { - return fmt.Errorf("read declared runtime: %w", err) + return err } - if actual := sha256Bytes(raw); actual != expected { - return fmt.Errorf("declared runtime fingerprint mismatch: got %s", actual) + if err := boatstackruntime.VerifyExecutable(expectedPath, identity); err != nil { + return err } - 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") + if version != admission.Invocation.RuntimeVersion || expected != admission.Invocation.RuntimeFingerprint { + return fmt.Errorf("runtime transition must be owned by the exact immutable candidate process") } 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 transitionSetsRuntimePin(id catalog.TransitionID) bool { + switch id { + case "runtime.hydrate", "runtime.replace", "runtime.reconcile", "installation.initialize", "installation.update", "installation.reconcile-update": + return true + default: + return false + } } -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 - } +func prepareRuntimePinMutation(repository string, state durable.State) (ports.ResourceMutation, error) { + identity := boatstackruntime.Identity{Version: state.RuntimeVersion, SHA256: state.RuntimeFingerprint, SourceRevision: state.RuntimeSource} + pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin(identity, state.ProgramFingerprint, durable.StateSchemaVersion)) 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 + return mutationFor(boatstackruntime.PinPath(repository), pinRaw, 0o644, false, false) } func mutationForExactResource(path string, target []byte, targetLink string, mode os.FileMode, installLast, deleteResource bool) (ports.ResourceMutation, error) { diff --git a/boatstack/internal/effects/host_skills.go b/boatstack/internal/effects/host_skills.go index b0694703..9f9a4f5e 100644 --- a/boatstack/internal/effects/host_skills.go +++ b/boatstack/internal/effects/host_skills.go @@ -69,15 +69,15 @@ const updateAuthorityContract = `For this operation, request only checksum-verif request or materialize repository, provider, publication, product-delivery, or 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 +If the candidate reports exact compiled-program drift, preserve the healthy admitted +runtime 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 +restored healthy prior runtime. Never acquire repository authority to escape an update recovery frontier.` func renderHostSkill(mode hostSkillMode) []byte { diff --git a/boatstack/internal/effects/host_skills_test.go b/boatstack/internal/effects/host_skills_test.go index c86c54f5..d2525dc8 100644 --- a/boatstack/internal/effects/host_skills_test.go +++ b/boatstack/internal/effects/host_skills_test.go @@ -88,7 +88,7 @@ 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", + "preserve the healthy admitted\nruntime", "program-delta fingerprint", "Do not accept the delta implicitly", "`--accept-program-change`", diff --git a/boatstack/internal/effects/integration_test.go b/boatstack/internal/effects/integration_test.go index 0ae2af4a..57f903bd 100644 --- a/boatstack/internal/effects/integration_test.go +++ b/boatstack/internal/effects/integration_test.go @@ -26,6 +26,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor" "github.com/operatorstack/boatstack/boatstack/internal/plant" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/surfaces" "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) @@ -58,6 +59,17 @@ func testProgram() control.ControlProgram { func (c fixedClock) Now() time.Time { return c.value } +func installTestRuntime(t *testing.T, executable string, raw []byte) string { + t.Helper() + home := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, home) + identity := boatstackruntime.Identity{Version: boatstack.Version, SHA256: digestBytes(raw), SourceRevision: "fixture"} + if _, err := boatstackruntime.InstallExecutable(executable, home, identity); err != nil { + t.Fatal(err) + } + return identity.Version +} + func run(t *testing.T, directory, name string, arguments ...string) { t.Helper() command := exec.Command(name, arguments...) @@ -175,13 +187,14 @@ func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing executable, _ = filepath.Abs(executable) executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) + runtimeVersion := installTestRuntime(t, executable, runtimeRaw) initialConfig := []byte("{\"schema_version\":2,\"project\":{\"name\":\"external-initial\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") initialPath := filepath.Join(t.TempDir(), "initial.json") if err := os.WriteFile(initialPath, initialConfig, 0o600); err != nil { t.Fatal(err) } apply("installation.initialize", human, false, protocol.Parameters{ - {Name: "source_revision", Value: "external-config-fixture"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "source_revision", Value: "external-config-fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: initialPath}, {Name: "config_sha256", Value: configFingerprint(t, initialConfig)}, }) apply("goal.configure", human, false, protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}}) @@ -255,6 +268,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { executable, _ = filepath.Abs(executable) executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) + runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"drift\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { @@ -264,7 +278,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-old", FlowID: "flow-program-drift", Goal: goal, TransitionID: "installation.initialize", Authority: human, Parameters: protocol.Parameters{ - {Name: "source_revision", Value: "program-old"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "source_revision", Value: "program-old"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }, }) @@ -313,7 +327,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli", CorrelationID: "program-drift-reconcile", 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: "source_revision", Value: "program-new"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "accept_obligation_change", Value: "true"}, }, } @@ -349,6 +363,17 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { if reconciled.Snapshot.Goal.Status != model.FactAbsent || reconciled.Receipt.GoalStatus != model.FactAbsent || reconciled.Receipt.GoalID != "" { t.Fatalf("reconcile-update invented product intent: %#v", reconciled) } + pinRaw, err := os.ReadFile(boatstackruntime.PinPath(repository)) + if err != nil { + t.Fatal(err) + } + pin, err := boatstackruntime.DecodePin(pinRaw) + if err != nil { + t.Fatal(err) + } + if pin.Version != runtimeVersion || pin.SHA256 != digestBytes(runtimeRaw) || pin.SourceRevision != "program-new" || pin.ProgramFingerprint != newProgram.Fingerprint() { + t.Fatalf("repository runtime pin did not atomically follow reconciliation: %#v", pin) + } afterSuccess, err := os.ReadFile(layout.StatePath) if err != nil { t.Fatal(err) @@ -368,7 +393,7 @@ func TestProgramDriftRequiresAtomicInstallationReconciliation(t *testing.T) { FlowID: "flow-program-drift", Goal: model.Goal{ID: "ignored-command-goal", Kind: model.GoalOpenPR, DeliveryID: "ignored"}, TransitionID: "installation.update", Authority: human, Parameters: protocol.Parameters{ - {Name: "source_revision", Value: "program-current"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "source_revision", Value: "program-current"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, }, }) if err != nil { @@ -432,13 +457,14 @@ func TestReferenceExtensionUsesKernelAdmissionVerificationAndReceiptPath(t *test executable, _ = filepath.Abs(executable) executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) + runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project.json") configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"extension\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { t.Fatal(err) } apply("installation.initialize", authority(catalog.AuthorityHuman), protocol.Parameters{ - {Name: "source_revision", Value: "extension-fixture"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "source_revision", Value: "extension-fixture"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }) apply("goal.configure", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}}) @@ -559,6 +585,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing. executable, _ = filepath.Abs(executable) executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) + runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configPath := filepath.Join(t.TempDir(), "project-v2.json") configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"integration\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { @@ -566,7 +593,7 @@ func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing. } approvedGoal := model.Goal{ID: "goal-approved", Kind: model.GoalApprovedPlan, DeliveryID: "delivery-workflow"} apply(approvedGoal, "installation.initialize", authority(catalog.AuthorityHuman), protocol.Parameters{ - {Name: "source_revision", Value: "integration-revision"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "source_revision", Value: "integration-revision"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }) apply(approvedGoal, "goal.configure", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "goal_kind", Value: string(approvedGoal.Kind)}, {Name: "delivery_id", Value: approvedGoal.DeliveryID}}) @@ -691,13 +718,14 @@ func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) executable, _ = filepath.Abs(executable) executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) + runtimeVersion := installTestRuntime(t, executable, runtimeRaw) configSource := filepath.Join(t.TempDir(), "project-v2.json") configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"workspace\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configSource, configRaw, 0o600); err != nil { t.Fatal(err) } apply(sourceInvocation, "installation.initialize", human, protocol.Parameters{ - {Name: "source_revision", Value: "integration-revision"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, + {Name: "source_revision", Value: "integration-revision"}, {Name: "runtime_version", Value: runtimeVersion}, {Name: "runtime_sha256", Value: digestBytes(runtimeRaw)}, {Name: "config_path", Value: configSource}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)}, }) apply(sourceInvocation, "goal.configure", human, protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}}) diff --git a/boatstack/internal/effects/locker.go b/boatstack/internal/effects/locker.go index bd86fe92..8158e277 100644 --- a/boatstack/internal/effects/locker.go +++ b/boatstack/internal/effects/locker.go @@ -63,13 +63,10 @@ func (l Locker) Acquire(ctx context.Context, invocation model.InvocationContext, unique = append(unique, name) } } - paths := make([]string, 0, len(unique)+1) + paths := make([]string, 0, len(unique)) for _, name := range unique { 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 { @@ -110,12 +107,3 @@ 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 51f0c6b3..1e6f5806 100644 --- a/boatstack/internal/effects/locker_test.go +++ b/boatstack/internal/effects/locker_test.go @@ -49,8 +49,8 @@ func TestKernelLockUsesProcessScopedHandleNotFilePresence(t *testing.T) { } } -func TestInstallationLockCoordinatesRepositoriesSharingOneLauncher(t *testing.T) { - // control-law: shared-launcher-installation-has-one-cross-repository-writer +func TestInstallationLocksAreRepositoryScoped(t *testing.T) { + // control-law: repository-runtime-admission-cannot-lock-or-mutate-another-repository resolver, err := plant.NewResolver(t.TempDir()) if err != nil { t.Fatal(err) @@ -74,12 +74,9 @@ func TestInstallationLockCoordinatesRepositoriesSharingOneLauncher(t *testing.T) 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"}) + independent, err := locker.Acquire(context.Background(), secondInvocation, []string{"installation"}) if err != nil { - t.Fatalf("unrelated repository state lock was coupled to installation: %v", err) + t.Fatalf("independent repository installation was coupled to another repository: %v", err) } if err := independent.Release(); err != nil { t.Fatal(err) diff --git a/boatstack/internal/effects/recovery.go b/boatstack/internal/effects/recovery.go index 12d9e565..f1aa9d3b 100644 --- a/boatstack/internal/effects/recovery.go +++ b/boatstack/internal/effects/recovery.go @@ -220,9 +220,8 @@ func validateRecoveryPath(layout ports.ControllerLayout, admission protocol.Admi return nil } } - if runtimePath, ok := admission.Parameters.Get("runtime_path"); ok { - root := filepath.Dir(runtimePath) - relative, err := filepath.Rel(root, path) + if destination, ok := admission.Parameters.Get("destination"); ok { + relative, err := filepath.Rel(destination, path) if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return nil } diff --git a/boatstack/internal/effects/recovery_test.go b/boatstack/internal/effects/recovery_test.go index a735176e..66fe5f44 100644 --- a/boatstack/internal/effects/recovery_test.go +++ b/boatstack/internal/effects/recovery_test.go @@ -9,12 +9,14 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/engine" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" "github.com/operatorstack/boatstack/boatstack/internal/plant" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) @@ -95,6 +97,12 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) executable, _ = filepath.Abs(executable) executable, _ = filepath.EvalSymlinks(executable) runtimeRaw, _ := os.ReadFile(executable) + home := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, home) + runtimeIdentity := boatstackruntime.Identity{Version: buildinfo.Version, SHA256: sha256Bytes(runtimeRaw), SourceRevision: "recovery-fixture"} + if _, err := boatstackruntime.InstallExecutable(executable, home, runtimeIdentity); err != nil { + t.Fatal(err) + } configPath := filepath.Join(t.TempDir(), "project.json") configRaw := []byte("{\"schema_version\":2,\"project\":{\"name\":\"recovery\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n") if err := os.WriteFile(configPath, configRaw, 0o600); err != nil { @@ -105,7 +113,7 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) t.Fatal(err) } parameters := protocol.Parameters{ - {Name: "source_revision", Value: "recovery-fixture"}, {Name: "runtime_path", Value: executable}, {Name: "runtime_sha256", Value: sha256Bytes(runtimeRaw)}, + {Name: "source_revision", Value: "recovery-fixture"}, {Name: "runtime_version", Value: runtimeIdentity.Version}, {Name: "runtime_sha256", Value: sha256Bytes(runtimeRaw)}, {Name: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint}, } admission, err := protocol.NewAdmission(initial, goal, transition, authority, parameters, clock.Now(), time.Minute) @@ -180,4 +188,7 @@ func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) if _, err := os.Stat(layout.StatePath); !os.IsNotExist(err) { t.Fatalf("rollback did not restore absent state file: %v", err) } + if _, err := os.Stat(boatstackruntime.PinPath(repository)); !os.IsNotExist(err) { + t.Fatalf("rollback did not restore the absent repository runtime pin: %v", err) + } } diff --git a/boatstack/internal/effects/runtime_boundary_test.go b/boatstack/internal/effects/runtime_boundary_test.go new file mode 100644 index 00000000..614a4853 --- /dev/null +++ b/boatstack/internal/effects/runtime_boundary_test.go @@ -0,0 +1,59 @@ +package effects + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" + "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/testprogram" +) + +func TestRuntimeAdmissionRequiresDurableImmutableStoreArtifact(t *testing.T) { + // control-law: temporary-staging-path-can-never-become-runtime-identity + home := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, home) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + executable, err = filepath.EvalSymlinks(executable) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(raw) + digest := hex.EncodeToString(sum[:]) + identity := boatstackruntime.Identity{Version: buildinfo.Version, SHA256: digest, SourceRevision: "candidate-source"} + parameters := protocol.Parameters{ + {Name: "runtime_version", Value: identity.Version}, + {Name: "runtime_sha256", Value: identity.SHA256}, + {Name: "source_revision", Value: identity.SourceRevision}, + } + transition, _ := testprogram.StandardRegistry().Lookup("installation.update") + admission := protocol.Admission{ + Invocation: model.InvocationContext{RuntimeVersion: identity.Version, RuntimePath: executable, RuntimeFingerprint: identity.SHA256}, + Parameters: parameters, + } + if err := verifyRuntimeParameters(admission, transition); err == nil { + t.Fatal("candidate absent from the durable host store was admitted") + } + if _, err := boatstackruntime.InstallExecutable(executable, home, identity); err != nil { + t.Fatal(err) + } + if err := verifyRuntimeParameters(admission, transition); err != nil { + t.Fatalf("durably installed exact candidate was rejected: %v", err) + } + withTemporaryPath := append(parameters, protocol.Parameter{Name: "runtime_path", Value: filepath.Join(t.TempDir(), "candidate")}) + if err := withTemporaryPath.Validate(transition); err == nil { + t.Fatal("runtime transition still accepts a physical runtime path") + } +} diff --git a/boatstack/internal/effects/state_reducer.go b/boatstack/internal/effects/state_reducer.go index 5eb3cfad..de7f2952 100644 --- a/boatstack/internal/effects/state_reducer.go +++ b/boatstack/internal/effects/state_reducer.go @@ -43,14 +43,14 @@ func applyStateTransition(state *durable.State, admission protocol.Admission, tr state.Phase, state.Engagement = model.PhaseDormant, model.EngagementDormant case "runtime.hydrate", "runtime.replace": state.Runtime = model.RuntimeVerified + state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version") 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.RuntimeVersion, _ = admission.Parameters.Get("runtime_version") 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") @@ -59,13 +59,13 @@ func applyStateTransition(state *durable.State, admission protocol.Admission, tr } state.ProgramFingerprint = admission.ProgramFingerprint state.Runtime = model.RuntimeVerified + state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version") 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.RuntimeVersion, _ = admission.Parameters.Get("runtime_version") state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") - state.RuntimePath, _ = admission.Parameters.Get("runtime_path") state.RuntimeSource, _ = admission.Parameters.Get("source_revision") clearRecoveryContext(state) state.Phase = settledPhase(*state) @@ -86,8 +86,8 @@ func applyStateTransition(state *durable.State, admission protocol.Admission, tr state.ProgramFingerprint = admission.ProgramFingerprint case "installation.initialize": state.Runtime, state.Configuration = model.RuntimeVerified, model.ConfigurationVerified + state.RuntimeVersion, _ = admission.Parameters.Get("runtime_version") state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") - state.RuntimePath, _ = admission.Parameters.Get("runtime_path") state.RuntimeSource, _ = admission.Parameters.Get("source_revision") state.ConfigFingerprint, _ = admission.Parameters.Get("config_sha256") state.Phase = model.PhaseObserved diff --git a/boatstack/internal/kernel/durable/state.go b/boatstack/internal/kernel/durable/state.go index e4d4a243..410543e8 100644 --- a/boatstack/internal/kernel/durable/state.go +++ b/boatstack/internal/kernel/durable/state.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "path/filepath" "sort" "time" @@ -13,7 +12,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) -const StateSchemaVersion = 1 +const StateSchemaVersion = 2 type GateEvidence struct { Gate string `json:"gate"` @@ -49,11 +48,9 @@ type State struct { ExternalEffectPolicy string `json:"external_effect_policy,omitempty"` IndependentReview bool `json:"independent_review_for_high_risk,omitempty"` EnabledHosts []string `json:"enabled_hosts,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` 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,14 +106,8 @@ func (s State) Validate() error { if s.Transaction != model.TransactionNone && (s.TransactionID == "" || s.TransactionTransition == "") { return fmt.Errorf("durable transaction state has incomplete transaction context") } - 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.Runtime == model.RuntimeVerified && (s.RuntimeVersion == "" || s.RuntimeFingerprint == "" || s.RuntimeSource == "") { + return fmt.Errorf("verified runtime requires version, source revision, and fingerprint") } 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 39fecdca..b0aa8470 100644 --- a/boatstack/internal/kernel/engine/engine_test.go +++ b/boatstack/internal/kernel/engine/engine_test.go @@ -163,7 +163,7 @@ func observation(phase model.ProtocolPhase, fingerprint string) model.Observatio } return model.Observation{ SchemaVersion: model.SnapshotSchemaVersion, - Invocation: model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/f", ControllerID: "ctl", InvokingPath: fixtureAbsolutePath("test-fixture", "repo"), RuntimePath: fixtureAbsolutePath("test-fixture", "runtime"), RuntimeFingerprint: "runtime", Topology: model.TopologyEmbedded, Host: "cli", Correlation: "corr"}, + Invocation: model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "wt", Ref: "refs/heads/f", ControllerID: "ctl", InvokingPath: fixtureAbsolutePath("test-fixture", "repo"), RuntimeVersion: "runtime-version", RuntimePath: fixtureAbsolutePath("test-fixture", "runtime"), RuntimeFingerprint: "runtime", Topology: model.TopologyEmbedded, Host: "cli", Correlation: "corr"}, Phase: model.Known(phase, e), Engagement: model.Known(model.EngagementActive, e), Delivery: model.Known(model.DeliveryActive, e), Workspace: model.Known(model.WorkspaceActive, e), Plan: model.Known(model.PlanApproved, e), Configuration: model.Known(model.ConfigurationVerified, configurationEvidence), Runtime: model.Known(model.RuntimeVerified, e), ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}}, configurationEvidence), diff --git a/boatstack/internal/kernel/model/identity.go b/boatstack/internal/kernel/model/identity.go index d1857ccb..a2f3b400 100644 --- a/boatstack/internal/kernel/model/identity.go +++ b/boatstack/internal/kernel/model/identity.go @@ -28,6 +28,7 @@ type InvocationContext struct { Ref string `json:"ref"` ControllerID string `json:"controller_id"` InvokingPath string `json:"invoking_path"` + RuntimeVersion string `json:"runtime_version"` RuntimePath string `json:"runtime_path"` RuntimeFingerprint string `json:"runtime_fingerprint"` Topology Topology `json:"topology"` @@ -60,8 +61,8 @@ func (c InvocationContext) Validate(effectful bool) error { if c.Host == "" || c.Correlation == "" { return fmt.Errorf("invocation: host and correlation identity are required") } - if effectful && (c.WorktreeID == "" || c.Ref == "" || c.RuntimePath == "" || c.RuntimeFingerprint == "" || !filepath.IsAbs(c.RuntimePath)) { - return fmt.Errorf("invocation: effectful operation requires worktree, ref, and exact runtime identity") + if effectful && (c.WorktreeID == "" || c.Ref == "" || c.RuntimeVersion == "" || c.RuntimePath == "" || c.RuntimeFingerprint == "" || !filepath.IsAbs(c.RuntimePath)) { + return fmt.Errorf("invocation: effectful operation requires worktree, ref, and exact runtime version, location, and fingerprint") } return nil } diff --git a/boatstack/internal/kernel/model/state_test.go b/boatstack/internal/kernel/model/state_test.go index 9607f0b9..9cceb61e 100644 --- a/boatstack/internal/kernel/model/state_test.go +++ b/boatstack/internal/kernel/model/state_test.go @@ -24,7 +24,7 @@ func testObservation(phase ProtocolPhase) Observation { SchemaVersion: SnapshotSchemaVersion, Invocation: InvocationContext{ RepositoryID: "repo-1", GitCommonID: "git-1", WorktreeID: "worktree-1", Ref: "refs/heads/feature", - ControllerID: "controller-1", InvokingPath: testAbsolutePath("test-fixture", "repo"), RuntimePath: testAbsolutePath("test-fixture", "runtime", "boatstack"), RuntimeFingerprint: "runtime-fingerprint", + ControllerID: "controller-1", InvokingPath: testAbsolutePath("test-fixture", "repo"), RuntimeVersion: "runtime-version", RuntimePath: testAbsolutePath("test-fixture", "runtime", "boatstack"), RuntimeFingerprint: "runtime-fingerprint", Topology: TopologyEmbedded, Host: "cli", Correlation: "corr-1", }, Phase: Known(phase, evidence), Engagement: Known(EngagementActive, evidence), Delivery: Known(DeliveryActive, evidence), diff --git a/boatstack/internal/kernel/protocol/parameters.go b/boatstack/internal/kernel/protocol/parameters.go index f6196149..a523f38f 100644 --- a/boatstack/internal/kernel/protocol/parameters.go +++ b/boatstack/internal/kernel/protocol/parameters.go @@ -54,7 +54,7 @@ func (p Parameters) Validate(transition catalog.Transition) error { } seen[parameter.Name] = true switch parameter.Name { - case "source_path", "runtime_path", "config_path", "destination", "evidence_path", "manifest_path", "body_path": + case "source_path", "config_path", "destination", "evidence_path", "manifest_path", "body_path": if !filepath.IsAbs(parameter.Value) { return fmt.Errorf("transition %q parameter %q must be an absolute path", transition.ID, parameter.Name) } diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go index 3be4c3e9..f7f6fde0 100644 --- a/boatstack/internal/kernel/protocol/receipt.go +++ b/boatstack/internal/kernel/protocol/receipt.go @@ -8,7 +8,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" ) -const ReceiptSchemaVersion = 2 +const ReceiptSchemaVersion = 3 type Outcome string @@ -30,6 +30,7 @@ type TransitionReceipt struct { PriorProgramFingerprint string `json:"prior_program_fingerprint,omitempty"` ProgramDeltaFingerprint string `json:"program_delta_fingerprint,omitempty"` ProgramChangeAccepted bool `json:"program_change_accepted,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"` RuntimeSourceRevision string `json:"runtime_source_revision,omitempty"` AdmissionID string `json:"admission_id"` @@ -82,9 +83,12 @@ func NewReceipt(flowID string, sequence uint64, admission Admission, transition 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 receipt.RuntimeVersion, _ = admission.Parameters.Get("runtime_version"); receipt.RuntimeVersion != "" { + receipt.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256") + receipt.RuntimeSourceRevision, _ = admission.Parameters.Get("source_revision") + } if transition.Policy.ReconcilesProgram && receipt.RuntimeFingerprint == "" { + receipt.RuntimeVersion = admission.Invocation.RuntimeVersion receipt.RuntimeFingerprint = admission.Invocation.RuntimeFingerprint } identity := receipt @@ -131,16 +135,19 @@ func (r TransitionReceipt) Validate() error { if (r.PriorProgramFingerprint == "") != (r.ProgramDeltaFingerprint == "") { return fmt.Errorf("receipt has incomplete program delta identity") } + if (r.RuntimeVersion == "") != (r.RuntimeFingerprint == "") || (r.RuntimeVersion == "") != (r.RuntimeSourceRevision == "") { + return fmt.Errorf("receipt has incomplete runtime version, digest, or source 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) { + if r.ProgramChangeAccepted && (r.PriorProgramFingerprint == "" || r.RuntimeVersion == "" || 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 == "") { + if r.TransitionID == "installation.reconcile-update" && (!r.ProgramChangeAccepted || r.PriorProgramFingerprint == "" || r.RuntimeVersion == "" || len(r.RuntimeFingerprint) != 64 || r.RuntimeSourceRevision == "") { return fmt.Errorf("reconciled installation receipt lacks exact program and runtime identity") } identity := r diff --git a/boatstack/internal/plant/observer.go b/boatstack/internal/plant/observer.go index ba28f7f5..7e454f42 100644 --- a/boatstack/internal/plant/observer.go +++ b/boatstack/internal/plant/observer.go @@ -21,6 +21,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" ) type TimeSource interface{ Now() time.Time } @@ -91,32 +92,52 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) } runtimeState := state.Runtime runtimeEvidence := append([]model.Evidence(nil), stateEvidence...) - if state.RuntimePath == "" { + pinPath := boatstackruntime.PinPath(layout.RepositoryRoot) + pinEvidence, _, pinExists, pinEvidenceErr := fileEvidence(pinPath, "runtime-pin", now) + if pinEvidenceErr != nil { + return model.Observation{}, pinEvidenceErr + } + runtimeEvidence = append(runtimeEvidence, pinEvidence) + if state.RuntimeVersion == "" || state.RuntimeFingerprint == "" || state.RuntimeSource == "" { if runtimeState == model.RuntimeVerified { runtimeState = model.RuntimeInvalid } - } else { - evidence, fingerprint, exists, runtimeErr := fileEvidence(state.RuntimePath, "runtime", now) - if runtimeErr != nil { - return model.Observation{}, runtimeErr - } - runtimeEvidence = append(runtimeEvidence, evidence) - if !exists { - runtimeState = model.RuntimeAbsent - } else if state.RuntimeFingerprint == "" || state.RuntimeFingerprint != fingerprint { - runtimeState = model.RuntimeStale - } else if state.RuntimePath != current.RuntimePath || state.RuntimeFingerprint != current.RuntimeFingerprint { - runtimeState = model.RuntimeWrongSource + if pinExists { + runtimeState = model.RuntimeConflicting } - } - 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 + } else if !pinExists { + runtimeState = model.RuntimeAbsent + } else { + pinRaw, readPinErr := os.ReadFile(pinPath) + if readPinErr != nil { + return model.Observation{}, readPinErr + } + pin, decodePinErr := boatstackruntime.DecodePin(pinRaw) + identity := boatstackruntime.Identity{Version: state.RuntimeVersion, SHA256: state.RuntimeFingerprint, SourceRevision: state.RuntimeSource} + if decodePinErr != nil || pin.Identity() != identity || pin.ProgramFingerprint != state.ProgramFingerprint || pin.StateSchemaVersion != durable.StateSchemaVersion { + runtimeState = model.RuntimeConflicting + } else { + home, homeErr := boatstackruntime.Home("") + if homeErr != nil { + return model.Observation{}, homeErr + } + runtimePath, pathErr := boatstackruntime.ExecutablePath(home, identity) + if pathErr != nil { + runtimeState = model.RuntimeInvalid + } else { + evidence, fingerprint, exists, runtimeErr := fileEvidence(runtimePath, "runtime", now) + if runtimeErr != nil { + return model.Observation{}, runtimeErr + } + runtimeEvidence = append(runtimeEvidence, evidence) + if !exists { + runtimeState = model.RuntimeAbsent + } else if verifyErr := boatstackruntime.VerifyExecutable(runtimePath, identity); verifyErr != nil || fingerprint != state.RuntimeFingerprint { + runtimeState = model.RuntimeStale + } else if current.RuntimeVersion != state.RuntimeVersion || current.RuntimeFingerprint != state.RuntimeFingerprint { + runtimeState = model.RuntimeWrongSource + } + } } } verification := state.Verification @@ -229,36 +250,6 @@ 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 diff --git a/boatstack/internal/plant/observer_test.go b/boatstack/internal/plant/observer_test.go index bb5e380e..1b4003b8 100644 --- a/boatstack/internal/plant/observer_test.go +++ b/boatstack/internal/plant/observer_test.go @@ -13,6 +13,7 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" ) type observerClock struct{ now time.Time } @@ -43,6 +44,14 @@ func TestObserverBindsVerifiedRuntimeToExecutingBinary(t *testing.T) { if err != nil { t.Fatal(err) } + home := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, home) + identity := boatstackruntime.Identity{Version: resolver.runtimeVersion, SHA256: resolver.runtimeFingerprint, SourceRevision: "fixture-revision"} + installed, err := boatstackruntime.InstallExecutable(resolver.runtimePath, home, identity) + if err != nil { + t.Fatal(err) + } + resolver.runtimePath = installed invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "runtime-binding") if err != nil { t.Fatal(err) @@ -53,9 +62,10 @@ func TestObserverBindsVerifiedRuntimeToExecutingBinary(t *testing.T) { } state := durable.Default(invocation, time.Unix(100, 0).UTC()) state.Runtime = model.RuntimeVerified - state.RuntimePath = invocation.RuntimePath + state.RuntimeVersion = invocation.RuntimeVersion state.RuntimeFingerprint = invocation.RuntimeFingerprint state.RuntimeSource = "fixture-revision" + state.ProgramFingerprint = strings.Repeat("a", 64) raw, err := durable.EncodeState(state) if err != nil { t.Fatal(err) @@ -66,36 +76,36 @@ func TestObserverBindsVerifiedRuntimeToExecutingBinary(t *testing.T) { if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { t.Fatal(err) } - - observer, err := NewObserver(resolver, observerClock{now: time.Unix(200, 0).UTC()}) + pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin(identity, state.ProgramFingerprint, durable.StateSchemaVersion)) if err != nil { t.Fatal(err) } - observed, err := observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) - if err != nil { + if err := os.MkdirAll(filepath.Dir(boatstackruntime.PinPath(repository)), 0o755); err != nil { t.Fatal(err) } - if observed.Runtime.Value != model.RuntimeVerified { - t.Fatalf("matching executing runtime observed as %s", observed.Runtime.Value) + if err := os.WriteFile(boatstackruntime.PinPath(repository), pinRaw, 0o644); err != nil { + t.Fatal(err) } - state.RuntimePath = filepath.Join(t.TempDir(), "stale-runtime") - if err := os.WriteFile(state.RuntimePath, mustRead(t, invocation.RuntimePath), 0o700); err != nil { + observer, err := NewObserver(resolver, observerClock{now: time.Unix(200, 0).UTC()}) + if err != nil { t.Fatal(err) } - raw, err = durable.EncodeState(state) + observed, err := observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) if err != nil { t.Fatal(err) } - if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil { - t.Fatal(err) + if observed.Runtime.Value != model.RuntimeVerified { + t.Fatalf("matching executing runtime observed as %s", observed.Runtime.Value) } + + observer.resolver.runtimeFingerprint = strings.Repeat("b", 64) observed, err = observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) if err != nil { t.Fatal(err) } if observed.Runtime.Value != model.RuntimeWrongSource { - t.Fatalf("alternate runtime path observed as %s, want %s", observed.Runtime.Value, model.RuntimeWrongSource) + t.Fatalf("different runtime identity observed as %s, want %s", observed.Runtime.Value, model.RuntimeWrongSource) } } diff --git a/boatstack/internal/plant/resolver.go b/boatstack/internal/plant/resolver.go index b4466645..c4a5db67 100644 --- a/boatstack/internal/plant/resolver.go +++ b/boatstack/internal/plant/resolver.go @@ -11,6 +11,7 @@ import ( "runtime" "strings" + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" @@ -31,6 +32,7 @@ func (execRunner) Output(ctx context.Context, name string, arguments ...string) type Resolver struct { externalRoot string runtimePath string + runtimeVersion string runtimeFingerprint string runner CommandRunner } @@ -55,7 +57,7 @@ func NewResolver(externalBase string) (Resolver, error) { if err != nil { return Resolver{}, fmt.Errorf("read executing runtime: %w", err) } - return Resolver{externalRoot: root, runtimePath: runtimePath, runtimeFingerprint: digest(string(runtimeRaw), 0), runner: execRunner{}}, nil + return Resolver{externalRoot: root, runtimePath: runtimePath, runtimeVersion: buildinfo.Version, runtimeFingerprint: digest(string(runtimeRaw), 0), runner: execRunner{}}, nil } func NewResolverWithRunner(externalBase string, runner CommandRunner) (Resolver, error) { @@ -214,7 +216,7 @@ func (r Resolver) ResolveInvocation(ctx context.Context, path, host, correlation } invocation := model.InvocationContext{ RepositoryID: repositoryID, GitCommonID: gitCommonID, WorktreeID: worktreeID, Ref: ref, - ControllerID: controllerID, InvokingPath: invokingPath, RuntimePath: r.runtimePath, RuntimeFingerprint: r.runtimeFingerprint, + ControllerID: controllerID, InvokingPath: invokingPath, RuntimeVersion: r.runtimeVersion, RuntimePath: r.runtimePath, RuntimeFingerprint: r.runtimeFingerprint, Topology: topology, Host: host, Correlation: correlation, } if err := invocation.Validate(true); err != nil { diff --git a/boatstack/internal/runtime/dispatch.go b/boatstack/internal/runtime/dispatch.go new file mode 100644 index 00000000..b77b749a --- /dev/null +++ b/boatstack/internal/runtime/dispatch.go @@ -0,0 +1,106 @@ +package runtime + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func ShouldDispatch(argv0 string) bool { + name := strings.ToLower(filepath.Base(argv0)) + return name == "boatstack" || name == "boatstack.exe" +} + +func Dispatch(arguments []string) (int, error) { + repository, err := repositoryArgument(arguments) + if err != nil { + return 1, err + } + executable, _, err := ResolvePinnedExecutable(repository) + if err != nil { + return 1, err + } + return execute(executable, append([]string{executable}, arguments...), os.Environ()) +} + +func ResolvePinnedExecutable(repository string) (string, Pin, error) { + repository, err := findPinnedRepository(repository) + if err != nil { + return "", Pin{}, err + } + raw, err := os.ReadFile(PinPath(repository)) + if err != nil { + return "", Pin{}, fmt.Errorf("read repository runtime pin: %w", err) + } + pin, err := DecodePin(raw) + if err != nil { + return "", Pin{}, fmt.Errorf("decode repository runtime pin: %w", err) + } + home, err := Home("") + if err != nil { + return "", Pin{}, err + } + executable, err := ExecutablePath(home, pin.Identity()) + if err != nil { + return "", Pin{}, err + } + if err := VerifyExecutable(executable, pin.Identity()); err != nil { + return "", Pin{}, err + } + return executable, pin, nil +} + +func repositoryArgument(arguments []string) (string, error) { + repository := "" + for index := 0; index < len(arguments); index++ { + value := arguments[index] + if value == "--repo" { + if index+1 >= len(arguments) || strings.TrimSpace(arguments[index+1]) == "" { + return "", fmt.Errorf("--repo requires a path") + } + repository = arguments[index+1] + index++ + continue + } + if strings.HasPrefix(value, "--repo=") { + repository = strings.TrimPrefix(value, "--repo=") + } + } + if repository == "" { + var err error + repository, err = os.Getwd() + if err != nil { + return "", err + } + } + absolute, err := filepath.Abs(repository) + if err != nil { + return "", err + } + return filepath.Clean(absolute), nil +} + +func findPinnedRepository(start string) (string, error) { + current := start + if info, err := os.Stat(current); err == nil && !info.IsDir() { + current = filepath.Dir(current) + } + for { + if _, err := os.Stat(PinPath(current)); err == nil { + return current, nil + } else if !os.IsNotExist(err) { + return "", err + } + if _, err := os.Lstat(filepath.Join(current, ".git")); err == nil { + return "", fmt.Errorf("repository at %s has no Boatstack runtime pin; initialize it first", current) + } else if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("no Boatstack runtime pin found from %s; initialize this repository first", start) + } + current = parent + } +} diff --git a/boatstack/internal/runtime/exec_unix.go b/boatstack/internal/runtime/exec_unix.go new file mode 100644 index 00000000..6ea0a73e --- /dev/null +++ b/boatstack/internal/runtime/exec_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package runtime + +import "syscall" + +func execute(path string, arguments, environment []string) (int, error) { + if err := syscall.Exec(path, arguments, environment); err != nil { + return 1, err + } + return 0, nil +} diff --git a/boatstack/internal/runtime/exec_windows.go b/boatstack/internal/runtime/exec_windows.go new file mode 100644 index 00000000..23dbfde5 --- /dev/null +++ b/boatstack/internal/runtime/exec_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package runtime + +import ( + "os" + "os/exec" +) + +func execute(path string, arguments, environment []string) (int, error) { + command := exec.Command(path, arguments[1:]...) + command.Env, command.Stdin, command.Stdout, command.Stderr = environment, os.Stdin, os.Stdout, os.Stderr + if err := command.Run(); err != nil { + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode(), nil + } + return 1, err + } + return 0, nil +} diff --git a/boatstack/internal/runtime/identity.go b/boatstack/internal/runtime/identity.go new file mode 100644 index 00000000..11bbaa02 --- /dev/null +++ b/boatstack/internal/runtime/identity.go @@ -0,0 +1,239 @@ +package runtime + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" +) + +const ( + PinSchemaVersion = 1 + HomeEnvironment = "BOATSTACK_HOME" +) + +var versionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +type Identity struct { + Version string `json:"version"` + SHA256 string `json:"sha256"` + SourceRevision string `json:"source_revision"` +} + +func (i Identity) Validate() error { + if !versionPattern.MatchString(i.Version) { + return fmt.Errorf("runtime version must be a non-empty filesystem-safe identity") + } + if len(i.SHA256) != 64 { + return fmt.Errorf("runtime SHA-256 must contain 64 lowercase hexadecimal characters") + } + if _, err := hex.DecodeString(i.SHA256); err != nil || strings.ToLower(i.SHA256) != i.SHA256 { + return fmt.Errorf("runtime SHA-256 must contain 64 lowercase hexadecimal characters") + } + if strings.TrimSpace(i.SourceRevision) == "" { + return fmt.Errorf("runtime source revision is required") + } + return nil +} + +type Pin struct { + SchemaVersion int `json:"schema_version"` + Version string `json:"version"` + SHA256 string `json:"sha256"` + SourceRevision string `json:"source_revision"` + ProgramFingerprint string `json:"program_fingerprint"` + StateSchemaVersion int `json:"state_schema_version"` +} + +func NewPin(identity Identity, programFingerprint string, stateSchemaVersion int) Pin { + return Pin{ + SchemaVersion: PinSchemaVersion, Version: identity.Version, SHA256: identity.SHA256, + SourceRevision: identity.SourceRevision, ProgramFingerprint: programFingerprint, + StateSchemaVersion: stateSchemaVersion, + } +} + +func (p Pin) Identity() Identity { + return Identity{Version: p.Version, SHA256: p.SHA256, SourceRevision: p.SourceRevision} +} + +func (p Pin) Validate() error { + if p.SchemaVersion != PinSchemaVersion { + return fmt.Errorf("runtime pin schema %d, want %d", p.SchemaVersion, PinSchemaVersion) + } + if err := p.Identity().Validate(); err != nil { + return err + } + if len(p.ProgramFingerprint) != 64 { + return fmt.Errorf("runtime pin requires an exact control-program fingerprint") + } + if _, err := hex.DecodeString(p.ProgramFingerprint); err != nil || strings.ToLower(p.ProgramFingerprint) != p.ProgramFingerprint { + return fmt.Errorf("runtime pin control-program fingerprint must be lowercase hexadecimal") + } + if p.StateSchemaVersion < 1 { + return fmt.Errorf("runtime pin requires a state schema identity") + } + return nil +} + +func EncodePin(pin Pin) ([]byte, error) { + if err := pin.Validate(); err != nil { + return nil, err + } + raw, err := json.MarshalIndent(pin, "", " ") + if err != nil { + return nil, err + } + return append(raw, '\n'), nil +} + +func DecodePin(raw []byte) (Pin, error) { + var pin Pin + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&pin); err != nil { + return Pin{}, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return Pin{}, fmt.Errorf("runtime pin contains trailing JSON") + } + if err := pin.Validate(); err != nil { + return Pin{}, err + } + return pin, nil +} + +func PinPath(repository string) string { + return filepath.Join(repository, ".boatstack", "runtime.json") +} + +func Home(explicit string) (string, error) { + if explicit == "" { + explicit = strings.TrimSpace(os.Getenv(HomeEnvironment)) + } + if explicit != "" { + absolute, err := filepath.Abs(explicit) + if err != nil { + return "", err + } + return filepath.Clean(absolute), nil + } + if goruntime.GOOS == "windows" { + if base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); base != "" { + return filepath.Join(base, "Boatstack"), nil + } + } + if base := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); base != "" { + return filepath.Join(base, "boatstack"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "boatstack"), nil +} + +func ExecutablePath(home string, identity Identity) (string, error) { + if err := identity.Validate(); err != nil { + return "", err + } + if home == "" || !filepath.IsAbs(home) { + return "", fmt.Errorf("runtime home must be absolute") + } + name := "boatstack-runtime" + if goruntime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(home, "runtimes", identity.Version+"-"+identity.SHA256, name), nil +} + +func VerifyExecutable(path string, identity Identity) error { + if err := identity.Validate(); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("pinned Boatstack runtime is not installed: %s", identity.Version+"@"+identity.SHA256) + } + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("pinned Boatstack runtime must be an immutable regular file") + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + sum := sha256.Sum256(raw) + if actual := hex.EncodeToString(sum[:]); actual != identity.SHA256 { + return fmt.Errorf("pinned Boatstack runtime checksum mismatch: got %s, want %s", actual, identity.SHA256) + } + return nil +} + +// InstallExecutable adds exact bytes to the host store without ever replacing +// an existing runtime identity. A digest collision fails closed. +func InstallExecutable(source, home string, identity Identity) (string, error) { + target, err := ExecutablePath(home, identity) + if err != nil { + return "", err + } + if err := VerifyExecutable(source, identity); err != nil { + return "", fmt.Errorf("verify runtime candidate: %w", err) + } + if err := VerifyExecutable(target, identity); err == nil { + return target, nil + } else if _, statErr := os.Lstat(target); statErr == nil { + return "", fmt.Errorf("Boatstack immutable runtime store collision at %s", target) + } else if !os.IsNotExist(statErr) { + return "", statErr + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", err + } + sourceFile, err := os.Open(source) + if err != nil { + return "", err + } + defer sourceFile.Close() + temporary, err := os.CreateTemp(filepath.Dir(target), ".boatstack-runtime-") + if err != nil { + return "", err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if _, err := io.Copy(temporary, sourceFile); err != nil { + temporary.Close() + return "", err + } + if err := temporary.Chmod(0o755); err != nil { + temporary.Close() + return "", err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return "", err + } + if err := temporary.Close(); err != nil { + return "", err + } + if err := VerifyExecutable(temporaryPath, identity); err != nil { + return "", fmt.Errorf("verify staged runtime candidate: %w", err) + } + if err := os.Link(temporaryPath, target); err != nil { + if verifyErr := VerifyExecutable(target, identity); verifyErr == nil { + return target, nil + } + return "", fmt.Errorf("install immutable Boatstack runtime: %w", err) + } + return target, VerifyExecutable(target, identity) +} diff --git a/boatstack/internal/runtime/runtime_test.go b/boatstack/internal/runtime/runtime_test.go new file mode 100644 index 00000000..523df841 --- /dev/null +++ b/boatstack/internal/runtime/runtime_test.go @@ -0,0 +1,177 @@ +package runtime + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func fixtureIdentity(version, source string, raw []byte) Identity { + sum := sha256.Sum256(raw) + return Identity{Version: version, SHA256: hex.EncodeToString(sum[:]), SourceRevision: source} +} + +func writeCandidate(t *testing.T, raw []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "candidate") + if err := os.WriteFile(path, raw, 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func writePin(t *testing.T, repository string, identity Identity) { + t.Helper() + raw, err := EncodePin(NewPin(identity, strings.Repeat("a", 64), 2)) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(PinPath(repository)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(PinPath(repository), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestRepositoriesResolveIndependentExactRuntimePins(t *testing.T) { + // control-law: repository-runtime-selection-is-exact-and-isolated + home := t.TempDir() + t.Setenv(HomeEnvironment, home) + oldRaw, newRaw := []byte("old-runtime"), []byte("new-runtime") + oldIdentity := fixtureIdentity("v1.0.0", "old-source", oldRaw) + newIdentity := fixtureIdentity("v2.0.0", "new-source", newRaw) + oldPath, err := InstallExecutable(writeCandidate(t, oldRaw), home, oldIdentity) + if err != nil { + t.Fatal(err) + } + newPath, err := InstallExecutable(writeCandidate(t, newRaw), home, newIdentity) + if err != nil { + t.Fatal(err) + } + repositoryA, repositoryB := t.TempDir(), t.TempDir() + writePin(t, repositoryA, oldIdentity) + writePin(t, repositoryB, newIdentity) + resolvedA, _, err := ResolvePinnedExecutable(repositoryA) + if err != nil { + t.Fatal(err) + } + resolvedB, _, err := ResolvePinnedExecutable(repositoryB) + if err != nil { + t.Fatal(err) + } + if resolvedA != oldPath || resolvedB != newPath { + t.Fatalf("resolved A=%s B=%s; want A=%s B=%s", resolvedA, resolvedB, oldPath, newPath) + } + thirdRaw := []byte("available-but-not-admitted") + thirdIdentity := fixtureIdentity("v3.0.0", "third-source", thirdRaw) + if _, err := InstallExecutable(writeCandidate(t, thirdRaw), home, thirdIdentity); err != nil { + t.Fatal(err) + } + resolvedAAfter, _, _ := ResolvePinnedExecutable(repositoryA) + resolvedBAfter, _, _ := ResolvePinnedExecutable(repositoryB) + if resolvedAAfter != oldPath || resolvedBAfter != newPath { + t.Fatal("installing an available candidate changed a repository selection") + } +} + +func TestMissingPinnedRuntimeFailsClosedWithoutLatestFallback(t *testing.T) { + // control-law: missing-exact-runtime-never-falls-through-to-another-version + home := t.TempDir() + t.Setenv(HomeEnvironment, home) + availableRaw := []byte("available") + available := fixtureIdentity("v9.0.0", "available-source", availableRaw) + if _, err := InstallExecutable(writeCandidate(t, availableRaw), home, available); err != nil { + t.Fatal(err) + } + missing := fixtureIdentity("v1.0.0", "missing-source", []byte("missing")) + repository := t.TempDir() + writePin(t, repository, missing) + if _, _, err := ResolvePinnedExecutable(repository); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Fatalf("missing pin resolution error = %v", err) + } +} + +func TestNestedUninitializedRepositoryCannotInheritParentPin(t *testing.T) { + // control-law: repository-runtime-selection-never-crosses-a-git-boundary + home := t.TempDir() + t.Setenv(HomeEnvironment, home) + raw := []byte("parent-runtime") + identity := fixtureIdentity("v1.0.0", "parent-source", raw) + if _, err := InstallExecutable(writeCandidate(t, raw), home, identity); err != nil { + t.Fatal(err) + } + parent := t.TempDir() + writePin(t, parent, identity) + nested := filepath.Join(parent, "nested") + if err := os.MkdirAll(filepath.Join(nested, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if _, _, err := ResolvePinnedExecutable(nested); err == nil || !strings.Contains(err.Error(), "has no Boatstack runtime pin") { + t.Fatalf("nested repository resolution error = %v", err) + } +} + +func TestRuntimeStoreIsImmutableAndScalesIndependentlyOfSelection(t *testing.T) { + // control-law: immutable-runtime-count-does-not-affect-exact-lookup + home := t.TempDir() + t.Setenv(HomeEnvironment, home) + var selected Identity + var selectedPath string + var selectedRaw []byte + for index := 0; index < 50; index++ { + raw := []byte(fmt.Sprintf("runtime-%02d", index)) + identity := fixtureIdentity(fmt.Sprintf("v1.0.%02d", index), "source", raw) + path, err := InstallExecutable(writeCandidate(t, raw), home, identity) + if err != nil { + t.Fatal(err) + } + if index == 23 { + selected, selectedPath, selectedRaw = identity, path, append([]byte(nil), raw...) + } + } + repository := t.TempDir() + writePin(t, repository, selected) + resolved, _, err := ResolvePinnedExecutable(repository) + if err != nil || resolved != selectedPath { + t.Fatalf("50-version resolution = %s, %v", resolved, err) + } + if err := os.WriteFile(selectedPath, []byte("mutated"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := InstallExecutable(writeCandidate(t, selectedRaw), home, selected); err == nil { + t.Fatal("immutable runtime collision was overwritten") + } +} + +func TestRuntimePinSurvivesRepositoryMoveWithoutHostPath(t *testing.T) { + // control-law: repository-pin-is-portable-identity-not-host-location + home := t.TempDir() + t.Setenv(HomeEnvironment, home) + raw := []byte("portable") + identity := fixtureIdentity("v4.0.0", "portable-source", raw) + expected, err := InstallExecutable(writeCandidate(t, raw), home, identity) + if err != nil { + t.Fatal(err) + } + original, moved := t.TempDir(), t.TempDir() + writePin(t, original, identity) + pinRaw, _ := os.ReadFile(PinPath(original)) + if strings.Contains(string(pinRaw), home) { + t.Fatal("repository pin contains a host-local path") + } + if err := os.MkdirAll(filepath.Dir(PinPath(moved)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(PinPath(moved), pinRaw, 0o644); err != nil { + t.Fatal(err) + } + resolved, _, err := ResolvePinnedExecutable(moved) + if err != nil || resolved != expected { + t.Fatalf("moved repository resolution = %s, %v", resolved, err) + } +} diff --git a/boatstack/kernel.go b/boatstack/kernel.go index 22069556..45a0294c 100644 --- a/boatstack/kernel.go +++ b/boatstack/kernel.go @@ -9,6 +9,7 @@ import ( "time" "github.com/operatorstack/boatstack/boatstack/control" + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" "github.com/operatorstack/boatstack/boatstack/internal/effects" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/engine" @@ -21,9 +22,9 @@ import ( ) var ( - Version = "v2.0.0-dev" - SourceCommit = "unknown" - ChecksumsSHA256 = "development" + Version = buildinfo.Version + SourceCommit = buildinfo.SourceCommit + ChecksumsSHA256 = buildinfo.ChecksumsSHA256 ) // Kernel is the deterministic mechanism facade over one immutable compiled diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md index b468370a..356a2884 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-v2-transition-catalog.md @@ -7,68 +7,68 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | 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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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` | -| `plan.approve` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | -| `plan.approve-amendment` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | -| `plan.create` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | -| `plan.invalidate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | -| `plan.validate` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | -| `publication.abandon` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | -| `publication.correct` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | -| `publication.execute` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | -| `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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`
`1dd9892b2fdd1e835d8cc15e3a26584b7ad21fea0c1cdaa404e1211841b86e4c` | `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` | -| `workspace.cut` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | -| `workspace.publish` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | -| `workspace.reap` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | -| `workspace.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | -| `workspace.sync` | primary-flow:`boatstack.standard@1.0.0`
`b2d86642677b67c6b5a4ba77fae3ecbe2a301f3a595926ba339be44f077698a9` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | +| `catalog.reconcile` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | GOAL_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_version*`, `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `source_revision*`, `runtime_version*`, `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | +| `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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` | +| `plan.approve` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | +| `plan.approve-amendment` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | FLOW_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | +| `plan.create` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | +| `plan.invalidate` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | +| `plan.validate` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | +| `publication.abandon` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | +| `publication.correct` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | +| `publication.execute` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | FLOW_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | +| `publication.observe` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `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`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | GOAL_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | +| `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | +| `runtime.replace` | core-system:`boatstack.core@1.0.0`
`cd8c2e6872499ca7276f580c88206dd0f0882bb26051bee667e90419c723f834` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | +| `workspace.abandon` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `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` | +| `workspace.cut` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | FLOW_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | +| `workspace.publish` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | +| `workspace.reap` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | +| `workspace.reconcile` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | FLOW_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | +| `workspace.sync` | primary-flow:`boatstack.standard@1.0.0`
`59e4d6078d2745d6885b5ccc4b155ebbf069332ce7474d6ba4fbd2b5bbbd0310` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | `*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`. diff --git a/docs/runtime-selection.md b/docs/runtime-selection.md new file mode 100644 index 00000000..76b3577f --- /dev/null +++ b/docs/runtime-selection.md @@ -0,0 +1,36 @@ +# Runtime selection + +Boatstack selects a runtime from repository-owned identity, not from a mutable +global current version. + +`.boatstack/runtime.json` records the admitted version, SHA-256 digest, source +revision, compiled-program fingerprint, and state schema version. It contains no +machine path. The stable `boatstack` dispatcher finds this pin from `--repo` or +the current directory, derives the host-store path, verifies the exact bytes, +and executes only that runtime. + +The default host store is: + +- `$BOATSTACK_HOME/runtimes` when `BOATSTACK_HOME` is set; +- `$XDG_DATA_HOME/boatstack/runtimes` on Unix when `XDG_DATA_HOME` is set; +- `~/.local/share/boatstack/runtimes` on Unix otherwise; +- `%LOCALAPPDATA%\Boatstack\runtimes` on Windows. + +Each artifact is stored as +`-/boatstack-runtime[.exe]`. Existing identity slots are never +overwritten. A missing, symlinked, non-regular, or digest-mismatched artifact is +an error; the dispatcher does not fall back to another installed version. + +An update first verifies and durably installs the candidate. The Kernel then +admits `installation.update` or `installation.reconcile-update` and atomically +commits the new pin with its state and generated projections. Refusal leaves the +old pin active. Recovery rollback restores the prior pin. Installing another +runtime or replacing the dispatcher cannot change a repository's selection. + +The pin is worktree-visible and portable across clones. A newly created managed +worktree receives the governed pin. Different repositories and worktrees can +therefore select different admitted versions from the same host store. + +Runtime garbage collection is intentionally outside the update transition. A +future collector must prove that no repository pin references an artifact +before removing it. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 26255e37..dab5afb4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,16 +14,18 @@ Malformed or unsupported configuration is not treated as verified. ## Runtime is absent, stale, or wrong -Use the checksum-verifying installer in update mode. It installs a versioned -runtime candidate, requests `installation.update`, and changes the launcher -only after the kernel verifies the candidate. +Use the checksum-verifying installer in update mode. It durably installs the +exact version-and-digest runtime candidate, requests `installation.update`, and +changes the repository runtime pin only after the kernel verifies the +candidate. ```sh BOATSTACK_MODE=update BOATSTACK_VERSION= \ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/main/install.sh)" ``` -Do not copy a helper from another worktree or select a “latest” cache slot. +Do not copy a helper from another worktree or select a “latest” cache slot. A +missing pinned runtime fails closed; reinstall that exact release and checksum. ## A transition is FRONTIER diff --git a/install.ps1 b/install.ps1 index 888cf2fb..e4bdd9a6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,6 +8,7 @@ $Version = if ($env:BOATSTACK_VERSION) { $env:BOATSTACK_VERSION } else { "latest $Mode = if ($env:BOATSTACK_MODE) { $env:BOATSTACK_MODE } else { "install" } $Actor = if ($env:BOATSTACK_ACTOR) { $env:BOATSTACK_ACTOR } elseif ($env:USERNAME) { $env:USERNAME } else { "operator" } $InstallDir = if ($env:BOATSTACK_INSTALL_DIR) { $env:BOATSTACK_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Boatstack\bin" } +$BoatstackHome = if ($env:BOATSTACK_HOME) { $env:BOATSTACK_HOME } else { Join-Path $env:LOCALAPPDATA "Boatstack" } if ($Mode -notin @("install", "update")) { throw "Boatstack V2 supports BOATSTACK_MODE=install or update" } $RepositoryOutput = & git -C $Repository rev-parse --show-toplevel @@ -56,10 +57,34 @@ try { $Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Candidate).Hash.ToLowerInvariant() if ($Actual -ne $Expected) { throw "Boatstack runtime checksum mismatch" } - New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null - $SafeVersion = [regex]::Replace($Version, '[^A-Za-z0-9._-]', '-') - $Runtime = Join-Path $InstallDir ("boatstack-v2-$SafeVersion-" + $Actual.Substring(0, 16) + ".exe") - Copy-Item -LiteralPath $Candidate -Destination $Runtime -Force + $CandidateVersionOutput = & $Candidate version + if ($LASTEXITCODE -ne 0 -or -not $CandidateVersionOutput) { throw "Boatstack runtime did not report its version identity" } + $CandidateVersion = ($CandidateVersionOutput -join "`n").Trim() + $SafeVersion = [regex]::Replace($CandidateVersion, '[^A-Za-z0-9._-]', '-') + if (-not $SafeVersion -or $SafeVersion -ne $CandidateVersion) { throw "Boatstack runtime reported an invalid version identity" } + $RuntimeDirectory = Join-Path $BoatstackHome ("runtimes\$SafeVersion-$Actual") + New-Item -ItemType Directory -Force -Path $RuntimeDirectory | Out-Null + $Runtime = Join-Path $RuntimeDirectory "boatstack-runtime.exe" + if (Test-Path -LiteralPath $Runtime) { + $Installed = (Get-FileHash -Algorithm SHA256 -LiteralPath $Runtime).Hash.ToLowerInvariant() + if ($Installed -ne $Actual) { throw "Boatstack immutable runtime store collision" } + } else { + $StagedRuntime = Join-Path $RuntimeDirectory (".boatstack-runtime-" + [guid]::NewGuid().ToString("N")) + try { + Copy-Item -LiteralPath $Candidate -Destination $StagedRuntime + $StagedHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $StagedRuntime).Hash.ToLowerInvariant() + if ($StagedHash -ne $Actual) { throw "Boatstack staged runtime checksum mismatch" } + try { + New-Item -ItemType HardLink -Path $Runtime -Target $StagedRuntime -ErrorAction Stop | Out-Null + } catch { + if (-not (Test-Path -LiteralPath $Runtime)) { throw } + $Installed = (Get-FileHash -Algorithm SHA256 -LiteralPath $Runtime).Hash.ToLowerInvariant() + if ($Installed -ne $Actual) { throw "Boatstack immutable runtime store collision" } + } + } finally { + Remove-Item -LiteralPath $StagedRuntime -Force -ErrorAction SilentlyContinue + } + } $Runtime = (Resolve-Path -LiteralPath $Runtime).Path if ($Mode -eq "install") { @@ -82,12 +107,15 @@ try { $AcceptProgramChange = @("--accept-program-change") } & $Runtime update --repo $Repository --human $Actor ` - --param "runtime_path=$Runtime" ` --param "runtime_sha256=$Actual" @AcceptProgramChange --format json } if ($LASTEXITCODE -ne 0) { throw "Boatstack kernel rejected installation" } - $Launcher = Join-Path $InstallDir "boatstack.cmd" + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + $Launcher = Join-Path $InstallDir "boatstack.exe" + $StagedLauncher = Join-Path $InstallDir (".boatstack-" + [guid]::NewGuid().ToString("N") + ".exe") + Copy-Item -LiteralPath $Candidate -Destination $StagedLauncher + Move-Item -LiteralPath $StagedLauncher -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 1e8d203b..2ec82426 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,7 @@ version="${BOATSTACK_VERSION:-latest}" mode="${BOATSTACK_MODE:-install}" actor="${BOATSTACK_ACTOR:-${USER:-operator}}" install_dir="${BOATSTACK_INSTALL_DIR:-${HOME}/.local/bin}" +boatstack_home="${BOATSTACK_HOME:-${XDG_DATA_HOME:-${HOME}/.local/share}/boatstack}" config_source="${BOATSTACK_CONFIG:-}" case "$mode" in @@ -70,10 +71,35 @@ fi [[ "$actual" == "$expected" ]] || { echo "Boatstack runtime checksum mismatch" >&2; exit 1; } chmod 0755 "$candidate" -mkdir -p "$install_dir" -safe_version="${version//[^a-zA-Z0-9._-]/-}" -runtime="$install_dir/boatstack-v2-${safe_version}-${actual:0:16}" -install -m 0755 "$candidate" "$runtime" +candidate_version="$("$candidate" version)" +safe_version="${candidate_version//[^a-zA-Z0-9._-]/-}" +[[ -n "$safe_version" && "$safe_version" == "$candidate_version" ]] || { echo "Boatstack runtime reported an invalid version identity" >&2; exit 1; } +runtime_dir="$boatstack_home/runtimes/${safe_version}-${actual}" +runtime="$runtime_dir/boatstack-runtime" +mkdir -p "$runtime_dir" +if [[ -e "$runtime" ]]; then + if command -v sha256sum >/dev/null 2>&1; then + installed="$(sha256sum "$runtime" | awk '{print $1}')" + else + installed="$(shasum -a 256 "$runtime" | awk '{print $1}')" + fi + [[ "$installed" == "$actual" ]] || { echo "Boatstack immutable runtime store collision" >&2; exit 1; } +else + staged="$runtime_dir/.boatstack-runtime.$$" + install -m 0755 "$candidate" "$staged" + if ! ln "$staged" "$runtime" 2>/dev/null; then + rm -f "$staged" + [[ -f "$runtime" ]] || { echo "Boatstack runtime installation raced without a durable artifact" >&2; exit 1; } + if command -v sha256sum >/dev/null 2>&1; then + installed="$(sha256sum "$runtime" | awk '{print $1}')" + else + installed="$(shasum -a 256 "$runtime" | awk '{print $1}')" + fi + [[ "$installed" == "$actual" ]] || { echo "Boatstack immutable runtime store collision" >&2; exit 1; } + else + rm -f "$staged" + fi +fi runtime="$(cd -P -- "$(dirname "$runtime")" && pwd)/$(basename "$runtime")" if [[ "$mode" == install ]]; then @@ -87,7 +113,6 @@ if [[ "$mode" == install ]]; then else update_arguments=( update --repo "$repository" --human "$actor" - --param "runtime_path=$runtime" --param "runtime_sha256=$actual" --format json ) @@ -97,6 +122,11 @@ else "$runtime" "${update_arguments[@]}" fi +mkdir -p "$install_dir" +launcher_staged="$install_dir/.boatstack.$$" +install -m 0755 "$candidate" "$launcher_staged" +mv -f "$launcher_staged" "$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-repository-runtime-selection.md b/release-notes/2026-08-11-repository-runtime-selection.md new file mode 100644 index 00000000..c505ea5b --- /dev/null +++ b/release-notes/2026-08-11-repository-runtime-selection.md @@ -0,0 +1,7 @@ +### Pin each repository to an immutable runtime + +Boatstack now records a portable version-and-checksum runtime identity in each +repository. A stable dispatcher verifies and executes only that exact artifact +from an immutable host store, so installing another release cannot silently +change an existing repository. Updates durably stage the candidate before the +Kernel atomically commits the new pin, and missing artifacts fail closed.