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.