diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..16244fc
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+docs/architecture/boatstack-v2-*.md text eol=lf
+docs/architecture/boatstack-v2-*.mmd text eol=lf
+docs/architecture/boatstack-v2-*.json text eol=lf
diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py
index 77729bf..da834f7 100644
--- a/.github/tests/test_detached_supervision.py
+++ b/.github/tests/test_detached_supervision.py
@@ -1,15 +1,4 @@
-"""End-to-end evaluation of Detached Supervision.
-
-Unlike the Go unit conformance tests, this harness builds the real
-``boatstack-helper`` binary once and drives it against actual scratch git
-repositories — attach, activate, guard, and detach — asserting at every step that
-the plant/controller boundary holds: no Boatstack-owned file ever lands in the
-target repository or its ``.git``, and the developer's own host config is never
-clobbered. It is the "actually set up repos and evaluate the system works" check.
-
-Run (from repo root):
- python -m unittest discover -s labs/12-product-engineering-loop/tests -p 'test_*.py'
-"""
+"""End-to-end tests for the V2 detached identity and guard boundary."""
from __future__ import annotations
@@ -22,42 +11,19 @@
REPO = Path(__file__).resolve().parents[2]
-SKILL = REPO / "boatstack"
-
-FORBIDDEN_IN_REPO = [
- ".product-loop",
- ".boatstack-project.json",
- ".claude",
- ".cursor",
- ".codex",
- ".gemini",
- ".agents",
- ".github/PULL_REQUEST_TEMPLATE/boatstack.md",
-]
-
-DESTRUCTIVE_EVENT = json.dumps(
- {
- "hook_event_name": "PreToolUse",
- "tool_name": "Bash",
- "tool_input": {"command": "git reset --hard HEAD~1"},
- }
-)
+RUNTIME = REPO / "boatstack"
class DetachedSupervisionEndToEnd(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
- cls.build_temp = tempfile.TemporaryDirectory()
- cls.binary = Path(cls.build_temp.name) / (
+ cls.build = tempfile.TemporaryDirectory()
+ cls.binary = Path(cls.build.name) / (
"boatstack-helper.exe" if os.name == "nt" else "boatstack-helper"
)
- env = dict(os.environ)
- env["GOCACHE"] = str(Path(cls.build_temp.name) / "go-cache")
- env["GOMODCACHE"] = str(Path(cls.build_temp.name) / "go-mod")
result = subprocess.run(
["go", "build", "-o", str(cls.binary), "./cmd/boatstack-helper"],
- cwd=SKILL,
- env=env,
+ cwd=RUNTIME,
text=True,
capture_output=True,
)
@@ -66,223 +32,228 @@ def setUpClass(cls) -> None:
@classmethod
def tearDownClass(cls) -> None:
- cls.build_temp.cleanup()
+ cls.build.cleanup()
def setUp(self) -> None:
self.work = tempfile.TemporaryDirectory()
self.addCleanup(self.work.cleanup)
- base = Path(self.work.name)
- self.state_root = base / "state"
- self.user_root = base / "user"
- self.repo = base / "app"
- for path in (self.state_root, self.user_root, self.repo):
- path.mkdir()
- self._git("init", "-b", "main")
- self._git("config", "user.name", "Boatstack Test")
- self._git("config", "user.email", "boatstack@example.invalid")
- self._git("remote", "add", "origin", "https://github.com/acme/app.git")
- (self.repo / "README.md").write_text("# app\n")
- (self.repo / "go.mod").write_text("module app\n\ngo 1.22\n")
- self._git("add", ".")
- self._git("commit", "-m", "init")
-
- # --- helpers -------------------------------------------------------------
-
- def _git(self, *args: str) -> subprocess.CompletedProcess[str]:
+ root = Path(self.work.name)
+ self.state_root = root / "state"
+ self.repo = root / "repo"
+ self.repo.mkdir()
+ self._git(self.repo, "init", "-b", "main")
+ self._git(self.repo, "config", "user.name", "Boatstack Test")
+ self._git(self.repo, "config", "user.email", "boatstack@example.invalid")
+ (self.repo / "README.md").write_text("# fixture\n")
+ self._git(self.repo, "add", "README.md")
+ self._git(self.repo, "commit", "-m", "fixture")
+
+ def _git(self, repository: Path, *args: str) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
- ["git", "-C", str(self.repo), *args], text=True, capture_output=True
+ ["git", "-C", str(repository), *args], text=True, capture_output=True
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return result
- def _env(self) -> dict:
+ def _env(self) -> dict[str, str]:
env = dict(os.environ)
env["BOATSTACK_STATE_ROOT"] = str(self.state_root)
- env["BOATSTACK_USER_CONFIG_ROOT"] = str(self.user_root)
return env
- def run_helper(self, *args: object, expected: int = 0, stdin: str | None = None):
+ def run_helper(
+ self,
+ *args: object,
+ cwd: Path | None = None,
+ expected: int = 0,
+ ) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
- [str(self.__class__.binary), *map(str, args)],
- cwd=str(self.repo),
+ [str(self.binary), *map(str, args)],
+ cwd=cwd or self.repo,
+ env=self._env(),
text=True,
capture_output=True,
- input=stdin,
- env=self._env(),
- )
- self.assertEqual(
- result.returncode, expected, f"{args}\nSTDOUT:{result.stdout}\nSTDERR:{result.stderr}"
)
+ self.assertEqual(result.returncode, expected, result.stdout + result.stderr)
return result
- def helper_json(self, *args: object) -> dict:
- return json.loads(self.run_helper(*args).stdout)
+ def helper_json(self, *args: object, cwd: Path | None = None) -> dict:
+ return json.loads(self.run_helper(*args, cwd=cwd).stdout)
- def porcelain(self) -> str:
+ def porcelain(self, repository: Path | None = None) -> str:
+ repository = repository or self.repo
return subprocess.run(
- ["git", "-C", str(self.repo), "status", "--porcelain=v1", "--untracked-files=all"],
+ ["git", "-C", str(repository), "status", "--porcelain=v1", "--untracked-files=all"],
text=True,
capture_output=True,
+ check=True,
).stdout.strip()
- def assert_repo_uncontaminated(self) -> None:
- for forbidden in FORBIDDEN_IN_REPO:
- self.assertFalse(
- (self.repo / forbidden).exists(),
- f"Boatstack file leaked into the repo: {forbidden}",
- )
+ @staticmethod
+ def goal_flags() -> tuple[str, ...]:
+ return (
+ "--goal-id", "bootstrap", "--goal-kind", "approved-plan",
+ "--delivery", "bootstrap",
+ )
- # --- tests ---------------------------------------------------------------
+ def attach(self, repository: Path | None = None) -> dict:
+ repository = repository or self.repo
+ return self.helper_json(
+ "attach", "--repo", repository, *self.goal_flags(), "--human", "contract",
+ "--param", "topology=detached", "--param", "config_authority=repository",
+ cwd=repository,
+ )
- def test_attach_leaves_repository_pristine_and_state_external(self) -> None:
+ def test_attach_and_detach_transfer_only_controller_authority(self) -> None:
before = self.porcelain()
- result = self.helper_json("attach", "--repo", ".", "--mode", "detached")
- self.assertEqual(result["verification_status"], "VERIFIED")
-
- self.assertEqual(self.porcelain(), before, "attach changed the working tree")
- self.assert_repo_uncontaminated()
-
- control_root = Path(result["control_root"])
- self.assertTrue((control_root / ".product-loop" / "project.json").exists())
- self.assertTrue((control_root / "binding.json").exists())
- self.assertTrue((self.state_root / "boatstack" / "registry.json").exists())
- # The external shared runtime slot was populated so the guard has a helper.
- runtimes = self.state_root / "boatstack" / "runtimes"
- self.assertTrue(runtimes.exists() and any(runtimes.rglob("boatstack-helper*")))
-
- status = self.helper_json("detached-status", "--repo", ".")
- self.assertTrue(status["attached"] and status["verified"])
-
- def test_bootstrap_oracle_is_credential_free_and_executes_exact_output(self) -> None:
- attached = self.helper_json("attach", "--repo", ".", "--mode", "detached")
- source_plan = self.repo / "request.md"
- source_plan.write_text("# Source plan\n")
- document = "# Synthetic artifact\n\n`rm -rf /` is inert documentation.\n"
-
- prescription = json.loads(
- self.run_helper(
- "flow",
- "bootstrap",
- "--repo",
- ".",
- "--feature",
- "detached-bootstrap",
- "--source-plan",
- "request.md",
- "--artifact",
- "source-plan.md",
- "--shell",
- "posix",
- "--json",
- stdin=document,
- ).stdout
+ attached = self.attach()
+ self.assertEqual(self.porcelain(), before)
+ self.assertEqual(attached["snapshot"]["invocation"]["topology"], "detached")
+ self.assertEqual(attached["receipt"]["transition_id"], "repository.attach")
+ bindings = list((self.state_root / "boatstack" / "v2").rglob("binding.json"))
+ self.assertEqual(len(bindings), 1)
+ binding = json.loads(bindings[0].read_text())
+ self.assertEqual(binding["topology"], "detached")
+
+ detached = self.helper_json(
+ "detach", "--repo", self.repo, *self.goal_flags(), "--human", "contract"
)
- self.assertEqual(prescription["verification_status"], "VERIFIED")
- self.assertEqual(prescription["supervision_mode"], "detached")
- self.assertTrue(Path(prescription["helper_path"]).is_absolute())
- self.assertNotIn(".product-loop/boatstack planning-write", prescription["planning_envelope"])
+ self.assertEqual(detached["snapshot"]["invocation"]["topology"], "embedded")
+ self.assertEqual(detached["receipt"]["transition_id"], "repository.detach")
+ self.assertEqual(self.porcelain(), before)
+ self.assertFalse(bindings[0].exists())
+
+ def test_two_clones_never_share_a_detached_binding_alias(self) -> None:
+ root = Path(self.work.name)
+ origin = root / "origin.git"
+ self._git(self.repo, "init", "--bare", origin)
+ self._git(self.repo, "remote", "add", "origin", str(origin))
+ self._git(self.repo, "push", "-u", "origin", "main")
+ self._git(origin, "symbolic-ref", "HEAD", "refs/heads/main")
+ clone = root / "clone"
+ result = subprocess.run(
+ ["git", "clone", str(origin), str(clone)], text=True, capture_output=True
+ )
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
- events = {
- "cursor": {"hook_event_name": "beforeShellExecution", "command": prescription["planning_envelope"]},
- "claude": {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": prescription["planning_envelope"]}},
- "codex": {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": prescription["planning_envelope"]}},
- "gemini": {"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": prescription["planning_envelope"]}},
- }
- for host, event in events.items():
- admitted = self.run_helper(
- "engagement-probe", "--host", host, "--repo", ".", stdin=json.dumps(event)
+ one = self.attach(self.repo)
+ two = self.attach(clone)
+ self.assertEqual(one["snapshot"]["invocation"]["repository_id"], two["snapshot"]["invocation"]["repository_id"])
+ self.assertNotEqual(one["snapshot"]["invocation"]["git_common_id"], two["snapshot"]["invocation"]["git_common_id"])
+ bindings = list((self.state_root / "boatstack" / "v2").rglob("binding.json"))
+ self.assertEqual(len(bindings), 2)
+
+ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> None:
+ self.attach()
+ config = Path(self.work.name) / "project.json"
+ config.write_text(
+ json.dumps(
+ {
+ "schema_version": 2,
+ "project": {"name": "fixture", "default_branch": "main", "commands": {}},
+ "policy": {"plan_approval": "human", "visual_evidence": "optional"},
+ "hosts": ["cli", "codex"],
+ }
)
- self.assertNotIn("deny", admitted.stdout.lower(), host)
-
- executed = subprocess.run(
- ["bash", "-c", prescription["planning_envelope"]],
- cwd=self.repo,
- env=self._env(),
- text=True,
- capture_output=True,
)
- self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
- artifact = Path(attached["control_root"]) / ".product-loop" / "features" / "detached-bootstrap" / "source-plan.md"
- self.assertEqual(artifact.read_text(), document)
- self.assert_repo_uncontaminated()
-
- def test_activate_installs_guard_preserving_user_hooks(self) -> None:
- self.run_helper("attach", "--repo", ".", "--mode", "detached")
+ initialized = self.helper_json(
+ "init", "--repo", self.repo, "--human", "contract",
+ "--param", f"config_path={config}",
+ )
+ self.assertEqual(initialized["snapshot"]["invocation"]["topology"], "detached")
+ self.assertEqual(initialized["snapshot"]["runtime"]["value"], "verified")
+ self.assertEqual(self.porcelain(), "?? .boatstack/project.json")
- claude_config = self.user_root / ".claude" / "settings.json"
- claude_config.parent.mkdir(parents=True, exist_ok=True)
- claude_config.write_text(
+ self.helper_json(
+ "apply", "--repo", self.repo, "--transition", "engagement.begin",
+ *self.goal_flags(), "--repository-authority",
+ )
+ ordinary = self.helper_json(
+ "guard", "--repo", self.repo, "--command", "go test ./..."
+ )
+ self.assertTrue(ordinary["guard"]["allowed"])
+ managed = self.helper_json(
+ "guard", "--repo", self.repo, "--command", "git push origin HEAD"
+ )
+ self.assertFalse(managed["guard"]["allowed"])
+ self.assertEqual(managed["guard"]["required_transition"], "publication.execute")
+ destructive = self.helper_json(
+ "guard", "--repo", self.repo, "--command", "rm -rf build/*"
+ )
+ self.assertFalse(destructive["guard"]["allowed"])
+ self.assertEqual(destructive["guard"]["intent"]["operation"], "filesystem.recursive-delete")
+
+ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) -> None:
+ # control-law: codex-mode-authority-survives-observation-and-effects
+ goal = (
+ "--goal-id", "codex-driver-authority-triggers",
+ "--goal-kind", "open-or-updated-pr",
+ "--delivery", "codex-driver-authority-triggers",
+ )
+ flow = ("--flow", "flow-codex-driver-authority-triggers")
+ self.helper_json(
+ "attach", "--repo", self.repo, *goal, *flow, "--human", "contract",
+ "--param", "topology=detached", "--param", "config_authority=repository",
+ )
+ config = Path(self.work.name) / "driver-project.json"
+ config.write_text(
json.dumps(
{
- "theme": "dark",
- "hooks": {
- "PreToolUse": [
- {"matcher": "Bash", "hooks": [{"type": "command", "command": "my-own.sh"}]}
- ]
- },
+ "schema_version": 2,
+ "project": {"name": "driver-fixture", "default_branch": "main", "commands": {}},
+ "policy": {"plan_approval": "human", "visual_evidence": "optional"},
+ "hosts": ["cli", "codex"],
}
)
)
+ self.helper_json(
+ "init", "--repo", self.repo, *goal, *flow, "--human", "contract",
+ "--param", f"config_path={config}",
+ )
+ self.helper_json(
+ "apply", "--repo", self.repo, "--transition", "engagement.begin",
+ *goal, *flow, "--repository-authority",
+ )
- installed = self.helper_json("activate", "--repo", ".")
- self.assertEqual(installed["verification_status"], "VERIFIED")
-
- text = claude_config.read_text()
- self.assertIn("my-own.sh", text)
- self.assertIn("engagement-probe", text)
- self.assertIn("engagement.json", text)
- self.assertIn("commandWindows", text)
- self.assertIn("theme", text)
-
- # Idempotent: re-activating changes nothing.
- again = self.helper_json("activate", "--repo", ".", "--host", "claude")
- self.assertTrue(all(host["action"] == "unchanged" for host in again["hosts"]))
-
- # Deactivate removes only the engagement probe.
- self.run_helper("deactivate", "--repo", ".", "--host", "claude")
- after = claude_config.read_text()
- self.assertNotIn("engagement-probe", after)
- self.assertIn("my-own.sh", after)
-
- def test_engagement_probe_is_inert_before_and_after_attachment(self) -> None:
- # Unattached: the developer-level probe must not control this repository.
- unmanaged = self.run_helper("engagement-probe", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT)
- self.assertEqual(unmanaged.stdout, "")
-
- # Attachment is configuration, not workflow authority.
- self.run_helper("attach", "--repo", ".", "--mode", "detached")
- attached = self.run_helper("engagement-probe", "--host", "claude", "--repo", ".", stdin=DESTRUCTIVE_EVENT)
- self.assertEqual(attached.stdout, "")
-
- def test_detached_work_keeps_repo_product_only(self) -> None:
- self.run_helper("attach", "--repo", ".", "--mode", "detached")
-
- context = self.helper_json("context", "--repo", ".", "--operation", "build", "--host", "claude")
- self.assertEqual(context["mode"], "detached")
- self.assertTrue(context["attached"])
- self.assertNotEqual(context.get("next_operation", ""), "")
-
- # Boatstack operations are read-only against the plant: the repo is pristine.
- self.assertEqual(self.porcelain(), "")
- self.assert_repo_uncontaminated()
-
- # The only change that ever appears in the repo is product work.
- (self.repo / "feature.txt").write_text("product work\n")
- self.assertEqual(self.porcelain(), "?? feature.txt")
-
- def test_detach_removes_external_state_and_restores_embedded(self) -> None:
- attached = self.helper_json("attach", "--repo", ".", "--mode", "detached")
- control_root = Path(attached["control_root"])
- self.assertTrue(control_root.exists())
-
- removed = self.helper_json("detach", "--repo", ".")
- self.assertEqual(removed["verification_status"], "VERIFIED")
- self.assertTrue(removed["state_removed"])
- self.assertFalse(control_root.exists())
+ before = self.porcelain()
+ diagnostic = self.helper_json("status", "--repo", self.repo, *goal, *flow)
+ self.assertEqual(diagnostic["decision"]["kind"], "FRONTIER")
+ self.assertIn("plan.create", diagnostic["decision"]["candidates"])
+ self.assertEqual(self.porcelain(), before)
+
+ plan = Path(self.work.name) / "source-plan.md"
+ plan.write_text("# Driver fix\n\nPreserve authority across resolution and effects.\n")
+ parameters = (
+ "--param", f"source_path={plan}",
+ "--param", "delivery_id=codex-driver-authority-triggers",
+ )
+ prescribed = self.helper_json(
+ "next", "--repo", self.repo, "--transition", "plan.create",
+ *goal, *flow, "--human", "contract", *parameters,
+ )
+ self.assertEqual(prescribed["decision"]["kind"], "PRESCRIBED")
+ self.assertEqual(prescribed["decision"]["transition"]["id"], "plan.create")
- status = self.helper_json("detached-status", "--repo", ".")
- self.assertFalse(status["attached"])
- self.assert_repo_uncontaminated()
+ applied_process = self.run_helper(
+ "plan-create", "--repo", self.repo, *goal, *flow,
+ "--human", "contract", *parameters,
+ )
+ applied = json.loads(applied_process.stdout)
+ self.assertEqual(applied_process.stderr, "")
+ self.assertEqual(applied["receipt"]["transition_id"], "plan.create")
+ self.assertEqual(applied["receipt"]["flow_id"], "flow-codex-driver-authority-triggers")
+ self.assertEqual(applied["receipt"]["outcome"], "succeeded")
+ self.assertTrue(applied["receipt"]["target_fingerprint"])
+ self.assertEqual(applied["receipt"]["recovery"], "recovery.resume")
+ self.assertEqual(applied["snapshot"]["plan"]["value"], "draft")
+ for field in ('"admission"', '"receipt"', '"snapshot"', '"target_fingerprint"', '"recovery"'):
+ self.assertIn(field, applied_process.stdout)
+
+ resolved = self.helper_json(
+ "next", "--repo", self.repo, "--transition", "plan.validate",
+ *goal, *flow, "--repository-authority",
+ )
+ self.assertEqual(resolved["decision"]["kind"], "PRESCRIBED")
+ self.assertEqual(resolved["decision"]["transition"]["id"], "plan.validate")
if __name__ == "__main__":
diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py
index b9b335b..1a3016f 100644
--- a/.github/tests/test_repository_contract.py
+++ b/.github/tests/test_repository_contract.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import hashlib
import json
import os
import re
@@ -35,17 +36,47 @@ def setUpClass(cls) -> None:
def tearDownClass(cls) -> None:
cls.build.cleanup()
- def run_command(self, *args: object, cwd: Path | None = None, expected: int = 0):
+ def run_command(
+ self,
+ *args: object,
+ cwd: Path | None = None,
+ env: dict[str, str] | None = None,
+ stdin: str | None = None,
+ expected: int = 0,
+ ) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
- [*map(str, args)], cwd=cwd, text=True, capture_output=True
+ [*map(str, args)],
+ cwd=cwd,
+ env=env,
+ input=stdin,
+ text=True,
+ capture_output=True,
)
self.assertEqual(result.returncode, expected, result.stdout + result.stderr)
return result
- def run_helper(self, *args: object, expected: int = 0):
- return self.run_command(self.helper, *args, expected=expected)
+ def run_helper(
+ self,
+ *args: object,
+ env: dict[str, str] | None = None,
+ stdin: str | None = None,
+ expected: int = 0,
+ ) -> subprocess.CompletedProcess[str]:
+ return self.run_command(
+ self.helper, *args, env=env, stdin=stdin, expected=expected
+ )
+
+ def init_repository(self, root: Path) -> None:
+ self.run_command("git", "init", "-b", "main", cwd=root)
+ self.run_command("git", "config", "user.name", "Boatstack Test", cwd=root)
+ self.run_command(
+ "git", "config", "user.email", "boatstack@example.invalid", cwd=root
+ )
+ (root / "README.md").write_text("# Fixture\n")
+ self.run_command("git", "add", "README.md", cwd=root)
+ self.run_command("git", "commit", "-m", "fixture", cwd=root)
- def test_active_workflows_have_no_intelligence_flow_path(self) -> None:
+ def test_active_workflows_have_no_private_upstream_authority(self) -> None:
workflows = REPO / ".github" / "workflows"
self.assertFalse((workflows / "sync-upstream.yml").exists())
for workflow in workflows.glob("*.yml"):
@@ -53,44 +84,84 @@ def test_active_workflows_have_no_intelligence_flow_path(self) -> None:
self.assertNotIn("operatorstack/intelligence-flow", value, workflow)
self.assertNotIn("sync/intelligence-flow-", value, workflow)
self.assertNotIn("UPSTREAM.json", value, workflow)
+ self.assertFalse((REPO / "UPSTREAM.json").exists())
- def test_release_authority_uses_boatstack_revision(self) -> None:
+ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None:
release = (REPO / ".github" / "workflows" / "release.yml").read_text()
automatic = (REPO / ".github" / "workflows" / "auto-release.yml").read_text()
+ for asset in (
+ "boatstack-helper_linux_amd64",
+ "boatstack-helper_linux_arm64",
+ "boatstack-helper_darwin_amd64",
+ "boatstack-helper_darwin_arm64",
+ "boatstack-helper_windows_amd64.exe",
+ "boatstack-helper_windows_arm64.exe",
+ ):
+ self.assertIn(asset, release)
+ for symbol in ("boatstack.Version", "boatstack.SourceCommit", "boatstack.ChecksumsSHA256"):
+ self.assertIn(symbol, release)
self.assertIn('source_commit="$(git rev-parse HEAD)"', release)
- self.assertNotIn("IMPORT_PROVENANCE.json", release)
- self.assertNotIn("UPSTREAM.json", release)
+ self.assertIn("sha256sum", release)
self.assertIn('workflows: ["Verify Boatstack distribution"]', automatic)
- self.assertIn("github.event.workflow_run.event == 'push'", automatic)
- self.assertIn("github.event.workflow_run.head_branch == 'main'", automatic)
- self.assertIn("repositories: boatstack", automatic)
-
- def test_current_public_surface_is_boatstack_owned(self) -> None:
- current = [REPO / "README.md", REPO / "CONTRIBUTING.md", *sorted((REPO / "docs").glob("*"))]
- forbidden = (
- "Generated from operatorstack/intelligence-flow",
- "Edit the upstream public source",
- "edit in Intelligence Flow",
- "generated content distribution",
- )
- for path in current:
- if not path.is_file() or path.suffix not in {".md", ".json"}:
- continue
- value = path.read_text()
- for phrase in forbidden:
- self.assertNotIn(phrase, value, path)
- self.assertFalse((REPO / "UPSTREAM.json").exists())
+
+ def test_manual_release_is_prerelease_only_and_exact_source_bound(self) -> None:
+ # control-law: branch-prerelease-publishes-only-an-exact-new-rc-source
+ release = (REPO / ".github" / "workflows" / "release.yml").read_text()
+ for contract in (
+ "prerelease_tag:",
+ "manual prereleases must be dispatched from a branch",
+ "manual release tags must use vMAJOR.MINOR.PATCH-rc.NUMBER",
+ "release tag $RELEASE_TAG already exists",
+ "selected branch no longer resolves to exact source $RELEASE_SOURCE",
+ '--target "$RELEASE_SOURCE"',
+ "--prerelease",
+ ):
+ self.assertIn(contract, release)
+ self.assertIn("VERSION: ${{ inputs.prerelease_tag || github.ref_name }}", release)
+ self.assertIn('RELEASE_SOURCE: ${{ github.sha }}', release)
+
+ def test_codex_modes_keep_authority_attached_after_observation(self) -> None:
+ # control-law: codex-mode-authority-survives-observation-and-effects
+ skill = (REPO / "boatstack" / "SKILL.md").read_text()
+ prompt = (REPO / "boatstack" / "agents" / "openai.yaml").read_text()
+ readme = (REPO / "README.md").read_text()
+
+ for surface in (skill, prompt, readme):
+ for mode in ("$boatstack Autoplan", "$boatstack Run", "$boatstack Update"):
+ self.assertIn(mode, surface)
+ for mapping in (
+ "`approved-plan` terminal",
+ "`open-or-updated-pr` terminal",
+ "`installation.update`",
+ ):
+ self.assertIn(mapping, skill)
+ for contract in (
+ "status` is observation only",
+ "authority-free `FRONTIER`",
+ "command-scoped authority context",
+ "every `next`, `apply`,\n`recover`, and re-resolution",
+ "complete `apply` response and stderr",
+ "authority-bearing resolution",
+ ):
+ self.assertIn(contract, skill)
+ self.assertIn("present exactly three choices", skill)
+ self.assertIn("case-insensitively", skill)
+ self.assertIn("It never selects\n merge authority", skill)
+ self.assertIn("bare $boatstack presents those three choices", prompt)
+ self.assertIn("Run never grants merge authority", readme)
def test_document_links_claims_and_assets_are_valid(self) -> None:
def anchors(document: Path) -> set[str]:
- result = set()
- for heading in re.findall(r"^#{1,6}\s+(.+?)\s*$", document.read_text(), re.MULTILINE):
+ result: set[str] = set()
+ for heading in re.findall(
+ r"^#{1,6}\s+(.+?)\s*$", document.read_text(), re.MULTILINE
+ ):
plain = re.sub(r"<[^>]+>", "", heading).strip().lower()
plain = re.sub(r"[^\w\s-]", "", plain)
result.add(re.sub(r"\s+", "-", plain))
return result
- documents = [REPO / "README.md", *sorted((REPO / "docs").glob("*.md"))]
+ documents = [REPO / "README.md", *sorted((REPO / "docs").rglob("*.md"))]
for document in documents:
for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", document.read_text()):
if target.startswith(("http://", "https://", "#", "mailto:")):
@@ -106,7 +177,7 @@ def anchors(document: Path) -> set[str]:
json.loads(example)
claims = json.loads((REPO / "docs" / "public-claims.json").read_text())
- self.assertNotIn("source_commit", claims)
+ self.assertEqual(claims["schema_version"], 2)
allowed = set(claims["statuses"])
for claim in claims["claims"]:
self.assertIn(claim["status"], allowed)
@@ -118,7 +189,11 @@ def anchors(document: Path) -> set[str]:
for evidence in claim["implementation"] + claim["verification"]:
self.assertTrue((REPO / "docs" / evidence).resolve().is_file(), evidence)
- for name in ("boatstack-mark.svg", "boatstack-journey.svg", "boatstack-portability.svg"):
+ for name in (
+ "boatstack-mark.svg",
+ "boatstack-journey.svg",
+ "boatstack-portability.svg",
+ ):
path = REPO / "assets" / name
root = ET.parse(path).getroot()
self.assertEqual(root.attrib.get("role"), "img", name)
@@ -126,7 +201,7 @@ def anchors(document: Path) -> set[str]:
self.assertIn("
None:
+ def test_public_tree_excludes_private_context_and_v1_operating_guidance(self) -> None:
private_values = (
"Tax" + "Weave",
"/" + "Users/apple/Documents/GitHub/" + "tax" + "weave",
@@ -141,92 +216,228 @@ def test_public_examples_exclude_private_context(self) -> None:
for private in private_values:
self.assertNotIn(private, value, path)
- def test_executable_documentation_examples_use_registered_cli(self) -> None:
+ current_guidance = [
+ REPO / "README.md",
+ REPO / "boatstack" / "SKILL.md",
+ *sorted((REPO / "docs").glob("*.md")),
+ *sorted((REPO / "boatstack" / "references").glob("*.md")),
+ *sorted((REPO / "boatstack" / "agents").glob("*.yaml")),
+ ]
+ deprecated = (
+ "plan-gate",
+ "product-loop",
+ "planning-write",
+ "ship-gate",
+ "insight-capture",
+ "deliverycontrol",
+ )
+ for document in current_guidance:
+ value = document.read_text().lower()
+ for token in deprecated:
+ self.assertNotIn(token, value, document)
+
+ def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None:
documents = [
REPO / "README.md",
REPO / "boatstack" / "SKILL.md",
*sorted((REPO / "docs").glob("*.md")),
*sorted((REPO / "boatstack" / "references").glob("*.md")),
]
- command_pattern = re.compile(
- r"(?:\.product-loop/boatstack(?:\.ps1)?|boatstack-helper)\s+"
- r"(?P[a-z][a-z0-9-]*)(?:\s+(?P[a-z][a-z0-9-]*))?"
+ registered = {
+ "status", "next", "next-status", "apply", "recover", "doctor",
+ "events", "catalog", "guard", "rpc", "retro", "version", "init",
+ "update", "attach", "detach", "hydrate-runtime", "configure",
+ "goal-configure", "plan-create", "plan-validate", "plan-approve",
+ "plan-activate", "plan-amend", "workspace-cut", "workspace-sync",
+ "workspace-cleanup", "workspace-reap", "record-build", "record-test",
+ "record-review", "record-change", "record-journey",
+ "publication-preview", "publish-pr", "observe-pr", "correct-pr",
+ "abandon",
+ }
+ pattern = re.compile(
+ r"(?m)^[ \t]*(?:\$[A-Za-z_][A-Za-z0-9_]*/)?"
+ r"boatstack(?:-helper)?[ \t]+([a-z][a-z0-9-]*)"
)
- nested = {"flow", "insight", "retro"}
- checked: dict[tuple[str, ...], str] = {}
+ seen: set[str] = set()
for document in documents:
- value = document.read_text().replace("\\\n", " ")
- for match in command_pattern.finditer(value):
- verb = match.group("verb")
- subcommand = match.group("subcommand") if verb in nested else None
- command = (verb, subcommand) if subcommand else (verb,)
- line_end = value.find("\n", match.end())
- if line_end < 0:
- line_end = len(value)
- segment = value[match.start():line_end]
- flags = set(re.findall(r"--([a-z][a-z0-9-]*)", segment))
- if command not in checked:
- help_result = self.run_helper(*command, "--help", expected=2)
- checked[command] = help_result.stdout + help_result.stderr
- help_text = checked[command]
- self.assertIn(f"Usage of {' '.join(command)}:", help_text, document)
- for flag in flags:
- self.assertRegex(help_text, rf"(?m)^ -{re.escape(flag)}(?:\s|$)", document)
-
- public_guidance = "\n".join(document.read_text() for document in documents)
- self.assertNotIn(".product-loop/boatstack planning-write", public_guidance)
- self.assertNotIn(".product-loop\\boatstack.ps1' planning-write", public_guidance)
-
- def test_export_and_drift_contract(self) -> None:
+ for verb in pattern.findall(document.read_text().replace("\\\n", " ")):
+ seen.add(verb)
+ self.assertIn(verb, registered, f"unregistered command in {document}")
+ self.assertTrue({"status", "apply", "catalog"}.issubset(seen))
+
+ def test_catalog_and_generated_artifacts_match_the_executable_registry(self) -> None:
+ attributes = (REPO / ".gitattributes").read_text().splitlines()
+ for pattern in (
+ "docs/architecture/boatstack-v2-*.md text eol=lf",
+ "docs/architecture/boatstack-v2-*.mmd text eol=lf",
+ "docs/architecture/boatstack-v2-*.json text eol=lf",
+ ):
+ self.assertIn(pattern, attributes)
+
+ response = json.loads(self.run_helper("catalog").stdout)
+ transitions = response["catalog"]
+ self.assertEqual(len(transitions), 61)
+ self.assertEqual(len({item["id"] for item in transitions}), 61)
+ self.assertEqual(
+ {item["class"] for item in transitions},
+ {"authority", "owned-local", "owned-external", "recovery", "observed-external"},
+ )
+ markdown = self.run_helper("catalog", "--format", "markdown").stdout
+ mermaid = self.run_helper("catalog", "--format", "mermaid").stdout
+ locus_safety = self.run_helper(
+ "catalog", "--format", "locus-safety"
+ ).stdout
+ locus_liveness = self.run_helper(
+ "catalog", "--format", "locus-liveness"
+ ).stdout
+ self.assertEqual(
+ markdown,
+ (REPO / "docs" / "architecture" / "boatstack-v2-transition-catalog.md").read_text(),
+ )
+ self.assertEqual(
+ mermaid,
+ (REPO / "docs" / "architecture" / "boatstack-v2-transition-catalog.mmd").read_text(),
+ )
+ for name, rendered in (
+ ("boatstack-v2-locus-safety.json", locus_safety),
+ ("boatstack-v2-locus-liveness.json", locus_liveness),
+ ):
+ checked = (REPO / "docs" / "architecture" / name).read_text()
+ self.assertEqual(rendered, checked)
+ model = json.loads(checked)
+ self.assertEqual(len(model["events"]), 61)
+ self.assertEqual(
+ {event["id"] for event in model["events"]},
+ {item["id"] for item in transitions},
+ )
+
+ def test_rpc_and_configuration_decoders_fail_closed(self) -> None:
+ malformed_rpc = json.dumps(
+ {
+ "schema_version": 2,
+ "operation": "catalog",
+ "repository": ".",
+ "host": "cli",
+ "correlation_id": "contract",
+ "unexpected": True,
+ }
+ )
+ rejected = self.run_helper("rpc", stdin=malformed_rpc, expected=1)
+ self.assertIn("unknown field", rejected.stderr.lower())
+
with tempfile.TemporaryDirectory() as temp:
- target = Path(temp)
- self.run_helper("export", "--repo", target, "--config", CONFIG, "--adapter-name", "boatstack", "--write")
- checked = self.run_helper("export", "--repo", target, "--config", CONFIG, "--adapter-name", "boatstack", "--check")
- self.assertIn("PASS", checked.stdout)
- self.assertTrue((target / ".cursor" / "commands" / "auto-plan.md").is_file())
- self.assertTrue((target / ".agents" / "skills" / "boatstack" / "SKILL.md").is_file())
- (target / ".cursor" / "commands" / "auto-plan.md").write_text("drift\n")
- drift = self.run_helper("export", "--repo", target, "--config", CONFIG, "--adapter-name", "boatstack", "--check", expected=1)
- self.assertIn("drift", (drift.stdout + drift.stderr).lower())
-
- def test_plan_activation_and_pr_preview_contract(self) -> None:
+ root = Path(temp)
+ repository = root / "repo"
+ repository.mkdir()
+ self.init_repository(repository)
+ invalid = json.loads(CONFIG.read_text())
+ invalid["unexpected"] = True
+ invalid_path = root / "invalid.json"
+ invalid_path.write_text(json.dumps(invalid))
+ env = dict(os.environ)
+ env["BOATSTACK_STATE_ROOT"] = str(root / "state")
+ rejected = self.run_helper(
+ "init", "--repo", repository, "--human", "contract",
+ "--param", f"config_path={invalid_path}", env=env, expected=1,
+ )
+ self.assertIn("unknown field", (rejected.stdout + rejected.stderr).lower())
+ self.assertFalse((repository / ".boatstack" / "project.json").exists())
+
+ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) -> None:
+ if os.name == "nt":
+ self.skipTest("the repository contract job exercises the POSIX installer")
with tempfile.TemporaryDirectory() as temp:
- repo = Path(temp)
- self.run_command("git", "init", "-b", "main", cwd=repo)
- self.run_command("git", "config", "user.name", "Boatstack Test", cwd=repo)
- self.run_command("git", "config", "user.email", "boatstack@example.invalid", cwd=repo)
- (repo / ".product-loop").mkdir()
- config = json.loads(CONFIG.read_text())
- config["project"]["default_branch"] = "main"
- (repo / ".product-loop" / "project.json").write_text(json.dumps(config) + "\n")
- (repo / "README.md").write_text("# Fixture\n")
- self.run_command("git", "add", ".", cwd=repo)
- self.run_command("git", "commit", "-m", "base", cwd=repo)
- bare = repo / ".git" / "origin.git"
- self.run_command("git", "init", "--bare", bare)
- self.run_command("git", "remote", "add", "origin", bare, cwd=repo)
- self.run_command("git", "push", "-u", "origin", "main", cwd=repo)
- self.run_command("git", "switch", "-c", "feat/direct", cwd=repo)
- (repo / "feature.txt").write_text("value\n")
- self.run_command("git", "add", "feature.txt", cwd=repo)
- self.run_command("git", "commit", "-m", "feature", cwd=repo)
- context = json.loads(self.run_helper("pr-context", "--repo", repo).stdout)
- self.assertEqual(context["mode"], "ad-hoc")
- template = self.run_helper("pr-context", "--repo", repo, "--format", "template")
- self.assertIn("boatstack_pr_version: 4", template.stdout)
- self.assertIn("## Review order", template.stdout)
-
- demo = REPO / "labs" / "diagram-json"
- checked = self.run_helper("check-plan", "--plan", demo / "plan.md")
- self.assertIn("PASS", checked.stdout)
-
- def test_installers_verify_downloads_and_support_updates(self) -> None:
+ root = Path(temp)
+ repository = root / "repo"
+ install_dir = root / "bin"
+ repository.mkdir()
+ self.init_repository(repository)
+ digest = hashlib.sha256(self.helper.read_bytes()).hexdigest()
+ env = dict(os.environ)
+ env.update(
+ {
+ "BOATSTACK_REPO": str(repository),
+ "BOATSTACK_BINARY": str(self.helper),
+ "BOATSTACK_BINARY_SHA256": digest,
+ "BOATSTACK_INSTALL_DIR": str(install_dir),
+ "BOATSTACK_CONFIG": str(CONFIG),
+ "BOATSTACK_STATE_ROOT": str(root / "state"),
+ "BOATSTACK_ACTOR": "contract",
+ "BOATSTACK_VERSION": "contract-v2",
+ }
+ )
+ self.run_command("bash", REPO / "install.sh", cwd=repository, env=env)
+ launcher = install_dir / "boatstack"
+ self.assertTrue(launcher.is_symlink())
+ self.assertTrue((repository / ".boatstack" / "project.json").is_file())
+
+ doctor = json.loads(
+ self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout
+ )
+ self.assertTrue(doctor["doctor"]["healthy"])
+ self.assertEqual(doctor["doctor"]["transition_count"], 61)
+ self.assertEqual(doctor["snapshot"]["runtime"]["value"], "verified")
+
+ goal = (
+ "--goal-id", "bootstrap", "--goal-kind", "approved-plan",
+ "--delivery", "bootstrap",
+ )
+ self.run_command(
+ launcher, "apply", "--repo", repository,
+ "--transition", "engagement.begin", *goal,
+ "--repository-authority", env=env,
+ )
+ ordinary = json.loads(
+ self.run_command(
+ launcher, "guard", "--repo", repository,
+ "--command", "go test ./...", env=env,
+ ).stdout
+ )
+ self.assertTrue(ordinary["guard"]["allowed"])
+ managed = json.loads(
+ self.run_command(
+ launcher, "guard", "--repo", repository,
+ "--command", "git push origin HEAD", env=env,
+ ).stdout
+ )
+ self.assertFalse(managed["guard"]["allowed"])
+ self.assertEqual(managed["guard"]["required_transition"], "publication.execute")
+ destructive = json.loads(
+ self.run_command(
+ launcher, "guard", "--repo", repository,
+ "--command", "git reset --hard HEAD~1", env=env,
+ ).stdout
+ )
+ self.assertFalse(destructive["guard"]["allowed"])
+
+ env["BOATSTACK_MODE"] = "update"
+ env["BOATSTACK_VERSION"] = "contract-v2-next"
+ 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"])
+ 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))
+
+ def test_installers_are_checksum_first_and_kernel_owned(self) -> None:
shell = (REPO / "install.sh").read_text()
powershell = (REPO / "install.ps1").read_text()
- for expected in ("sha256sum", "BOATSTACK_INTEGRATIONS", "BOATSTACK_MODE", "BOATSTACK_VERSION", "--repair"):
+ for expected in (
+ "BOATSTACK_BINARY_SHA256", "sha256sum", "shasum -a 256",
+ '"$runtime" init', '"$runtime" update',
+ ):
self.assertIn(expected, shell)
- for expected in ("Get-FileHash", "BOATSTACK_INTEGRATIONS", "BOATSTACK_MODE", "BOATSTACK_VERSION", "--repair"):
+ for expected in (
+ "BOATSTACK_BINARY_SHA256", "Get-FileHash", "$Runtime init", "$Runtime update",
+ ):
self.assertIn(expected, powershell)
+ self.assertNotIn("--repair", shell)
+ self.assertNotIn("--repair", powershell)
if __name__ == "__main__":
diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
index 870bb70..5326de4 100644
--- a/.github/workflows/auto-release.yml
+++ b/.github/workflows/auto-release.yml
@@ -35,27 +35,27 @@ jobs:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- - uses: actions/setup-go@v7
- with:
- go-version-file: boatstack/go.mod
- cache-dependency-path: boatstack/go.mod
- name: Detect release-bearing change
id: classify
shell: bash
run: |
- if [[ ! -f boatstack/release.go ]]; then
- echo "The release classifier is not projected yet; no tag will be created."
- echo "release_required=false" >> "$GITHUB_OUTPUT"
- exit 0
- fi
latest_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)"
if [[ -z "$latest_tag" ]]; then
echo "BLOCKED: automatic patch releases require an existing stable tag." >&2
exit 1
fi
- classification="$(cd boatstack && go run ./cmd/boatstack-helper \
- release-classify --repo .. --base "$latest_tag" --head HEAD)"
- printf '%s\n' "$classification" >> "$GITHUB_OUTPUT"
+ rewritten="$(git diff --name-only --diff-filter=MD --no-renames "$latest_tag" HEAD -- 'release-notes/*.md')"
+ if [[ -n "$rewritten" ]]; then
+ echo "BLOCKED: release notes are append-only:" >&2
+ printf ' %s\n' "$rewritten" >&2
+ exit 1
+ fi
+ added="$(git diff --name-only --diff-filter=A --no-renames "$latest_tag" HEAD -- 'release-notes/*.md')"
+ if [[ -n "$added" ]]; then
+ echo "release_required=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "release_required=false" >> "$GITHUB_OUTPUT"
+ fi
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
- name: Create next verified patch tag
if: steps.classify.outputs.release_required == 'true'
@@ -64,8 +64,13 @@ jobs:
LATEST_TAG: ${{ steps.classify.outputs.latest_tag }}
shell: bash
run: |
- next_tag="$(cd boatstack && go run ./cmd/boatstack-helper \
- next-patch --version "$LATEST_TAG")"
+ version="${LATEST_TAG#v}"
+ IFS=. read -r major minor patch <<< "$version"
+ if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ || ! "$patch" =~ ^[0-9]+$ ]]; then
+ echo "BLOCKED: latest tag is not a stable semantic version: $LATEST_TAG" >&2
+ exit 1
+ fi
+ next_tag="v${major}.${minor}.$((patch + 1))"
if git rev-parse --verify --quiet "refs/tags/$next_tag"; then
echo "BLOCKED: tag already exists: $next_tag" >&2
exit 1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e36fd03..22d3bdc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,6 +5,11 @@ on:
push:
tags: ["v*"]
workflow_dispatch:
+ inputs:
+ prerelease_tag:
+ description: "New prerelease tag to publish from the selected branch (for example v2.0.0-rc.1)"
+ required: true
+ type: string
permissions:
contents: write
@@ -47,7 +52,7 @@ jobs:
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
ASSET: ${{ matrix.asset }}
- VERSION: ${{ github.ref_name }}
+ VERSION: ${{ inputs.prerelease_tag || github.ref_name }}
run: |
source_commit="$(git rev-parse HEAD)"
mkdir -p dist
@@ -63,7 +68,7 @@ jobs:
path: dist/${{ matrix.asset }}*
release:
- if: startsWith(github.ref, 'refs/tags/')
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
steps:
@@ -77,26 +82,49 @@ jobs:
- name: Publish release assets
env:
GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ inputs.prerelease_tag || github.ref_name }}
+ RELEASE_SOURCE: ${{ github.sha }}
+ MANUAL_PRERELEASE: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
- previous_tag="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
+ if [[ "$MANUAL_PRERELEASE" == true ]]; then
+ [[ "$GITHUB_REF" == refs/heads/* ]] || {
+ echo "BLOCKED: manual prereleases must be dispatched from a branch" >&2
+ exit 2
+ }
+ [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]] || {
+ echo "BLOCKED: manual release tags must use vMAJOR.MINOR.PATCH-rc.NUMBER" >&2
+ exit 2
+ }
+ if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then
+ echo "BLOCKED: release tag $RELEASE_TAG already exists" >&2
+ exit 2
+ fi
+ remote_source="$(git ls-remote origin "refs/heads/$GITHUB_REF_NAME" | awk 'NR == 1 {print $1}')"
+ [[ -n "$remote_source" && "$remote_source" == "$RELEASE_SOURCE" ]] || {
+ echo "BLOCKED: selected branch no longer resolves to exact source $RELEASE_SOURCE" >&2
+ exit 2
+ }
+ fi
+
+ previous_tag="$(git describe --tags --abbrev=0 "${RELEASE_SOURCE}^" 2>/dev/null || true)"
if [[ -n "$previous_tag" ]]; then
rewritten_notes=()
while IFS= read -r note; do
rewritten_notes+=("$note")
done < <(git diff --name-only --diff-filter=MD --no-renames \
- "$previous_tag" "$GITHUB_REF_NAME" -- 'release-notes/*.md')
+ "$previous_tag" "$RELEASE_SOURCE" -- 'release-notes/*.md')
release_notes=()
while IFS= read -r note; do
release_notes+=("$note")
done < <(git diff --name-only --diff-filter=A --no-renames \
- "$previous_tag" "$GITHUB_REF_NAME" -- 'release-notes/*.md' | LC_ALL=C sort)
+ "$previous_tag" "$RELEASE_SOURCE" -- 'release-notes/*.md' | LC_ALL=C sort)
else
rewritten_notes=()
release_notes=()
while IFS= read -r note; do
release_notes+=("$note")
- done < <(git ls-tree -r --name-only "$GITHUB_REF_NAME" -- \
+ done < <(git ls-tree -r --name-only "$RELEASE_SOURCE" -- \
'release-notes/*.md' | LC_ALL=C sort)
fi
if (( ${#rewritten_notes[@]} > 0 )); then
@@ -117,7 +145,16 @@ jobs:
echo
done
} > "$release_body"
- gh release create "${GITHUB_REF_NAME}" dist/* \
- --repo "$GITHUB_REPOSITORY" \
- --notes-file "$release_body" \
- --verify-tag
+ if [[ "$MANUAL_PRERELEASE" == true ]]; then
+ gh release create "$RELEASE_TAG" dist/* \
+ --repo "$GITHUB_REPOSITORY" \
+ --target "$RELEASE_SOURCE" \
+ --title "$RELEASE_TAG" \
+ --notes-file "$release_body" \
+ --prerelease
+ else
+ gh release create "$RELEASE_TAG" dist/* \
+ --repo "$GITHUB_REPOSITORY" \
+ --notes-file "$release_body" \
+ --verify-tag
+ fi
diff --git a/README.md b/README.md
index c0b2343..642fda2 100644
--- a/README.md
+++ b/README.md
@@ -1,215 +1,117 @@
-
-
-
+# Boatstack
-Boatstack
+Boatstack V2 is one repository-delivery controller for humans and coding agents.
+It observes a repository, resolves one legal transition, binds exact authority,
+executes owned effects transactionally, verifies the result, and records a
+receipt.
-Build freely. Prove it. Ship.
+Cursor, Codex, Claude Code, Gemini CLI, MCP, the CLI, and the Go SDK use the same
+versioned protocol. They do not keep separate workflow state machines.
-## Keep your software delivery process when you change coding agents
+> V2 is a flag-day replacement. It does not read or migrate V1 machine state,
+> commands, internal APIs, caches, leases, or detached bindings. Reinstall or
+> reattach a repository.
-Boatstack is a repository-local delivery harness for Cursor, Codex, Claude Code, and Gemini CLI.
+## Why V2
-AI coding agents write code quickly. But each tool brings its own planning flow, session state, and definition of "done". If you change agents, your delivery process often disappears with the chat.
-
-Boatstack keeps the delivery process in the repository. Your plans, product decisions, tests, review findings, accepted gaps, and completion evidence stay connected from idea to pull request. This holds no matter which agent or model does the work. Use Cursor, Codex, Claude Code, or Gemini CLI. Boatstack keeps the same approval, testing, review, and shipping boundaries across all of them.
-
-**Your product development flow stays with the repository, not the coding agent.** Change agents, models, or specialist skills without rebuilding how your team plans, verifies, reviews, and ships software.
-
-The coding agent executes the work. Boatstack supervises the delivery. Your repository owns the policy and evidence.
-
-
-
-
-
-| You change | You keep |
-|---|---|
-| Cursor, Codex, Claude Code, or Gemini CLI | The same path from approved plan to reviewed PR |
-| Lower-cost, general, or frontier model | The same definition of done and proof requirements |
-| React guidance, gstack, Spec Kit, or another skill | Human product decisions remain authoritative |
-| Session, worktree, or feature | Decisions, open gaps, evidence, and verified delivery state |
-
-## How it works
-
-1. Save a plan in your coding agent.
-2. Boatstack validates the plan and pauses for material product decisions.
-3. The agent builds freely inside the approved scope.
-4. Boatstack checks the promised outcomes against tests and evidence.
-5. Review findings, risks, and accepted gaps become a focused PR brief.
-6. The resulting context stays in the repository for the next feature.
-
-## Each delivery makes the next one easier
-
-Boatstack does not preserve an agent's private reasoning or replay old chats. It keeps the durable parts of delivery:
-- approved product decisions
-- unresolved gaps
-- validation evidence
-- review findings
-- verified repository state
-
-So the next feature starts from recorded project knowledge. You do not reconstruct intent from another agent session.
-
-## Prevent systemic failure instead of patching symptoms
-
-When coding agents or developers meet a bug, they usually patch the local symptom. The underlying architectural flaw stays open — for example, a database edge that accepts bad data. The same failure then happens again elsewhere.
-
-When you ask for a fix during `/auto-plan`, Boatstack scans your codebase. It checks whether the bug is a symptom of a missing systemic boundary. If it is, Boatstack pauses instead of patching the symptom. It then asks whether you want to add a programmatic lock, such as a database trigger or a strict validator.
-
-To diagnose a bug first, run `/root-cause ` and paste a stack trace, an error, or a failing signal. The operation is strictly read-only. It locates the failure below its surface symptom. It names the failure *class*. It traces a cited root-cause chain, maps the blast radius, and proposes the structural change that removes the whole class. It ends with a source plan. Save that plan and pass it to `/auto-plan --plan `, the diagnostic front door to the plan gate.
-
-Boatstack turns one-off bug fixes into systemic constraints, so your codebase gets safer with every agent run. It requires a negative test that proves the new lock holds. On publication, it records that verified boundary in the repository's global memory. All future agent runs are then bound by the new rule.
-
-## Install with your coding agent
-
-Copy this into Cursor, Codex, Claude Code, or Gemini CLI while the repository is open:
-
-```text
-Install Boatstack in this repository from https://github.com/operatorstack/boatstack. Detect whether you are running in Cursor, Codex, Claude Code, or Gemini CLI; create or use a chore/install-boatstack branch; run the official installer for this operating system; default to core unless I request gstack or Spec Kit; keep all portable host adapters; run Boatstack doctor; show me the generated files and installation diff; and prepare the installation PR without merging it or starting product work.
-```
-
-Install Boatstack in its own infrastructure PR. Merge that PR before you start a feature. After that one repository adoption, fresh clones and linked worktrees inherit tracked launchers that activate the exact verified runtime automatically.
-
-## Start with two moves
-
-1. Create and save a plan in your coding tool's Plan mode.
-2. Start Boatstack with the entry point for your host.
-
-| Host | Start command |
-|---|---|
-| Claude Code | `/auto-plan` |
-| Cursor | `/auto-plan` |
-| Gemini CLI | `/auto-plan` |
-| Codex | `$boatstack auto-plan` |
-
-That is all you need to learn up front. Boatstack shows one next action at a time through approval, building, tests, review, and PR preparation.
-
-When you return after an interruption, run `/boatstack-next` in Claude Code, Cursor, or Gemini CLI. In Codex, run `$boatstack next`. Boatstack reports the repository-verified stage and one next action. It does not change state. It tells apart a feature that has not started from one that is complete.
-
-`/boatstack-run --to plan|verified|pr` (or `$boatstack run --to …` in Codex) starts from one saved plan and stops at the selected goal. An explicit goal-driven run can resolve only low-risk, reversible, evidence-backed implementation choices inside the specification. Any material or uncertain choice still pauses. The `pr` target authorizes one normal PR open or update; it never merges or deploys.
-
-In Claude Code, Cursor, and Gemini CLI, that guidance moves through `/plan-gate` → `/build` → `/test-gate` → `/review-gate` → `/ship-gate`. In Codex, use the same operation names after `$boatstack`.
-
-> The diagram shows what Boatstack guides. It is not a checklist you must memorize.
-
-
-
-
-
-## Change course without losing the delivery
-
-After Build, describe changes normally. Boatstack records them, keeps valid work, and resumes at the earliest boundary. You do not need to remember a repair command. Ordinary CI failures, review findings, and denied publication attempts route automatically for active deliveries and published PRs.
+V1 repeatedly reconstructed lifecycle, identity, publication, runtime, and
+recovery state in different commands. V2 replaces that distributed authority:
```text
-"This is wrong" → record → repair → test → review
- ↘ changed intent → approve delta
+explicit invocation
+ -> read-only observation
+ -> canonical snapshot
+ -> deterministic supervisor
+ -> exact admission
+ -> registered effect
+ -> fresh observation and postcondition
+ -> immutable receipt
```
-Receipts stay as history. Published corrections become independently approved, linked deliveries. Boatstack updates an open PR after fresh gates. For merged or closed work, it opens a new PR.
-
-## What you get
-
-- **Change coding agents without changing how you ship.**
-- **Resume work without reconstructing the previous chat.**
-- **Stop agents from guessing material product decisions.**
-- **Require evidence for every outcome the change claims to deliver.**
-- **Create reviewer-ready PRs from the actual scope, changes, risks, and validation.**
-
-
-Technical Features
-
-- **A guided path from idea to PR.** `/auto-plan` starts a one-action-at-a-time delivery flow.
-- **Instant orientation after a break.** `boatstack next` reconstructs the verified stage. It does not treat chat or a running process as workflow evidence. You resume in seconds instead of re-reading history.
-- **Human decisions stay human.** Material product questions stay open until a person answers them. Implementation waits for explicit approval.
-- **Evidence tied to the promise.** Tests and checks map to the outcomes the change claims to deliver. One green command is not proof of everything.
-- **Context that survives the feature.** Plans, decisions, gaps, evidence, and code state stay useful beyond the chat.
-- **Conversational repair after Build.** Describe what changed. Boatstack keeps valid work and reruns only the affected boundaries.
-- **Safer agent execution.** High-confidence destructive recovery is stopped before execution; phased work is gated and published one approved delivery slice at a time.
-- **Reviewer-ready pull requests.** Actual changes, evidence, risks, rollout, and rollback become a focused PR brief. Reviewers spend time on judgment, not reconstruction.
-- **Optional repository changelog.** Require readable `CHANGELOG.md` entries grounded in actual changes.
-- **Portable across your AI stack.** Hosts, models, and skills share one repository-owned delivery contract.
-- **Repository-friendly maintenance.** Worktrees restore runtime. Updates stay in separate infrastructure PRs.
-
-
-
-## Configure repository policy
-
-`.boatstack-project.json` controls three things: the project commands and context Boatstack uses, the coding hosts it supports, and the opt-in policies for changelogs, boundary analysis, high-risk review, and feature workspaces. [Choose the outcomes you want and see every configuration field](docs/configuration.md).
-
-## How Boatstack fits into your AI stack
-
-| Part | Its job |
-|---|---|
-| **Coding agent** — Cursor, Codex, Claude Code, or Gemini CLI | Executes the work in your repository |
-| **Model** — lower-cost, general, or frontier | Reasons, writes, and evaluates within the agent |
-| **Skill** — React guidance, gstack, Spec Kit, or another specialty | Adds expertise for a particular kind of work |
-| **Boatstack** | Carries the delivery path, saved context, and proof of completion across them |
-
-Boatstack is a repository-local delivery harness.
-
-> **Designed for model flexibility · Quality uplift evaluation in progress**
-
-- **Verified:** the same completion requirements apply regardless of model, provider, or price.
-- **Observed:** benchmark runs exposed failures in protocol handling, context, verification, and recovery — not only model capability.
-- **Being evaluated:** whether this improves product quality, cost, or delivery time with lower-cost models.
-
-This does not mean every model performs equally. [See the evidence and evaluation design](docs/research-and-design.md#evaluation-of-the-finished-node).
+The immediate value is simple: every host consumes one executable delivery law.
+The [technical specification](docs/architecture/boatstack-v2-kernel.md) records
+the complete contract and the historical failure synthesis.
-## Built from failures observed in real coding work
+## Install
-These behaviors come from coding failures observed in benchmark and product work, not from guesses. When a failure reveals a reusable delivery problem rather than a project-specific mistake, Boatstack turns it into a boundary that future runs enforce. Each link explains what happened, what Boatstack does, and whether that behavior has actually been tested.
+On macOS or Linux:
-| What happened | What Boatstack does | Current evidence |
-|---|---|---|
-| The agent guessed a product decision | Records a human answer and approval before code | Approval and drift tests |
-| A passing test was used to support a broader claim | Links each promised outcome to its validation | Coverage and plan-compiler tests |
-| A failed write led to an invented reset path | Denies high-confidence destructive recovery | Hook behavior verified; outcome benefit still being evaluated |
-| A PR lost decisions and accepted gaps | Builds a review brief from scope, diff, and evidence | Projection and stale-preview tests |
-| A phased plan opened PRs during build | Gates and publishes one delivery slice at a time | Slice-state and bypass tests |
-| A feature worktree lost its helper or stranded its validated plan | Verifies the pinned runtime and moves the exact planning package before approval or autonomy | Linked-worktree, identity, rollback, and plan-fingerprint tests |
-
-The [claim record](docs/public-claims.json) keeps every material statement tied to its sources and tests.
-
-## A small example
-
-A request asked to "Add a password reset button". But the product used passwordless sign-in. Boatstack flagged the conflict. The developer chose dual authentication. Later, review caught an unsafe recovery assumption and prompted a repair.
-
-[Follow the sanitized walkthrough](docs/account-recovery-walkthrough.md) or [ship your first feature](docs/getting-started.md).
-
-## Updates stay out of product work
-
-After you publish a PR, Boatstack may report a new stable release. It does not change the feature branch. `/boatstack-update` prepares a separate infrastructure branch, shows the diff, and waits for `open update PR`. It never merges the update.
-
-
-Install manually
-
-macOS or Linux:
-
-```bash
-git switch -c chore/install-boatstack
+```sh
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/main/install.sh)"
```
-Windows PowerShell:
+On Windows PowerShell:
```powershell
-git switch -c chore/install-boatstack
irm https://raw.githubusercontent.com/operatorstack/boatstack/main/install.ps1 | iex
```
-The installer previews generated paths, verifies the platform helper, offers optional integrations, runs a smoke check, and prints the files to commit. Boatstack core requires no Python, Node, Go, or package manager.
-
-
-
-## Find what you need
-
-**Start:** [Getting started](docs/getting-started.md) · [Files](docs/generated-files.md) · [Troubleshooting](docs/troubleshooting.md)
+The installer verifies the release checksum, runs the registered
+`installation.initialize` transition, and then installs the launcher. Review
+and commit `.boatstack/project.json`; machine-local controller state stays
+outside the worktree.
-**Inspect:** [Research and design](docs/research-and-design.md) · [Validation and evidence](docs/validation-and-evidence.md) · [Safety](docs/safety.md)
+Run the independent health query:
-**Go deeper:** [Coding](docs/evidence-engineered-coding.md) · [Design](docs/research-and-design.md) · [Contributing](CONTRIBUTING.md)
-
-## Project status
+```sh
+boatstack doctor --repo . --format text
+```
-Boatstack is an open-source research prototype. Its workflow and enforcement behavior are tested. But the current record does not prove improved delivery success. The next evaluation is a paired feature benchmark with the same model, task, and budget.
+See [Getting started](docs/getting-started.md) and
+[Configuration](docs/configuration.md) for the first delivery.
+
+## Product surface
+
+- `status`, `next`, `doctor`, `catalog`, and `events` are read-only.
+- `apply` and `recover` request stable transition IDs from the 61-event
+ executable catalog.
+- Friendly aliases such as `plan-create`, `plan-approve`,
+ `workspace-cut`, `record-test`, and `publish-pr` map to those IDs.
+- `guard` is the shared safety-hook query. It blocks high-confidence
+ destruction and routes active managed effects through admission.
+- `rpc` is the strict JSON boundary for hooks, MCP, and host adapters.
+- `sdk` is the public Go protocol client.
+- `retro` is passive analysis. It cannot decide lifecycle or write managed
+ delivery state.
+- Visual capture is normalized to `evidence.visual.attach`; the independent V1
+ capture and insight writers are removed.
+
+The generated [transition catalog](docs/architecture/boatstack-v2-transition-catalog.md)
+and [Mermaid inventory](docs/architecture/boatstack-v2-transition-catalog.mmd)
+come directly from the runtime registry.
+The [replacement closure report](docs/architecture/boatstack-v2-closure-report.md)
+records the deleted V1 authority and its V2 evidence.
+
+## Codex modes
+
+Codex users can invoke `$boatstack Autoplan` to reach an approved plan,
+`$boatstack Run` to drive a delivery through a normal PR open or update, or
+`$boatstack Update` to run the checksum-verified Boatstack installation update
+path. Mode names are case-insensitive, and bare `$boatstack` presents the same
+three choices. Run never grants merge authority.
+
+The initial `status` read is observation only. After a mode is selected, the
+Codex driver keeps one command-scoped goal, flow, worktree, actor, and authority
+context through resolution and effects. An authority-free diagnostic frontier
+cannot end an otherwise authorized invocation.
+
+## Safety
+
+Unknown, absent, stale, ambiguous, and conflicting evidence are different
+states. None grants permission to delete, publish, overwrite, or advance.
+External publication requires human or autonomy authority **and** a current
+provider receipt. Cleanup requires proved landing or explicit abandonment.
+
+Boatstack never grants merge authority. See [Safety](docs/safety.md).
+
+## Develop
+
+```sh
+python3 .github/scripts/run_go_tests.py
+python3 -m unittest discover -s .github/tests -p 'test_*.py'
+cd boatstack
+go test -race ./...
+go vet ./...
+```
-Boatstack is developed directly in this repository.
+Every pull request adds one release note. See [CONTRIBUTING.md](CONTRIBUTING.md).
diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md
index 697d7b7..ff4bac3 100644
--- a/boatstack/SKILL.md
+++ b/boatstack/SKILL.md
@@ -1,280 +1,106 @@
---
name: boatstack
-description: Use only when the user explicitly asks for Boatstack, asks what is next in Boatstack, or works on a verified active pre-publication Boatstack delivery in the current worktree and branch. Do not activate from installation, attachment, saved or approved drafts, published work, repository presence, or repository administration.
+description: Use when the user explicitly asks for Boatstack or when a V2 status query proves that the current exact worktree has an active managed delivery. Do not infer engagement from repository files, a saved plan, a branch name, or prior conversation.
---
-# Boatstack
+# Boatstack V2
-Build the smallest complete product slice that can be independently verified. Implementation methods remain open: project facts, approval, and gate evidence are canonical; host-specific prompts are adapters. You are free in how you build. Only claims of completion require evidence.
+Boatstack owns delivery control. The coding agent owns implementation.
-Boatstack starts only from an explicit user request or a verified active pre-publication delivery bound to the current worktree and branch. A saved, approved, policy-ready, ambiguous, stale, invalid, or published delivery is repository evidence, not ambient workflow authority. Outside engagement, Boatstack applies no workflow, state, publication, or irreversible-operation policy.
+## Select one Codex mode
-## Start by selecting the operation
+Recognize these mode names case-insensitively. The legacy spelling
+`auto-plan` remains an alias for `Autoplan`.
-Map the request to one operation:
+- `$boatstack Autoplan` selects the `approved-plan` terminal.
+- `$boatstack Run` selects the `open-or-updated-pr` terminal. It never selects
+ merge authority.
+- `$boatstack Update` selects the checksum-verified `installation.update`
+ path. It does not reclassify a product delivery.
-- `init`: inspect a repository and create or update `.product-loop/project.json`.
-- `next`: report the verified current stage and exactly one next action without changing workflow or repository state.
-- `run`: drive the verified feature through every delivery slice and PR publication, pausing at approval, product-decision, and publication boundaries.
-- `root-cause`: read-only failure-mode-elimination diagnosis of a bug — classify the failure class below its symptom, produce a cited root-cause chain, and hand a class-eliminating source plan to `auto-plan`; never edits code or advances a gate.
-- `auto-plan`: refine a saved host Plan-mode file into a reviewable draft feature package; refuse when that file is absent.
-- `plan-gate`: validate the Markdown draft, present it for explicit human acceptance, and record that acceptance in Markdown.
-- `build`: activate the approved Markdown plan, then implement only the active delivery slice's tasks.
-- `repair`: classify a free-form post-build change, record it durably, and resume from the earliest affected stage without silently changing approved intent.
-- `test-gate`: test requirements and relevant regressions using independent evidence.
-- `review-gate`: review the diff against the spec, project invariants, risks, and known gaps.
-- `ship-gate`: preview, then explicitly open or update, a reviewer-ready PR grounded in the approved diff and evidence.
-- `boatstack-update`: check for a stable Boatstack release and prepare its infrastructure-only update branch and PR after explicit confirmation.
-- `retro`: classify failures, propose a harness move, and gate it before promotion.
-- `export`: generate thin Cursor, Claude Code, Codex, and GitHub adapters.
+For bare `$boatstack`, present exactly three choices: `Autoplan`, `Run`, and
+`Update`. Selecting a choice is identical to invoking that mode directly.
+Mode selection chooses intent and a target only. It does not approve unseen
+plan bytes, supply external-provider authority, authorize merge, or broaden
+repository policy.
-For the full state machine, read [workflow.md](references/workflow.md). For artifact meanings and templates, read [artifacts.md](references/artifacts.md).
+## Observe once
-## Report what is next
+Run:
-Run the project-local helper's read-only `next-status --repo . --json` inspection. Once an explicit request identifies a feature slug, pass `--feature ` on every status call so unrelated saved drafts cannot redirect the run. Repository artifacts, managed delivery state, gate receipts, and the recorded PR identity are evidence; conversation, terminal, worktree, and process observations are context only. Never run the returned operation automatically. `NOT_STARTED` points to `auto-plan` (run it with the plan path via `--plan`); `PUBLISHED` means a PR exists but is not a verified merge; only `FEATURE_COMPLETE` requires no action. If unscoped state is ambiguous, stale, or invalid, name the blocker instead of choosing by recency or clearing artifacts. When an `AMBIGUOUS` block names only past deliveries the user no longer cares about, name the ignorable delivery slug(s) and offer to exclude them from ambiguity resolution; only after explicit user confirmation, add each slug with `.product-loop/boatstack ignore-delivery --repo . --feature ` (a bounded, provenance-safe write to `workflow.ignored_deliveries` — never hand-edit config or delivery state). Any new, unlisted ambiguous delivery still pauses the unscoped workflow.
-
-To see every feature at once, run the read-only `.product-loop/boatstack flow frontier --repo .`. It lists each delivery, its observed position, and who owes the next step. To wait for a published PR to move (checks finish, a review lands, a merge happens), run the read-only `.product-loop/boatstack flow watch --repo .`. The watch observes on an interval and exits when the frontier changes, when nothing can move, or at its timeout. It never acts on what it sees. When it exits, run `next-status` again and continue from the fresh state.
-
-## Run to an explicit goal
-
-For `$boatstack run --to plan|verified|pr`, `/boatstack-run`, or a natural-language run request, resolve the target and feature slug from the request or supplied plan. When the target is absent, ask once for `plan`, `verified`, or `pr`. First run the read-only `next-status --repo . --feature --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Carry the same feature slug through every later status and planning call. Return **Feature complete** only for a verified completed feature, and stop on unverified, stale, or invalid selected state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the plan fingerprint. When workspace management is enabled, run `workspace-cut` after the plan passes validation and continue every later command from its `destination_repository`. Only then record human approval or the selected autonomy target, so the receipt binds the final feature branch. Target `plan` stops after the valid reviewable plan. Targets `verified` and `pr` activate with `--autonomy ` and stop if that receipt becomes stale. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, incomplete journey decision, or ineligible policy decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions.
-
-After preflight, repeatedly run `next-status --repo . --feature --json`, execute only its verified next operation, verify the resulting repository state, and resolve again. Continue across all declared slices until the selected target is reached. A policy receipt may resolve only a non-material, within-spec, reversible choice with one recommendation, repository evidence, no protected impact, and a runnable oracle. Record it as `RESOLVED_BY_POLICY`, never `ANSWERED`. Any failed or unknown condition pauses for the human. Target `verified` stops after current test and review evidence passes. Target `pr` supplies scoped authority for one normal open or update action recorded in `autonomy.md`; after the exact preview is revalidated, call `publish-pr --autonomy ` without asking for `o` or `u`. A changed plan, repository, branch, PR action, preview, evidence, or target invalidates that path. Same-intent test/review failures may be repaired for at most three complete cycles per active slice. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or exhausted repairs. Never force-push, merge, deploy, or execute a foreign program.
-
-When `delivery.terminal` is `merged`, follow the post-publish prescriptions exactly. After publication, run `flow next` (or `next-status`). When it prescribes `flow watch`, run the watch and re-resolve when it exits. When checks fail, it prescribes `record-change --source-stage ci`; derive the exact message, classification, evidence, and changed repair mechanism from the failing check logs, never from memory, then repair, re-gate, and republish with `publish-pr --action update`. When the PR is observed merge-eligible, it prescribes the exact `gh pr merge` command; run it only as rendered, under the host's own permissions — Boatstack never merges, and you never merge without the prescription. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable PR position always ends your turn at the operator frontier.
-
-## Enforce the irreversible-operation boundary during engagement
-
-Read [irreversible-operation-boundary.md](references/irreversible-operation-boundary.md). During an explicit Boatstack command or verified active delivery, project hooks hard-deny high-confidence destructive shell and MCP operations. Outside engagement, Boatstack is inert; the host, operator, and external platform own safety. Never request or invent an in-session bypass while engaged. After an engaged external-write failure, preserve state, use read-only diagnosis, retain the immutable target boundary, and choose only proven transactional retry or fix-forward recovery.
-
-This enforcement is defense in depth, not a complete sandbox. Keep least-privilege service credentials and service-side destructive approval in place. Read `authority_status` from `run-preflight`: `HOOK_GUARDED` never proves ambient cloud authority absent, while `CREDENTIAL_ENFORCED` means a trusted external attestor supplied a current repository-only receipt. Never strengthen the former into the latter in prose.
-
-## Keep repository administration outside delivery
-
-Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes are repository administration, not Boatstack intent. Never activate Boatstack or route them to `auto-plan`, `repair`, or `workspace-sync`. If the user explicitly asks Boatstack to manage repository alignment during an active delivery, the named command remains available.
-
-For requests such as “ensure main is same as origin/main remove any current changes,” use ordinary repository administration. Do not inspect feature plans, invoke Boatstack, or render a Boatstack response unless the user explicitly asks Boatstack to perform that operation.
-
-## Bound the outcome
-
-For ordinary feature work, define one bounded outcome:
-
-1. one product domain;
-2. one input/output contract;
-3. one user-visible goal;
-4. one next operator;
-5. one verification boundary.
-
-Because this workflow is also a reusable product, maintain delivery and improvement as separate paths:
-
-- **Delivery path:** intent -> host Plan mode -> plan passed to auto-plan via `--plan` -> questions -> spec -> approved plan -> code -> gates -> PR.
-- **Improvement path:** traces -> failure classification -> proposed move -> paired evaluation -> promote/reject.
-
-Never mix benchmark observations or speculative harness changes into the delivery path during an active feature. The improvement path may propose an experiment; only a passed promotion gate changes the canonical loop.
-
-## Initialize from repository evidence
-
-Inspect only the minimal relevant code and documentation. Look for:
-
-- `AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, constitutions, architecture docs, ADRs, prior feature specs, and open gap ledgers;
-- entry points, schemas, public interfaces, decision-making functions, validators, tests, CI, deployment, and rollback paths;
-- recent PRs touching the same domain;
-- commands that actually build, lint, type-check, and test the affected slice.
-
-Do not scan the entire repository by default. Record discovered paths and commands in `.product-loop/project.json`; preserve existing host configuration rather than replacing it.
-
-## Respond to the developer
-
-Follow the **User-facing response contract** in `references/workflow.md` for every operation. Begin every Boatstack response with the status banner (`.product-loop/boatstack next-status --repo . --render`), then lead with the mapped plain-language outcome, show only decision-relevant content, end with one `### Next step`, and put machine status, helper output, fingerprints, artifact paths, receipts, and locks inside collapsed **Technical details**. Internal operations such as `check-plan`, `record-approval`, and `activate-plan` must not appear in the primary response. Write every response in Simplified Technical English: short sentences, the active voice, the present tense, one idea per sentence, the condition first, and the simple common word.
-
-Use the global, state-scoped reply shortcuts for finite input: `a` approves the pending plan, `o` opens the currently previewed feature/ad-hoc/update PR, `u` updates the currently previewed existing PR, and `r` accepts every recommendation displayed in the current finite-question response. Trim surrounding whitespace and match the complete reply case-insensitively. Bracketed forms such as `[o]`, embedded letters, and shortcuts from another state are ordinary text. Continue accepting `approve`, `open PR`, `update PR`, and `open update PR` for compatibility, but do not advertise them in user-facing responses.
-
-Shortcuts do not bypass fingerprints, committed-diff checks, evidence, authentication, or manual commit/push prerequisites. Never interpret `r` as approval, publication, identity, secret input, permission escalation, policy bypass, destructive recovery authorization, or another safety exception. Free-text and operation-command prompts remain explicit. Use an explicit approval identity first; otherwise use the authenticated GitHub login when available. Ask once for a name or handle only when no trustworthy identity can be resolved. Never invent a placeholder name (e.g., Sam, Eve). Never infer the approver from the filesystem username, commit history, or agent identity. If identity is missing after approval, preserve the current approval intent and ask only for identity; do not make the human approve the unchanged plan again.
-
-## Handle new intent during active deliveries
-
-Before starting `/auto-plan` for a new feature, check `next-status --repo . --json`. If there is already an active managed delivery on the current branch (e.g., Status is `BUILD`):
-1. Stop and clarify the developer's intent. Ask: *"You have an active delivery (``). Are these new ideas amendments to this feature, or a completely separate feature?"*
-2. If the developer confirms it is an **amendment**, do not start a new feature. Route to the `repair` operation, classify it as a `requirement_amendment`, and update the existing plan.
-3. If the developer confirms it is a **completely separate feature**, proactively suggest worktree isolation to avoid branch entanglement. Ask: *"Since `` is still active here, do you want to cut a new worktree (`feat/`) to keep this work isolated? (Recommended)"*
-4. If they accept isolation, route to the `workspace-cut` operation. If they explicitly choose to stack both features on the same branch, only then proceed with `/auto-plan` for the new feature.
-
-## Run `auto-plan`
-
-0. Require the plan file produced in the active host's Plan mode, passed explicitly. Validate it with `.product-loop/boatstack check-source-plan --repo . --plan `. Boatstack never scans directories for plans, so `--plan` is required and no unshipped saved plan becomes ambient context. If no plan path is supplied or the file is missing, empty, or unreadable, return `BLOCKED`; do not write or guess the missing source plan inside `auto-plan`. Because its hash is re-checked through `build`, point `--plan` at a durable in-repo path that stays present and unchanged; a path outside the repository is rejected.
-1. Treat the supplied plan as an initial proposal, not approved truth. Record its path as `source_plan_path` in the structured plan.
-2. Write the bounded outcome definition before proposing architecture.
-3. Separate facts, decisions, unknowns, and safely deferrable gaps.
-4. Before proposing implementation tasks, inspect the repository and verify any assumptions about API routes, data access, UI components, authentication, server actions, streams, jobs, and external services. Do not guess application architecture.
-5. If `workflow.boundary_analysis` is `true` in `project.json`: Evaluate if the requested change is a symptom of a missing systemic boundary (e.g., deficient data normalization, leaky validation, missing authorization edge). If it is, perform a rapid codebase scan for other vulnerabilities sharing this failure mode. Present this as a material product decision, showing concrete codebase evidence of the blast radius. Offer tiered implementation paths: [1a] Symptom Patch (fix only the requested route), or [1b] Programmatic Enforcement (refactor the edge and install a programmatic boundary to mathematically prevent this). If the user chooses programmatic enforcement, explicitly structure the plan into two delivery slices: Slice 1 establishes the programmatic boundary (hook, trigger, or strict test), and Slice 2 implements the feature using that boundary.
-- When `workflow.pr_visual_evidence` is `suggest` or `require`, also record one structural `pr_visual_evidence` decision reused through test, review, and ship. Changes below `project.visual_surfaces[].paths` are relevant. Use one to three scenarios naming user context, user goal, journey step, reviewer context, entry, state, viewport, surface, and expected visible outcomes, or `not_relevant` with a non-empty reason for review. Discover repository-owned visual tooling but do not add or require framework-specific tooling.
-6. Express verified architectural information as typed `architecture_facts`. Each architecture fact must reference evidence IDs produced by Boatstack repository inspection. Do not create or invent evidence IDs. Reading one arbitrary repository file does not ground an unrelated architectural claim.
-7. When an architectural question cannot be verified, record it in `architecture_unknowns`. Do not create an implementation task that depends on an unresolved architecture unknown. Create a bounded discovery task instead.
-8. Every architecture-sensitive task must reference the facts it depends on through `requires_facts`.
-9. Ask the developer only questions whose answers materially change behavior, contracts, risk, or acceptance. Ask 1-3 concise questions at a time and give each 2-3 mutually exclusive choices with compact inline-code keys (`1a`, `1b`, `1c`, then `2a`, `2b`, and so on). Suffix exactly one choice per question with `(Recommended)`, explain the impact, and end with one reply hint naming the keys or `r` for all recommendations. Use this format with structured question tools and plain text alike, then return `WAITING_FOR_INPUT`.
-10. Treat a standalone `r` as explicit human acceptance only when every displayed question has exactly one recommendation. Echo the selected question-to-answer mapping before recording each as `ANSWERED`; otherwise ask again without choosing. An authoritative repository fact is `DISCOVERED`, an agent suggestion or inferred choice is `PROPOSED`, and only an explicit human response is `ANSWERED`. Every material proposal remains in `plan.md` as a `blocking_questions` ID until the human answers it. Never use labels such as “answered by plan default.”
-11. Create the feature spec: problem, users, outcomes, non-goals, acceptance criteria, invariants, interfaces, failure behavior, observability, rollout, and rollback. Translate every accepted claim into an observable condition with a defensible oracle.
-12. Run product, design, engineering, and developer-experience reviews only when applicable. If gstack is installed, its review skills can implement these lenses; do not require it.
-13. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative.
-14. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`.
-14. For every external write, record `affected_paths` plus side-effect kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Reject ambiguous reset rollback or target names.
-15. Write only Markdown feature artifacts, including the canonical structured `plan.md`. For every artifact, send the selected feature, durable in-repo source plan, artifact name, target shell, and complete Markdown to the read-only `flow bootstrap` oracle through the current Boatstack operation entrypoint. Execute only its returned `planning_envelope`, unchanged. The oracle is the sole authority for embedded or detached helper identity, source-plan freshness, and literal shell rendering. Re-resolve it after `workspace-cut` from `destination_repository`. Never construct a `planning-write` command, split either literal envelope across calls, use an expansion-capable delimiter, target another repository or helper, or paste Markdown at a shell prompt. Put the authoritative JSON inside the marked Boatstack block and run the current Boatstack entrypoint's read-only `check-plan --plan /plan.md`. The host's ordinary Markdown writer may be used only where the host explicitly permits it. Never use arbitrary shell redirection to evade a host write boundary.
-16. Keep implementation tasks separate from publication authority. Internal phases remain tasks inside one delivery slice. When the accepted outcome explicitly requires multiple PRs, declare ordered `delivery_slices`; assign every task exactly once and give each slice its own optional base/head branch contract. Plan approval approves this structure but never authorizes a push or PR.
-17. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`.
-
-Do not treat an ADR as general project context. ADRs record accepted durable decisions. Use a question ledger for unknowns and a gap ledger for known divergence.
-
-Treat repository-owned product context as canonical. Do not require it to be migrated or rewritten into a Boatstack memory. Specs, plans, summaries, and selected context are temporary task projections: keep them reviewable, link material claims back to their source paths, and never silently replace the source. Preserve the source; project only the relevant slice.
-
-## Run `plan-gate`
-
-1. Run the read-only Markdown preflight and retain its exact fingerprint:
-
-```bash
-.product-loop/boatstack check-plan \
- --plan .product-loop/features//plan.md
-```
-
-2. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, validation provenance, `PLAN_FINGERPRINT`, and `READINESS_FINGERPRINT` in a reviewable form. A schema-v3 plan must decide `journey_evidence`: `relevant` with complete typed runnable oracles, or `not_relevant` with a reason.
-3. When workspace management is enabled, run `workspace-cut` with this feature. Continue from the returned `destination_repository`. The validated plan fingerprint must remain unchanged.
-4. When `workflow.human_plan_approval` is true, ask the developer to approve it or request changes and end with: Reply `a` to approve. When false, state that Build will create a policy-activation lock and do not imply human approval.
-5. On changes, return to `auto-plan`, preserve the feedback in the question ledger, and issue a new draft.
-6. When human approval is enabled, invoke `.product-loop/boatstack record-approval` with the destination plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`.
-7. End in Plan mode and tell the developer the feature is authorized for the host's normal Build transition. Do not compile tasks, create a lock, request Agent mode merely to write a file, or edit product code.
-
-All files created or updated by `auto-plan` and `plan-gate` must be Markdown. gstack and Spec Kit may help produce those documents, but their implementation stages and non-Markdown executable state are deferred to `build`.
-
-## Build without erasing evidence
-
-- First confirm the host is in an execution-capable mode. If a requested transition is rejected or product-code writes remain unavailable, return `READY_FOR_BUILD` and stop without activating, compiling, or writing a lock.
-- Before the first product-code edit, activate the exact authorized Markdown plan. Include `--approval` only when `workflow.human_plan_approval` is true:
-
-```bash
-.product-loop/boatstack activate-plan \
- --plan .product-loop/features//plan.md \
- --out-dir .product-loop/features//compiled \
- --output .product-loop/features//plan.lock.json
-```
-
-For human authorization, add `--approval .product-loop/features//approval.md`.
-
-- Activation atomically repeats readiness, verifies the plan fingerprint and any required approval, compiles `tasks.json`, `test-matrix.json`, `journey-oracles.json`, and the evidence skeleton, then writes a schema-v3 readiness-bound lock with `authorization_mode: human` or `policy`. Existing active schema-v1/v2 locks remain readable. Missing required approval, open blocking questions, or any changed input returns `BLOCKED`.
-- Activation also creates ignored delivery state bound to the plan lock. Read it with `delivery-status`; implement only the active slice's `task_ids`. A multi-slice plan advances only after the current slice publishes through `ship-gate`.
-- Keep the source plan present and hash-current through completion of `build`.
-- Choose any suitable model, tool, or implementation tactic inside the approved boundary. Boatstack controls transitions and claims, not local creativity.
-- Work from approved tasks and acceptance criteria.
-- Never push, open, update, ready, or merge a PR during `build`. The host hook denies direct publication while managed delivery is active; publication is reachable only through the confirmed `ship-gate` publisher.
-- Preserve the last known-good state; repair locally instead of restarting a near-correct implementation.
-- Re-scope context at task boundaries. Include relevant source, interfaces, invariants, and tests—not arbitrary history.
-- Stop and ask when implementation exposes a new product decision or a high-impact irreversible choice.
-- Log deviations from the plan. Update the spec when product intent changes; add an ADR only when a durable architectural decision changes.
-- Do not repeat the same failed tactic more than twice without re-diagnosing the failure class.
-
-Do not branch the workflow on model brand, price, or a guessed capability tier. Branch only on observable work state: unresolved ambiguity, risk, convergence, repeated tactics, tool results, test fidelity, and gate evidence. A repository may choose any implementation model; the contract and gates stay the same.
-
-## Repair from ordinary conversation
-
-Before any product edit or explicit `repair`, run `recovery-status` with the exact requested change and observed source stage. It resolves active work and published work associated with the current branch or recorded PR. Automatically use repair for ordinary CI failures, review findings, denied publication, problems, and modifications even when the user does not name Boatstack or a slash command. Active work resumes through `record-change`; a published parent returns `CORRECTIVE_CHILD_REQUIRED` and a deterministic child id. If active work enters `AMENDMENT_REQUIRED` or `PLAN_INVALID`, treat that composite state as authoritative over the slice status. Save the accepted amendment as a durable source plan, obtain a fresh lifecycle-bound `flow bootstrap` prescription for the same feature, execute only its returned envelope, then require normal approval and activation before delivery resumes. Never ask the user to manually repeat a denied push or PR mutation.
-
-If Cursor reports `MainThreadShellExec not initialized`, the host failed before Boatstack's hook process started. Keep the hook fail-closed and make **Developer: Reload Window** the primary recovery, then retry the operation. Recommend the verified installer only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper/runtime.
-
-If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it could not safely decode; no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop shell and tool retries, preserve current edits, and run `.product-loop/boatstack diagnose-hook --host --repo .` from an external terminal. For Cursor, start a new task after the probe. The diagnostic proves the installed guard with a canonical event but cannot inspect the live event supplied by the host. Do not recommend reinstall or hydration unless Boatstack separately reports a missing, drifted, unsafe, or checksum-invalid runtime.
-
-Same-intent repair resumes at the helper-reported stage and reuses the existing gates. Pass `--mechanism` for every repair classification. Implementation, verification, and review repairs each have an independent three-attempt budget. Requirement amendments and readiness recovery consume none. An identical failure-class, evidence, and mechanism retry is denied. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority.
-
-A published delivery is immutable. Record the append-only observation without changing its state, then automatically prepare a one-slice correction under the suggested feature id with `parent_delivery` set to the published feature. Present the inherited intent, observed failure, existing local diff, verification, and PR destination, then pause for the normal fingerprinted human approval. The corrective child receives its own lock and full gates. A verified open PR reuses its head branch and is updated; merged or closed work uses a fresh branch and PR. Unknown PR state may be planned but blocks destination-specific publication.
-
-## Enforce the gates
-
-### Test gate
-
-- After build completes, the source Plan-mode file is no longer a runtime prerequisite. Test, review, and ship use the approved lock, actual diff, and accumulated evidence; provenance remains recorded in the lock.
-- Derive tests from acceptance criteria and affected contracts, not only from the implementation.
-- Run existing relevant tests plus targeted new tests, linters, type checks, builds, and runtime checks.
-- When `journey_evidence` is relevant, run every compiled oracle and import typed results with `record-journey-results --feature --results `. Test and review gates reject missing, failed, manifest-mismatched, head-mismatched, or diff-stale results.
-- For relevant PR visual scenarios, use the repository runner first, then a host browser against the existing development server, one supplied launch instruction, or an explicitly approved machine-local runtime. A harness may write `BOATSTACK_CAPTURE_RECEIPT` with scenario id, reached state or URL, named check results, and overall result; without it the PNG is only `CAPTURED`, never scenario-verified. Do not modify repository dependencies or configuration for capture. A human must review each exact PNG for secrets and private data and record `human-reviewed` before any external upload; keep the images outside the repository.
-- Treat model-authored tests and same-model self-review as evidence, not ground truth.
-- Validate that tests load and exercise the intended interface. For high-risk code, add an independent oracle such as contract fixtures, mutation testing, differential checks, staging verification, or human acceptance.
-- A failing check blocks the gate. A skipped check must include a reason and risk owner. `PASS_WITH_GAPS` is accepted only when `workflow.allow_pass_with_gaps` is true.
-- Commit the intentional active-slice product and evidence diff, then record the test result with `record-delivery-gate --feature --slice --gate test`. The receipt is bound to the base/head branches, commit, product diff, and evidence hash. Editing an evidence status is not a gate transition.
-
-### Review gate
-
-- Review the actual diff, not the intended plan alone.
-- Check spec traceability, invariants, data/security/tenancy boundaries, failure behavior, backward compatibility, migrations, observability, tests, docs, and gaps.
-- When configured high-risk paths changed, use a human peer or separate agent and record `--reviewer-identity` with `--review-method human_peer|separate_agent`.
-- Convert actionable findings into tasks. Do not pass while critical findings are open.
-- On pass, record `record-delivery-gate --feature --slice --gate review`. Review is rejected unless the same diff already has a test receipt; any later product change makes both receipts stale.
-
-### Ship gate
-
-- Require a clean, intentional diff; passing required checks; a filled evidence ledger; explicit known gaps; and rollout/rollback notes.
-- Project only review-relevant context into `.product-loop/features//pr.md`: why, changed behavior, review order, decisions, acceptance evidence, gaps, risks, rollout, rollback, and collapsed provenance.
-- Treat the actual committed diff as what changed, approved artifacts as why it changed, and evidence as the only support for completion claims.
-- In the visible Evidence table, link each managed claim to the current repository-relative evidence ledger using a readable link label; do not expose hashes or absolute paths.
-- Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. Add UI evidence, security/privacy, migration, or operations sections only when relevant.
-- When PR visual evidence is relevant or unresolved, show the exact fingerprinted PNGs and external-host privacy warning, include the structural Visual evidence table, and treat `o` or `u` as approval of the PR body plus one evidence comment. Boatstack defaults to Litterbox with a 72-hour expiry, verifies every hosted URL, and puts only hosted Markdown image links in the comment. Never attach image files to the PR or commit an evidence branch. `suggest` retains a visible gap, while `require` blocks completed publication. Preserve an opened PR and retry the same fingerprint and comment from `visual_pending` rather than opening a duplicate.
-- Internally generate the normalized context and preview skeleton with `pr-context --repo . --feature `, write `pr.md`, and validate it with `check-pr --repo . --preview `. Keep these helper names and their fingerprints out of the primary response.
-- Inspect the projected changed files, diff stat, high-risk matches, and actual diff before composing the brief. Commit messages are navigation aids, not proof of what changed.
-- Show the exact title and rendered body before any GitHub mutation. If no PR exists, render the one next action as: Reply `o` to open PR. If one exists, render: Reply `u` to update PR.
-- After that exact confirmation, commit only the reviewed `pr.md`, rerun the preview check, require the same preview fingerprint, then invoke the internal publisher with the selected open/update action. It rechecks the current committed diff, approval, lock, and evidence and performs only a normal push. Any intervening change invalidates the preview and requires regeneration; never force-push.
-- The publisher additionally requires current test and review receipts for the active slice. Successful publication marks only that slice published and activates the next slice. Plan approval, a prose phase label, or a previous slice's receipts cannot authorize a later slice.
-- Keep model attribution inside collapsed provenance. Create or update the PR, but keep merge and deploy as separate authorized actions.
-- Only after successful PR publication, perform the bounded cached release check. If a newer stable Boatstack release should be announced, keep `Review the PR` as the one next action and put the no-mutation update notice in collapsed details. Release lookup failure never changes the ship result.
-- Never hide failed experiments, skipped checks, or `PASS_WITH_GAPS` behind a green summary.
-- If a required check also fails on the base branch, record that comparison and recommend a separate repair PR. Do not edit unrelated code in the approved feature branch. A bypass is valid only when repository policy permits it and the human explicitly authorizes it; otherwise return to planning for any scope expansion.
-
-Gate statuses are `PASS`, `PASS_WITH_GAPS`, and `BLOCKED`. Critical safety, correctness, or product-acceptance gaps always produce `BLOCKED`.
-
-## Update Boatstack separately
-
-Treat `boatstack-update` as infrastructure maintenance, never as feature work:
-
-1. Run the current local helper's `doctor`, then force the cached stable-release check. If Boatstack is current, return **Boatstack is current** with no action required.
-2. Fetch the configured default branch without editing product files. Require that branch to be current and clean; otherwise return **Update postponed** and change nothing.
-3. Create only `chore/update-boatstack-v`. Run the installer fetched from the exact release tag in update mode with the exact version, repository path, and non-interactive preview acceptance.
-4. Preserve `.boatstack-project.json`, all portable adapters, optional integration selections, and unrelated host settings. Block on generated drift, collisions, missing provenance, a failed checksum, a failed `doctor`, or any product-file change.
-5. Run `prepare-update-pr --repo . --version --json`; show its exact non-empty fingerprinted package with the version transition, release notes, integration state, changed infrastructure paths, checksums, rollout, and rollback. Respond **Boatstack update ready** and render the one next action as: Reply `o` to open update PR.
-6. Only the state-scoped `o` or compatible full reply authorizes `publish-update-pr` with that preview fingerprint. The publisher owns staging, the exact commit, normal push, and one PR. If its response is interrupted, inspect `operation-status` and reconcile the exact branch/PR rather than repeating GitHub mutation. Never merge it. If GitHub publication is unavailable, retain the prepared branch and provide one manual action.
-
-Natural requests such as “Update Boatstack” use this operation. `doctor` may display a cached notice but must remain offline. Do not perform release discovery during planning, approval, build, test, review, or PR preview.
-
-## Improve an existing PR without a public command
-
-When the user naturally asks Boatstack to prepare, improve, summarize, or update a PR and no managed feature package is available:
-
-1. Do not invent a `/pr-brief` command or require the user to learn another operation.
-2. Project the current committed branch diff, commits, observed checks, and relevant repository context into `.product-loop/pr-briefs//pr.md`.
-3. Use the same reviewer-first title/body contract as `ship-gate`, but label missing approval or gate evidence `NOT_VERIFIED`. Never imply Boatstack approved the plan or passed a gate that did not run.
-4. Add conditional security/privacy, migration, UI evidence, or operations sections only when the diff makes them relevant.
-5. Preview the exact title and rendered body. Render only Reply `o` to open PR. or Reply `u` to update PR., as appropriate.
-6. Internally run `pr-context --repo .` without a feature, validate with `check-pr`, and keep those mechanics out of the primary response.
-7. After confirmation, commit only `pr.md`, recheck the exact preview fingerprint and committed diff, then publish with the selected open/update action. If anything changed, regenerate instead of publishing stale text.
-
-This is a two-slice ZCA projection: the reviewer brief minimizes review effort, while collapsed provenance preserves the evidence boundary. The projection must not become a dump of every generated artifact.
-
-## Learn without overfitting
-
-Read [failure-moves.md](references/failure-moves.md) before proposing a loop change.
-
-For a retro over past sessions, run the read-only `.product-loop/boatstack retro derive --input [--input ...]`. It detects operator instructions that recur across sessions and classifies each as a missing observation, verb, setpoint, or guard, with a suggested typed promotion. It reads only the transcript files the user names, works fully offline, and writes nothing. A recurring instruction is evidence of a missing typed control — promote it by hand through the normal reviewed delivery flow; never turn it into a saved prompt, and never apply a proposal automatically.
-
-1. Classify the observed failure below the surface symptom.
-2. State a mechanism and the exact failure population the move targets.
-3. Estimate cost, risk, and possible regressions.
-4. Run a cheap smoke test, then a paired representative evaluation.
-5. Keep a holdout or independent acceptance boundary.
-6. Promote only a clear non-regressing result; otherwise record `REJECT` or `WASH`.
-
-More steps, more context, stronger wording, more tests, or more retries are not improvements by themselves. Preserve negative results in the move ledger.
-
-## Export host adapters
-
-Read [portability.md](references/portability.md), then use:
-
-```bash
-.product-loop/boatstack export --repo /path/to/repo --config /path/to/project.json --write
+```sh
+boatstack status --repo . --format json
```
-Run with `--check` in CI to detect drift. The exporter writes generated files only and refuses to overwrite user-owned files. Review the generated diff in a branch and ship it through a PR.
+Use only the returned canonical snapshot and decision. Do not inspect or edit
+machine state. `status` is observation only: an authority-free `FRONTIER` is a
+diagnostic result, not the delivery verdict for an explicitly selected mode.
+If no goal is configured, bind `Autoplan` or `Run` to a safe goal ID, delivery
+ID, and the terminal selected above. `Update` preserves any configured product
+goal and requests only `installation.update`.
+
+## Bind authority and follow the prescription
+
+After mode selection, create one command-scoped authority context containing
+the exact goal ID, delivery ID, repository path, worktree, flow ID, actor, and
+authority receipt paths actually supplied by the user or host. Do not synthesize
+missing authority. Carry this same context through every `next`, `apply`,
+`recover`, and re-resolution; never fall back to the authority-free status
+decision.
+
+- Request the stable transition ID returned by `next`.
+- Execute only the typed parameters declared by `catalog`.
+- Preserve the complete `apply` response and stderr before interpreting it,
+ including admission, receipt, postcondition evidence, error, recovery, and
+ transaction fields. Do not pipe away or truncate those fields.
+- Re-resolve with the same authority context after every complete receipt.
+- Stop on `FRONTIER`, `BLOCKED`, `REFUSED`, or `UNRESOLVED` only when that
+ decision was produced by an authority-bearing resolution for the selected
+ mode.
+- Treat `TERMINAL` as exact goal evidence, not an agent completion claim.
+
+Friendly CLI verbs are aliases only. The registry ID is authoritative. Use
+`apply --transition ` or `recover --transition ` when an alias does
+not exist.
+
+## Authority
+
+Human, autonomy, repository-policy, and provider authority are separate.
+Repository authority comes from the current V2 configuration fingerprint.
+External publication requires human or autonomy authority **and** an unexpired
+provider receipt whose fingerprint binds the exact preview or correction body.
+Never infer authority from authentication, path presence, prior approval, or a
+successful command.
+
+## Workspaces
+
+`workspace.cut` transfers controller authority to the returned destination and
+parks the source checkout. Continue only from that exact destination.
+`workspace.cleanup` is legal only for proved landing or explicit abandonment;
+it verifies completion from the preserved source checkout.
+
+## Safety and recovery
+
+Send shell commands to `boatstack guard --repo . --host --command
+` when the host integration requests a guard decision. Never recreate
+the destructive-command policy in prompt text.
+
+If observation reports `RECOVERY`, use only a transition listed in
+`recovery_info.permitted` and pass the exact transaction ID. Do not retry an
+unknown external effect.
+
+## Product evidence
+
+Plans, approvals, gate evidence, visual manifests, and publication previews are
+repository-owned inputs. Presence is never authority. Visual capture enters the
+delivery model only through `evidence.visual.attach`. Retrospective analysis is
+passive and cannot advance a delivery.
+
+## Boundaries
+
+Read [workflow.md](references/workflow.md) for the event families,
+[artifacts.md](references/artifacts.md) for ownership, and
+[host-hook-contracts.md](references/host-hook-contracts.md) for RPC integration.
+
+Boatstack never authorizes merge. Do not merge unless the user separately and
+explicitly asks.
diff --git a/boatstack/activation.go b/boatstack/activation.go
deleted file mode 100644
index c5a7fab..0000000
--- a/boatstack/activation.go
+++ /dev/null
@@ -1,389 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-// engagementHookMarker identifies the user-level engagement probe. The legacy
-// marker is recognized only so updates can remove the superseded hook entry.
-const engagementHookMarker = "engagement-probe"
-const legacyAmbientHookMarker = "ambient-safety-hook"
-
-func containsEngagementHook(value any) bool {
- switch typed := value.(type) {
- case string:
- return strings.Contains(typed, engagementHookMarker) || strings.Contains(typed, legacyAmbientHookMarker)
- case []any:
- for _, item := range typed {
- if containsEngagementHook(item) {
- return true
- }
- }
- case map[string]any:
- for _, item := range typed {
- if containsEngagementHook(item) {
- return true
- }
- }
- }
- return false
-}
-
-// detachedHelperPath resolves the helper the engagement probe should invoke: the
-// external shared-runtime slot's binary when present (stable across helper
-// relocation), else the running executable, else the bare name.
-func detachedHelperPath(repo string) string {
- if binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit); err == nil && fileExists(binaryPath) {
- return binaryPath
- }
- if exe, err := os.Executable(); err == nil && exe != "" {
- return exe
- }
- return "boatstack-helper"
-}
-
-func engagementDesiredEntry(host, event, helper string) map[string]any {
- entry := desiredHostHookForEvent(host, event)
- overrideHookCommands(entry, engagementProbeCommand(host, helper), engagementProbePowerShellCommand(host, helper))
- return entry
-}
-
-// Detached activation. A detached repository has no in-repo host hook, so the
-// developer installs one user-level engagement probe per coding agent. The probe
-// is inert unless a worktree-local active-delivery lease is valid.
-//
-// activation deliberately does NOT silently rewrite a developer's global host
-// configuration. It emits the exact per-host config location and the precise
-// Boatstack-owned snippet to add, so activation is transparent and never clobbers
-// existing global hooks. The snippet is host-neutral in intent: every supported
-// agent gets the same engagement probe, shaped for that agent's hook schema.
-
-// HostActivation is the activation instruction for one coding agent.
-type HostActivation struct {
- Host string `json:"host"`
- ConfigPath string `json:"config_path"`
- Snippet string `json:"snippet"`
- Instruction string `json:"instruction"`
-}
-
-// ActivationPlan is the set of per-host activation instructions for a repository.
-type ActivationPlan struct {
- SchemaVersion int `json:"schema_version"`
- Mode string `json:"mode"`
- Attached bool `json:"attached"`
- RepoRoot string `json:"repo_root"`
- HelperPath string `json:"helper_path"`
- Hosts []HostActivation `json:"hosts"`
- Reason string `json:"reason"`
-}
-
-// userHostConfigPath returns the developer-level (not repo-level) config path a
-// host reads across all projects. BOATSTACK_USER_CONFIG_ROOT overrides the base for
-// tests and for a launcher that keeps host state external.
-func userHostConfigPath(host string) (string, error) {
- base := os.Getenv("BOATSTACK_USER_CONFIG_ROOT")
- if base == "" {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", err
- }
- base = home
- }
- switch host {
- case "cursor":
- return filepath.Join(base, ".cursor", "hooks.json"), nil
- case "claude":
- return filepath.Join(base, ".claude", "settings.json"), nil
- case "codex":
- return filepath.Join(base, ".codex", "hooks.json"), nil
- case "gemini":
- return filepath.Join(base, ".gemini", "settings.json"), nil
- default:
- return "", fmt.Errorf("unsupported host %q", host)
- }
-}
-
-// engagementProbeCommand builds the shell command a user-level hook runs. Claude
-// exposes the project directory as ${CLAUDE_PROJECT_DIR}; the others resolve it
-// from Git at hook time.
-func engagementProbeCommand(host, helper string) string {
- root := `ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"`
- if host == "claude" {
- root = `ROOT="${CLAUDE_PROJECT_DIR:-}"`
- }
- return fmt.Sprintf(`%s; [ -n "$ROOT" ] || exit 0; GIT_DIR="$(git -C "$ROOT" rev-parse --path-format=absolute --git-dir 2>/dev/null)" || exit 0; LEASE="$GIT_DIR/boatstack/engagement.json"; [ -f "$LEASE" ] && [ ! -L "$LEASE" ] || exit 0; BRANCH="$(git -C "$ROOT" branch --show-current 2>/dev/null)"; LEASE_BRANCH="$(sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$LEASE" | head -n 1)"; [ -n "$BRANCH" ] && [ "$BRANCH" = "$LEASE_BRANCH" ] || exit 0; exec %q engagement-probe --host %s --repo "$ROOT"`, root, helper, host)
-}
-
-func engagementProbePowerShellCommand(host, helper string) string {
- root := `(& git rev-parse --show-toplevel 2>$null)`
- if host == "claude" {
- root = `$env:CLAUDE_PROJECT_DIR`
- }
- return fmt.Sprintf(`$root = %s; if (-not $root) { exit 0 }; $gitDir = (& git -C $root rev-parse --path-format=absolute --git-dir 2>$null); if (-not $gitDir) { exit 0 }; $leasePath = Join-Path $gitDir 'boatstack/engagement.json'; if (-not (Test-Path -LiteralPath $leasePath -PathType Leaf)) { exit 0 }; $leaseInfo = Get-Item -LiteralPath $leasePath; if ($leaseInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) { exit 0 }; try { $lease = Get-Content -LiteralPath $leasePath -Raw | ConvertFrom-Json } catch { exit 0 }; $branch = (& git -C $root branch --show-current 2>$null); if (-not $branch -or $lease.branch -ne $branch) { exit 0 }; & %s engagement-probe --host %s --repo $root; exit $LASTEXITCODE`, root, powerShellPlanningWord(helper), host)
-}
-
-// engagementHostFragment shapes the engagement probe into a host's hook schema, reusing
-// the embedded entry shape and overriding only the command so the guard runs from
-// the external helper rather than an in-repo guard script.
-func engagementHostFragment(host, helper string) ([]byte, error) {
- command := engagementProbeCommand(host, helper)
- commandWindows := engagementProbePowerShellCommand(host, helper)
- events := map[string]any{}
- for _, event := range hookEvents(host) {
- entry := desiredHostHookForEvent(host, event)
- overrideHookCommands(entry, command, commandWindows)
- events[event] = entry
- }
- return GeneratedJSON(map[string]any{"schema_version": 1, "host": host, "scope": "user", "events": events})
-}
-
-// overrideHookCommand replaces the command in a desired-hook entry (both the flat
-// cursor form and the nested hooks[] form) with the engagement-probe command.
-func overrideHookCommands(entry map[string]any, command, commandWindows string) {
- if _, ok := entry["command"]; ok {
- entry["command"] = command
- entry["commandWindows"] = commandWindows
- }
- if nested, ok := entry["hooks"].([]any); ok {
- for _, item := range nested {
- if hook, ok := item.(map[string]any); ok {
- hook["command"] = command
- hook["commandWindows"] = commandWindows
- }
- }
- }
-}
-
-// DetachedActivationPlan returns the per-host activation instructions for a
-// repository. It is read-only. It requires the repository to be attached in
-// detached mode (an unattached repository has nothing to activate).
-func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, error) {
- root, err := ResolveRepository(repoPath)
- if err != nil {
- return ActivationPlan{}, err
- }
- plan := ActivationPlan{SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), RepoRoot: root}
- ctx, ok, verifyErr := detachedContextFor(root)
- if verifyErr != nil {
- plan.Mode = string(SupervisionDetached)
- plan.Attached = true
- plan.Reason = verifyErr.Error()
- return plan, nil
- }
- if !ok {
- plan.Reason = "This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first."
- return plan, nil
- }
- plan.Mode = string(SupervisionDetached)
- plan.Attached = true
- _ = ctx
-
- helper := detachedHelperPath(root)
- plan.HelperPath = helper
-
- if len(hosts) == 0 {
- hosts = []string{"cursor", "claude", "codex", "gemini"}
- }
- for _, host := range hosts {
- configPath, pathErr := userHostConfigPath(host)
- if pathErr != nil {
- continue
- }
- snippet, fragErr := engagementHostFragment(host, helper)
- if fragErr != nil {
- return ActivationPlan{}, fragErr
- }
- plan.Hosts = append(plan.Hosts, HostActivation{
- Host: host,
- ConfigPath: configPath,
- Snippet: string(snippet),
- Instruction: fmt.Sprintf("Merge the Boatstack engagement probe for %s into %s. It emits no output and applies no policy unless this worktree has a verified active delivery.", host, configPath),
- })
- }
- plan.Reason = "Add the developer-level engagement probe for each coding agent you use. Repository presence and attachment alone remain inert."
- return plan, nil
-}
-
-// EngagementHostResult is the per-host outcome of an install/uninstall.
-type EngagementHostResult struct {
- Host string `json:"host"`
- ConfigPath string `json:"config_path"`
- Action string `json:"action"` // installed | removed | unchanged
-}
-
-// EngagementActivationResult is the deterministic outcome of installing or
-// removing the developer-level engagement probe.
-type EngagementActivationResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
- Mode string `json:"mode,omitempty"`
- RepoRoot string `json:"repo_root,omitempty"`
- Hosts []EngagementHostResult `json:"hosts,omitempty"`
- Reason string `json:"reason"`
-}
-
-func blockedEngagementActivation(reason string) EngagementActivationResult {
- return EngagementActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason}
-}
-
-func defaultActivationHosts(hosts []string) []string {
- if len(hosts) == 0 {
- return []string{"cursor", "claude", "codex", "gemini"}
- }
- return hosts
-}
-
-// mergeEngagementHooks installs exactly one engagement probe per host event,
-// preserving every unrelated entry (a user's own hooks, and any embedded guard)
-// verbatim. Stripping then re-adding the single owned entry makes reinstall
-// idempotent — the same input config yields the same output.
-func mergeEngagementHooks(config map[string]any, host, helper string) error {
- hooks, ok := config["hooks"].(map[string]any)
- if config["hooks"] == nil {
- hooks = map[string]any{}
- config["hooks"] = hooks
- } else if !ok {
- return fmt.Errorf("host hook config has non-object hooks")
- }
- for _, event := range hookEvents(host) {
- var entries []any
- if existing := hooks[event]; existing != nil {
- list, listOK := existing.([]any)
- if !listOK {
- return fmt.Errorf("host hook event %s is not a list", event)
- }
- entries = list
- }
- kept := []any{}
- for _, entry := range entries {
- if containsEngagementHook(entry) {
- continue
- }
- kept = append(kept, entry)
- }
- kept = append(kept, engagementDesiredEntry(host, event, helper))
- hooks[event] = kept
- }
- if host == "cursor" && config["version"] == nil {
- config["version"] = float64(1)
- }
- return nil
-}
-
-// removeEngagementHooks strips only Boatstack engagement probes, preserving all
-// other entries. It reports whether anything changed.
-func removeEngagementHooks(config map[string]any, host string) bool {
- hooks, ok := config["hooks"].(map[string]any)
- if !ok {
- return false
- }
- changed := false
- for _, event := range hookEvents(host) {
- existing, listOK := hooks[event].([]any)
- if !listOK {
- continue
- }
- kept := []any{}
- for _, entry := range existing {
- if containsEngagementHook(entry) {
- changed = true
- continue
- }
- kept = append(kept, entry)
- }
- if len(kept) == 0 {
- delete(hooks, event)
- } else {
- hooks[event] = kept
- }
- }
- return changed
-}
-
-// InstallEngagementProbes merges the engagement probe into each agent's developer-level
-// config. It requires the repository to be attached in detached mode. It preserves
-// existing user hooks and is idempotent.
-func InstallEngagementProbes(repoPath string, hosts []string) (EngagementActivationResult, error) {
- root, err := ResolveRepository(repoPath)
- if err != nil {
- return blockedEngagementActivation(err.Error()), nil
- }
- _, ok, verifyErr := detachedContextFor(root)
- if verifyErr != nil {
- return blockedEngagementActivation(verifyErr.Error() + " Reattach before activating."), nil
- }
- if !ok {
- return blockedEngagementActivation("This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first."), nil
- }
- helper := detachedHelperPath(root)
- result := EngagementActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", Mode: string(SupervisionDetached), RepoRoot: root}
- for _, host := range defaultActivationHosts(hosts) {
- configPath, pathErr := userHostConfigPath(host)
- if pathErr != nil {
- continue
- }
- config, loadErr := loadHookConfig(configPath)
- if loadErr != nil {
- return blockedEngagementActivation(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil
- }
- before, _ := MarshalJSON(config)
- if err := mergeEngagementHooks(config, host, helper); err != nil {
- return blockedEngagementActivation(err.Error()), nil
- }
- after, marshalErr := MarshalJSON(config)
- if marshalErr != nil {
- return blockedEngagementActivation(marshalErr.Error()), nil
- }
- action := "unchanged"
- if string(before) != string(after) {
- if err := atomicWriteMode(configPath, after, 0o644); err != nil {
- return blockedEngagementActivation(err.Error()), nil
- }
- action = "installed"
- }
- result.Hosts = append(result.Hosts, EngagementHostResult{Host: host, ConfigPath: configPath, Action: action})
- }
- result.Reason = "Installed the Boatstack engagement probe. It applies policy only for a verified active delivery in the current worktree and branch."
- return result, nil
-}
-
-// RemoveEngagementProbes removes the engagement probe from each agent's developer-level
-// config, preserving every other entry.
-func RemoveEngagementProbes(repoPath string, hosts []string) (EngagementActivationResult, error) {
- root, err := ResolveRepository(repoPath)
- if err != nil {
- return blockedEngagementActivation(err.Error()), nil
- }
- result := EngagementActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", RepoRoot: root}
- for _, host := range defaultActivationHosts(hosts) {
- configPath, pathErr := userHostConfigPath(host)
- if pathErr != nil {
- continue
- }
- if !fileExists(configPath) {
- result.Hosts = append(result.Hosts, EngagementHostResult{Host: host, ConfigPath: configPath, Action: "unchanged"})
- continue
- }
- config, loadErr := loadHookConfig(configPath)
- if loadErr != nil {
- return blockedEngagementActivation(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil
- }
- action := "unchanged"
- if removeEngagementHooks(config, host) {
- after, marshalErr := MarshalJSON(config)
- if marshalErr != nil {
- return blockedEngagementActivation(marshalErr.Error()), nil
- }
- if err := atomicWriteMode(configPath, after, 0o644); err != nil {
- return blockedEngagementActivation(err.Error()), nil
- }
- action = "removed"
- }
- result.Hosts = append(result.Hosts, EngagementHostResult{Host: host, ConfigPath: configPath, Action: action})
- }
- result.Reason = "Removed the Boatstack engagement probe from your developer-level host configuration."
- return result, nil
-}
diff --git a/boatstack/agents/gemini.yaml b/boatstack/agents/gemini.yaml
index 66c81f4..1188802 100644
--- a/boatstack/agents/gemini.yaml
+++ b/boatstack/agents/gemini.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "Boatstack (Gemini)"
- short_description: "Build freely; approve, verify, review, and ship with evidence using Gemini CLI."
- default_prompt: "Use $boatstack as an evidence-engineered coding node: keep implementation tactics open and require evidence for approval, completion, review, and shipping."
+ short_description: "Use the shared Boatstack V2 delivery kernel from Gemini CLI."
+ default_prompt: "Use $boatstack V2. Read status once, follow the returned semantic transition, preserve exact goal and invocation identity, and send guarded commands to the shared kernel instead of reconstructing lifecycle or safety policy."
diff --git a/boatstack/agents/openai.yaml b/boatstack/agents/openai.yaml
index 4ff6ffb..cf9d442 100644
--- a/boatstack/agents/openai.yaml
+++ b/boatstack/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "Boatstack"
- short_description: "Build freely; approve, verify, review, and ship with evidence."
- default_prompt: "Use $boatstack as an evidence-engineered coding node: keep implementation tactics open and require evidence for approval, completion, review, and shipping."
+ short_description: "Use one V2 kernel for delivery state, authority, effects, and proof."
+ default_prompt: "Use $boatstack Autoplan, $boatstack Run, or $boatstack Update; bare $boatstack presents those three choices. Treat status as observation only, preserve one command-scoped authority context through next, apply, recover, and re-resolution, and never infer authority or completion from files, branches, authentication, or prior conversation."
diff --git a/boatstack/analysis/retrospective.go b/boatstack/analysis/retrospective.go
new file mode 100644
index 0000000..8641c6c
--- /dev/null
+++ b/boatstack/analysis/retrospective.go
@@ -0,0 +1,27 @@
+// Package analysis exposes passive, deterministic evidence analysis that never
+// participates in lifecycle authority or managed writes.
+package analysis
+
+import "github.com/operatorstack/boatstack/boatstack/internal/retromine"
+
+const (
+ FormatNeutral = retromine.FormatNeutral
+ FormatClaudeCode = retromine.FormatClaudeCode
+ FormatPlaintext = retromine.FormatPlaintext
+)
+
+type Event = retromine.Event
+type EventRef = retromine.EventRef
+type Cluster = retromine.Cluster
+type Proposal = retromine.Proposal
+type Report = retromine.Report
+
+// DeriveRetrospective performs a read-only transcript projection and returns
+// proposals for human review. It cannot admit or execute a kernel transition.
+func DeriveRetrospective(format, source string, content []byte) (Report, error) {
+ events, err := retromine.ParseTranscript(format, source, content)
+ if err != nil {
+ return Report{}, err
+ }
+ return retromine.BuildReport(events), nil
+}
diff --git a/boatstack/analysis/retrospective_test.go b/boatstack/analysis/retrospective_test.go
new file mode 100644
index 0000000..f3dbfa3
--- /dev/null
+++ b/boatstack/analysis/retrospective_test.go
@@ -0,0 +1,17 @@
+package analysis_test
+
+import (
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/analysis"
+)
+
+func TestRetrospectiveIsAReadOnlyPublicProjection(t *testing.T) {
+ report, err := analysis.DeriveRetrospective(analysis.FormatPlaintext, "fixture", []byte("User: always run the exact test\nUser: always run the exact test\nUser: always run the exact test\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.SchemaVersion == 0 {
+ t.Fatalf("invalid retrospective report: %#v", report)
+ }
+}
diff --git a/boatstack/assets/templates/adr.md b/boatstack/assets/templates/adr.md
deleted file mode 100644
index 8391efb..0000000
--- a/boatstack/assets/templates/adr.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# ADR :
-
-- Status: proposed
-- Date:
-- Supersedes:
-
-## Context and forces
-
-## Decision
-
-## Alternatives considered
-
-## Consequences and risks
-
-## Verification
-
-## Supersession rule
diff --git a/boatstack/assets/templates/approval.md b/boatstack/assets/templates/approval.md
deleted file mode 100644
index 7f5a946..0000000
--- a/boatstack/assets/templates/approval.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Plan approval:
-
-This receipt may be created only after the named human explicitly approves the exact fingerprint shown by `boatstack-helper check-plan`.
-
-
-```json
-{
- "schema_version": 3,
- "status": "APPROVED",
- "approved_by": "",
- "approved_at": "",
- "approval_fingerprint": "",
- "baseline_diff_sha256": "",
- "baseline_changed_paths": [],
- "readiness_fingerprint": "",
- "base_branch": "",
- "head_branch": "",
- "base_commit": "",
- "head_commit": "",
- "upstream": "",
- "upstream_relation": "",
- "journey_manifest_sha256": ""
-}
-```
-
diff --git a/boatstack/assets/templates/evidence.md b/boatstack/assets/templates/evidence.md
deleted file mode 100644
index b5c2308..0000000
--- a/boatstack/assets/templates/evidence.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Evidence ledger:
-
-- Canonical loop version:
-- Config hash:
-- Approved plan hash:
-- Plan approver:
-- Implementation commit:
-- Gate status: `BLOCKED`
-
-## Acceptance evidence
-
-| Acceptance criterion | Evidence | Result |
-|---|---|---|
-
-## Commands and checks
-
-| Check | Command | Result | Evidence/link |
-|---|---|---|---|
-
-## Review findings
-
-## Known gaps
-
-## Rollout and rollback
diff --git a/boatstack/assets/templates/feature-spec.md b/boatstack/assets/templates/feature-spec.md
deleted file mode 100644
index 5078443..0000000
--- a/boatstack/assets/templates/feature-spec.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Feature spec:
-
-## Outcome boundary
-
-- Domain:
-- Actor:
-- Input:
-- Output:
-- User-visible goal:
-- Next operator:
-- Verification boundary:
-
-## Problem and outcome
-
-## Non-goals
-
-## Scenarios
-
-## Acceptance criteria
-
-## Interfaces and data
-
-## Invariants and trust boundaries
-
-## Failure and recovery behavior
-
-## Observability
-
-## Rollout and rollback
-
-## Linked questions, ADRs, and gaps
diff --git a/boatstack/assets/templates/gaps.md b/boatstack/assets/templates/gaps.md
deleted file mode 100644
index a8ed5a7..0000000
--- a/boatstack/assets/templates/gaps.md
+++ /dev/null
@@ -1,4 +0,0 @@
-# Gap ledger:
-
-| ID | Expected | Actual | Impact/severity | Why deferred | Owner | Revisit trigger | Acceptance criteria | Ship blocking? |
-|---|---|---|---|---|---|---|---|---|
diff --git a/boatstack/assets/templates/move.md b/boatstack/assets/templates/move.md
deleted file mode 100644
index 7a42960..0000000
--- a/boatstack/assets/templates/move.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Loop move:
-
-- Target failure:
-- Population:
-- Mechanism:
-- Minimal change:
-- Expected effect:
-- Cost:
-- Regression risks:
-- Smoke test:
-- Paired evaluation:
-- Holdout:
-- Rollback/default:
-- Decision: `PROPOSED`
-
-## Evidence
-
-## Result and interpretation
diff --git a/boatstack/assets/templates/plan-lock.json b/boatstack/assets/templates/plan-lock.json
deleted file mode 100644
index 20375e0..0000000
--- a/boatstack/assets/templates/plan-lock.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "schema_version": 3,
- "status": "LOCKED",
- "authorization_mode": "human",
- "activated_at": "",
- "approved_by": "",
- "approved_at": "",
- "source_commit": "",
- "source_plan_path": "source-plan.md",
- "source_plan_sha256": "",
- "spec_path": "feature-spec.md",
- "spec_sha256": "",
- "plan_path": "plan.md",
- "plan_sha256": "",
- "task_graph_path": "compiled/tasks.json",
- "task_graph_sha256": "",
- "baseline_diff_sha256": "",
- "baseline_changed_paths": [],
- "readiness_fingerprint": "",
- "readiness_plan_fingerprint": "",
- "base_branch": "",
- "head_branch": "",
- "base_commit": "",
- "head_commit": "",
- "upstream": "",
- "upstream_relation": "",
- "journey_manifest_sha256": "",
- "invalidated_at": null,
- "invalidation_reason": null
-}
diff --git a/boatstack/assets/templates/plan.md b/boatstack/assets/templates/plan.md
deleted file mode 100644
index ebb820e..0000000
--- a/boatstack/assets/templates/plan.md
+++ /dev/null
@@ -1,115 +0,0 @@
-# Structured plan:
-
-- This Markdown file is the canonical plan.
-- Prose and structured data are both covered by the approval fingerprint.
-- Approval state is recorded separately in `approval.md`; never edit this file merely to mark it approved.
-
-## Human-readable summary
-
-
-
-## Structured plan
-
-
-```json
-{
- "schema_version": 3,
- "feature_id": "",
- "source_plan_path": "source-plan.md",
- "spec_path": "feature-spec.md",
- "blocking_questions": [],
- "autonomy_decisions": [],
- "acceptance_criteria": [
- {
- "id": "AC-1",
- "text": ""
- }
- ],
- "architecture_facts": [
- {
- "id": "fact_example_route_absent",
- "kind": "route_absent",
- "subject": "/api/clients",
- "evidence_ids": ["ev_001"],
- "summary": "No /api/clients route was found in the repository."
- }
- ],
- "architecture_unknowns": [
- {
- "id": "unknown_example",
- "question": "How are chat responses delivered to the browser?",
- "blocks": ["T-1"]
- }
- ],
- "pr_visual_evidence": {
- "relevance": "not_relevant",
- "reason": "",
- "scenarios": []
- },
- "journey_evidence": {
- "relevance": "not_relevant",
- "reason": "",
- "oracles": []
- },
- "tasks": [
- {
- "id": "T-1",
- "title": "",
- "depends_on": [],
- "requires_facts": ["fact_example_route_absent"],
- "acceptance_criteria": ["AC-1"],
- "affected_paths": [""],
- "side_effects": [],
- "validation": [
- {
- "criteria": ["AC-1"],
- "run": "",
- "origin": "",
- "oracle": "",
- "independence": ""
- }
- ],
- "rollback_boundary": ""
- }
- ],
- "delivery_slices": [
- {
- "id": "delivery",
- "title": "",
- "task_ids": ["T-1"],
- "base_branch": "",
- "head_branch": ""
- }
- ]
-}
-```
-
-
-Keep `autonomy_decisions` empty for the normal human-driven flow. An explicit
-goal-driven run may add a decision only with `resolution: RESOLVED_BY_POLICY`,
-one selected recommended option, `material: false`, `within_spec: true`,
-`reversible: true`, every protected `impact` flag false, repository
-`evidence_ids`, a rationale, and a runnable `verification` with an independent
-oracle.
-
-For an external write, replace the empty `side_effects` list with entries such as:
-
-```json
-{
- "kind": "database-write",
- "target": "",
- "reversibility": "transactional",
- "failure_policy": "rollback-transaction",
- "destructive": false
-}
-```
-
-Boatstack rejects ambiguous targets, automated resets, and destructive rollback. Use
-`stop-and-fix-forward` when a transaction cannot contain the full operation.
-
-`delivery_slices` is the only place a plan may declare multiple PR-sized phases.
-Every task belongs to exactly one delivery slice. Each slice receives its own
-build, test, review, ship confirmation, and PR; plan approval never authorizes
-publication by itself. Internal implementation phases should remain ordinary tasks
-inside one delivery slice. When `workflow.maintain_changelog` is enabled, include
-`CHANGELOG.md` in the affected paths of tasks assigned to every delivery slice.
diff --git a/boatstack/assets/templates/questions.md b/boatstack/assets/templates/questions.md
deleted file mode 100644
index aa75f6f..0000000
--- a/boatstack/assets/templates/questions.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Question ledger:
-
-| ID | Question | Why it matters | Options | Recommendation | Answer | Source | Status/expiry |
-|---|---|---|---|---|---|---|---|
-
-Use `ANSWERED` only for an explicit human answer or an authoritative existing contract. In an explicit goal-driven run, use `RESOLVED_BY_POLICY` only when the structured autonomy envelope proves the choice non-material, within-spec, reversible, free of protected impacts, evidence-backed, and independently verifiable. Repository inference is otherwise `PROPOSED`. Material unanswered questions remain `OPEN`, appear in `plan.md` as `blocking_questions`, and block activation.
-
-When presenting finite questions, give every choice a compact inline-code key (`1a`, `1b`, `1c`, then `2a`, `2b`, and so on) and suffix exactly one choice per question with `(Recommended)`. End with one reply hint: name the keys for explicit selection, or use `r` to accept all displayed recommendations. A standalone `r` is `ANSWERED` human provenance only after the selected question-to-answer mapping is echoed; it is never an agent-selected default.
diff --git a/boatstack/assets/templates/test-plan.md b/boatstack/assets/templates/test-plan.md
deleted file mode 100644
index 86db3a4..0000000
--- a/boatstack/assets/templates/test-plan.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Test plan:
-
-| Requirement/risk | Check | Oracle/source | Command or procedure | Result/evidence | Owner |
-|---|---|---|---|---|---|
-
-## Required regression checks
-
-## Independent verification
-
-## Skipped checks and accepted risk
diff --git a/boatstack/atomic_unix.go b/boatstack/atomic_unix.go
deleted file mode 100644
index 5bdefac..0000000
--- a/boatstack/atomic_unix.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !windows
-
-package boatstack
-
-import "os"
-
-func replaceFile(source, destination string) error {
- return os.Rename(source, destination)
-}
diff --git a/boatstack/attach.go b/boatstack/attach.go
deleted file mode 100644
index df14e9f..0000000
--- a/boatstack/attach.go
+++ /dev/null
@@ -1,356 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-// Attach, detach, and status operations for Detached Supervision. Attaching a
-// repository writes Boatstack's controller state to an external control root and a
-// binding that identifies the repository; it never writes into the target working
-// tree or its Git directory. Detaching removes the attachment (and, unless asked
-// to preserve it, the external state). Status reports the binding and verifies it.
-
-// AttachOptions requests a detached attachment. StateRoot, when set, overrides the
-// external control-state root for this process (the CLI wires --state-root to it).
-type AttachOptions struct {
- Repo string
- ConfigPath string
- // BinaryPath is the already verified helper to install into detached
- // controller state. The CLI leaves it empty and uses its running binary;
- // tests and embedders may bind an equivalent verified helper explicitly.
- BinaryPath string
- Force bool
-}
-
-// AttachResult is the deterministic outcome of an attach request.
-type AttachResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
- Mode string `json:"mode,omitempty"`
- RepoID string `json:"repo_id,omitempty"`
- RepoRoot string `json:"repo_root,omitempty"`
- ControlRoot string `json:"control_root,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- ConfigSHA256 string `json:"config_sha256,omitempty"`
- ConfigAuthority string `json:"config_authority,omitempty"`
- Reason string `json:"reason"`
- FeatureMigrations []DetachedFeatureMigration `json:"feature_migrations,omitempty"`
-}
-
-func loadDetachedAttachConfig(root, explicitPath string) (ProjectConfig, []byte, error) {
- if strings.TrimSpace(explicitPath) == "" {
- configPath := filepath.Join(root, sourceConfigName)
- config, raw, err := LoadConfig(configPath)
- if os.IsNotExist(err) {
- config = defaultConfig(root, detectTestCommand(root))
- raw, err = MarshalJSON(config)
- }
- return config, raw, err
- }
- absolute, err := filepath.Abs(explicitPath)
- if err != nil {
- return ProjectConfig{}, nil, err
- }
- inputInfo, err := os.Lstat(absolute)
- if err != nil {
- return ProjectConfig{}, nil, fmt.Errorf("external project configuration is missing or unreadable: %w", err)
- }
- if inputInfo.Mode()&os.ModeSymlink != 0 {
- return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be a regular non-symlink file")
- }
- resolved, err := filepath.EvalSymlinks(absolute)
- if err != nil {
- return ProjectConfig{}, nil, fmt.Errorf("external project configuration is missing or unreadable: %w", err)
- }
- info, err := os.Lstat(resolved)
- if err != nil || !info.Mode().IsRegular() {
- return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be a readable regular file")
- }
- common, err := gitCommonDir(root)
- if err != nil {
- return ProjectConfig{}, nil, err
- }
- if pathWithin(root, resolved) || pathWithin(common, resolved) {
- return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be outside the repository and its Git directory")
- }
- return LoadConfig(resolved)
-}
-
-func blockedAttach(reason string) AttachResult {
- return AttachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason}
-}
-
-// AttachDetached attaches repo in detached mode. It leaves the repository working
-// tree and Git directory byte-for-byte unchanged; all controller state is written
-// under the external control root.
-func AttachDetached(opts AttachOptions) (AttachResult, error) {
- root, err := ResolveRepository(opts.Repo)
- if err != nil {
- return blockedAttach(err.Error()), nil
- }
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return blockedAttach(err.Error()), nil
- }
- identity, err := repoIdentity(root)
- if err != nil {
- return blockedAttach("Boatstack could not compute a repository identity: " + err.Error()), nil
- }
-
- registry, err := loadRegistry(stateRoot)
- if err != nil {
- return blockedAttach("Boatstack could not read the attachment registry: " + err.Error()), nil
- }
- if existing, ok := registry.Repositories[root]; ok && !opts.Force {
- return blockedAttach(fmt.Sprintf("This repository is already attached (repo_id %s). Detach first, or re-run with --force.", existing)), nil
- }
-
- ctx := detachedContextFromIdentity(stateRoot, identity)
-
- config, rawConfig, err := loadDetachedAttachConfig(root, opts.ConfigPath)
- if err != nil {
- return blockedAttach("Boatstack could not load the detached project configuration: " + err.Error()), nil
- }
- configSHA256 := SHA256Bytes(rawConfig)
- configAuthority := ConfigAuthorityExternalSnapshot
- if strings.TrimSpace(opts.ConfigPath) == "" {
- configAuthority = ConfigAuthoritySynthesized
- if fileExists(filepath.Join(root, sourceConfigName)) {
- configAuthority = ConfigAuthorityRepository
- }
- }
- imports, migrationResults, migrationErr := planDetachedFeatureImports(root, ctx)
- if migrationErr != nil {
- result := blockedAttach("Boatstack refused detached feature migration: " + migrationErr.Error())
- result.FeatureMigrations = migrationResults
- return result, nil
- }
-
- // Generate the controller bundle and write it under the external control root.
- // The bundle layout mirrors embedded (.product-loop/** plus host adapter dirs),
- // only relocated outside the repository.
- bundle, err := BuildExportBundle(ctx.SourceConfigPath(), config, rawConfig, "boatstack")
- if err != nil {
- return blockedAttach("Boatstack could not build the controller bundle: " + err.Error()), nil
- }
- if err := os.MkdirAll(ctx.controlRoot, 0o755); err != nil {
- return blockedAttach(err.Error()), nil
- }
- if err := writeExport(ctx.controlRoot, bundle.Files, nil); err != nil {
- return blockedAttach("Boatstack could not write the controller bundle: " + err.Error()), nil
- }
- sourcePath, err := newControllerPath(ctx.controlRoot, ctx.SourceConfigPath())
- if err != nil {
- return blockedAttach(err.Error()), nil
- }
- if err := atomicWrite(sourcePath.path, rawConfig); err != nil {
- return blockedAttach(err.Error()), nil
- }
- migrationResults, err = applyDetachedFeatureImports(imports, migrationResults)
- if err != nil {
- return blockedAttach("Boatstack could not import embedded feature state: " + err.Error()), nil
- }
-
- // Write the binding and index it in the registry.
- binding := DetachedBinding{
- SchemaVersion: detachedSchemaVersion,
- Mode: string(SupervisionDetached),
- RepoID: identity.RepoID,
- CanonicalRepoPath: identity.CanonicalRepoPath,
- GitCommonIdentity: identity.GitCommonIdentity,
- InitialCommit: identity.InitialCommit,
- NormalizedOrigin: identity.NormalizedOrigin,
- ConfigSHA256: configSHA256,
- ConfigAuthority: configAuthority,
- CreatedByVersion: Version,
- CreatedAt: nowRFC3339(),
- }
- bindingRaw, err := MarshalJSON(binding)
- if err != nil {
- return blockedAttach(err.Error()), nil
- }
- if err := os.MkdirAll(filepath.Dir(bindingPath(stateRoot, identity.RepoID)), 0o755); err != nil {
- return blockedAttach(err.Error()), nil
- }
- if err := atomicWrite(bindingPath(stateRoot, identity.RepoID), bindingRaw); err != nil {
- return blockedAttach(err.Error()), nil
- }
- registry.Repositories[root] = identity.RepoID
- if err := saveRegistry(stateRoot, registry); err != nil {
- return blockedAttach(err.Error()), nil
- }
- invalidateWorkspaceCache()
-
- // Populate the external shared-runtime slot from the running helper so the
- // developer-level engagement probe has a stable helper to invoke. The binding is
- // written above, so WorkspaceFor now resolves detached and the slot is external.
- source := strings.TrimSpace(opts.BinaryPath)
- if source == "" {
- source, err = os.Executable()
- if err != nil {
- return blockedAttach("Boatstack could not locate its running helper: " + err.Error()), nil
- }
- }
- if _, runtimeErr := installDetachedRuntime(root, source); runtimeErr != nil {
- return blockedAttach("Boatstack could not install the external runtime: " + runtimeErr.Error()), nil
- }
- if _, _, runtimeErr := installControllerLocalRuntime(ctx.ExportRoot(), source, config.Integrations); runtimeErr != nil {
- return blockedAttach("Boatstack could not install the detached controller helper: " + runtimeErr.Error()), nil
- }
-
- return AttachResult{
- SchemaVersion: detachedSchemaVersion,
- VerificationStatus: "VERIFIED",
- Mode: string(SupervisionDetached),
- RepoID: identity.RepoID,
- RepoRoot: root,
- ControlRoot: ctx.controlRoot,
- WorktreeID: identity.WorktreeID,
- ConfigSHA256: configSHA256,
- ConfigAuthority: configAuthority,
- FeatureMigrations: migrationResults,
- Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.",
- }, nil
-}
-
-// DetachOptions requests removal of a detached attachment.
-type DetachOptions struct {
- Repo string
- PreserveState bool
-}
-
-// DetachResult is the deterministic outcome of a detach request.
-type DetachResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
- RepoID string `json:"repo_id,omitempty"`
- StateRemoved bool `json:"state_removed"`
- Reason string `json:"reason"`
-}
-
-// DetachDetached removes a repository's detached attachment. It always removes the
-// registry entry; it removes the external controller state only when PreserveState
-// is false. It never touches the repository itself.
-func DetachDetached(opts DetachOptions) (DetachResult, error) {
- root, err := ResolveRepository(opts.Repo)
- if err != nil {
- return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
- }
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
- }
- registry, err := loadRegistry(stateRoot)
- if err != nil {
- return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
- }
- repoID, ok := registry.Repositories[root]
- if !ok {
- return DetachResult{
- SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED",
- Reason: "This repository is not attached in detached mode; nothing to detach.",
- }, nil
- }
- delete(registry.Repositories, root)
- if err := saveRegistry(stateRoot, registry); err != nil {
- return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil
- }
- stateRemoved := false
- if !opts.PreserveState {
- if err := os.RemoveAll(repositoryControlRoot(stateRoot, repoID)); err != nil {
- return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil
- }
- stateRemoved = true
- }
- invalidateWorkspaceCache()
- reason := "Detached Boatstack. The external controller state was removed."
- if opts.PreserveState {
- reason = "Detached Boatstack. The external controller state was preserved."
- }
- return DetachResult{
- SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED",
- RepoID: repoID, StateRemoved: stateRemoved, Reason: reason,
- }, nil
-}
-
-// DetachedStatusResult reports whether a repository is attached in detached mode
-// and whether its binding verifies.
-type DetachedStatusResult struct {
- SchemaVersion int `json:"schema_version"`
- Attached bool `json:"attached"`
- Verified bool `json:"verified"`
- Mode string `json:"mode"`
- RepoID string `json:"repo_id,omitempty"`
- RepoRoot string `json:"repo_root,omitempty"`
- ControlRoot string `json:"control_root,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- ConfigSHA256 string `json:"config_sha256,omitempty"`
- ConfigAuthority string `json:"config_authority,omitempty"`
- ConfigRelation string `json:"config_relation,omitempty"`
- RepositoryConfigSHA256 string `json:"repository_config_sha256,omitempty"`
- ControllerConfigSHA256 string `json:"controller_config_sha256,omitempty"`
- AffectedWorktrees []string `json:"affected_worktrees,omitempty"`
- NextOperation string `json:"next_operation,omitempty"`
- Reason string `json:"reason"`
-}
-
-// DetachedStatus reports the detached attachment state for a repository. It is
-// read-only.
-func DetachedStatus(repoPath string) (DetachedStatusResult, error) {
- root, err := ResolveRepository(repoPath)
- if err != nil {
- return DetachedStatusResult{SchemaVersion: detachedSchemaVersion, Reason: err.Error()}, nil
- }
- ctx, ok, verifyErr := detachedContextFor(root)
- if !ok {
- return DetachedStatusResult{
- SchemaVersion: detachedSchemaVersion, Attached: false, Mode: string(SupervisionEmbedded),
- RepoRoot: root, Reason: "This repository is not attached in detached mode.",
- }, nil
- }
- if verifyErr != nil {
- configSHA256 := ""
- stateRoot, rootErr := detachedStateRoot()
- if rootErr == nil {
- registry, registryErr := loadRegistry(stateRoot)
- if registryErr == nil {
- if binding, bindingErr := loadBinding(stateRoot, registry.Repositories[root]); bindingErr == nil {
- configSHA256 = binding.ConfigSHA256
- }
- }
- }
- return DetachedStatusResult{
- SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached),
- RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID,
- ConfigSHA256: configSHA256, ControllerConfigSHA256: configSHA256, Reason: verifyErr.Error(),
- }, nil
- }
- topology, topologyErr := ResolveConfigurationTopology(root)
- if topologyErr != nil {
- return DetachedStatusResult{SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached), RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, Reason: topologyErr.Error()}, nil
- }
- return DetachedStatusResult{
- SchemaVersion: detachedSchemaVersion, Attached: true, Verified: true, Mode: string(SupervisionDetached),
- RepoID: ctx.RepoID, RepoRoot: ctx.RepoRoot, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID,
- ConfigSHA256: topology.ControllerConfigSHA256, ConfigAuthority: topology.Authority,
- ConfigRelation: topology.Relation, RepositoryConfigSHA256: topology.RepositoryConfigSHA256,
- ControllerConfigSHA256: topology.ControllerConfigSHA256, AffectedWorktrees: topology.AffectedWorktrees,
- NextOperation: topology.NextOperation,
- Reason: "This repository is attached in detached mode and its binding verifies.",
- }, nil
-}
-
-func bindingConfigSHA256(ctx WorkspaceContext) string {
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return ""
- }
- binding, err := loadBinding(stateRoot, ctx.RepoID)
- if err != nil {
- return ""
- }
- return binding.ConfigSHA256
-}
diff --git a/boatstack/authority.go b/boatstack/authority.go
deleted file mode 100644
index 2b931b7..0000000
--- a/boatstack/authority.go
+++ /dev/null
@@ -1,246 +0,0 @@
-package boatstack
-
-import (
- "crypto/ed25519"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "reflect"
- "strings"
- "time"
-)
-
-const (
- AuthorityHookGuarded = "HOOK_GUARDED"
- AuthorityCredentialEnforced = "CREDENTIAL_ENFORCED"
- AuthorityClassRepositoryOnly = "repository-only"
- AuthorityReceiptEnv = "BOATSTACK_AUTHORITY_RECEIPT"
- AuthorityHostSessionEnv = "BOATSTACK_HOST_SESSION"
- AuthorityPrincipalEnv = "BOATSTACK_PRINCIPAL_FINGERPRINT"
- maxAuthorityReceiptLifetime = 15 * time.Minute
-)
-
-var (
- authorityNow = time.Now
- authorityTrustStoreProtected = protectedExternalTrustStore
-)
-
-// ExternalAuthorityPolicy chooses whether a managed run relies on hook-only
-// interception or requires an independently signed, credential-enforced boundary.
-// Boatstack stores only public verification keys and never signs its own receipt.
-type ExternalAuthorityPolicy struct {
- Mode string `json:"mode,omitempty"` // "" | "hook-only" | "credential-enforced"
- TrustStore string `json:"trust_store,omitempty"`
-}
-
-type externalAuthorityTrustStore struct {
- SchemaVersion int `json:"schema_version"`
- Issuers map[string]string `json:"issuers"`
-}
-
-// AuthorityBoundaryReceipt is supplied by an external authority such as service
-// IAM, a credential broker, or an isolated host. Signature covers every field
-// except Signature using AuthorityReceiptSigningBytes.
-type AuthorityBoundaryReceipt struct {
- SchemaVersion int `json:"schema_version"`
- RepositoryFingerprint string `json:"repository_fingerprint"`
- WorktreeFingerprint string `json:"worktree_fingerprint"`
- HostSession string `json:"host_session"`
- PrincipalFingerprint string `json:"principal_fingerprint"`
- AuthorityClass string `json:"authority_class"`
- CloudControlPlaneAuthority bool `json:"cloud_control_plane_authority"`
- EnforcedBy string `json:"enforced_by"`
- Issuer string `json:"issuer"`
- IssuedAt string `json:"issued_at"`
- ExpiresAt string `json:"expires_at"`
- Signature string `json:"signature"`
-}
-
-type AuthorityContext struct {
- SchemaVersion int `json:"schema_version"`
- RepositoryFingerprint string `json:"repository_fingerprint"`
- WorktreeFingerprint string `json:"worktree_fingerprint"`
-}
-
-// AuthorityReceiptSigningBytes is the stable external-attestor wire contract.
-func AuthorityReceiptSigningBytes(receipt AuthorityBoundaryReceipt) ([]byte, error) {
- receipt.Signature = ""
- return json.Marshal(receipt)
-}
-
-func ResolveAuthorityContext(repoInput string) (AuthorityContext, error) {
- repo, err := ResolveRepository(repoInput)
- if err != nil {
- return AuthorityContext{}, err
- }
- common, err := gitCommonDir(repo)
- if err != nil {
- return AuthorityContext{}, err
- }
- repoPath, err := filepath.Abs(repo)
- if err != nil {
- return AuthorityContext{}, err
- }
- commonPath, err := filepath.Abs(common)
- if err != nil {
- return AuthorityContext{}, err
- }
- return AuthorityContext{
- SchemaVersion: 1,
- RepositoryFingerprint: SHA256Bytes([]byte(filepath.Clean(commonPath))),
- WorktreeFingerprint: SHA256Bytes([]byte(filepath.Clean(repoPath))),
- }, nil
-}
-
-func normalizedAuthorityMode(policy *ExternalAuthorityPolicy) string {
- if policy == nil || strings.TrimSpace(policy.Mode) == "" {
- return "hook-only"
- }
- return strings.TrimSpace(policy.Mode)
-}
-
-func validateExternalAuthorityPolicy(policy *ExternalAuthorityPolicy) error {
- mode := normalizedAuthorityMode(policy)
- if mode != "hook-only" && mode != "credential-enforced" {
- return fmt.Errorf("workflow.external_authority.mode must be \"hook-only\" or \"credential-enforced\"")
- }
- if mode == "credential-enforced" && (policy == nil || !filepath.IsAbs(strings.TrimSpace(policy.TrustStore))) {
- return fmt.Errorf("workflow.external_authority.trust_store must be an absolute external path for credential-enforced mode")
- }
- return nil
-}
-
-func ownerID(info os.FileInfo) (uint64, bool) {
- value := reflect.ValueOf(info.Sys())
- if !value.IsValid() {
- return 0, false
- }
- if value.Kind() == reflect.Pointer {
- value = value.Elem()
- }
- if !value.IsValid() || value.Kind() != reflect.Struct {
- return 0, false
- }
- uid := value.FieldByName("Uid")
- if !uid.IsValid() || !uid.CanUint() {
- return 0, false
- }
- return uid.Uint(), true
-}
-
-// protectedExternalTrustStore refuses trust roots the managed principal can
-// replace. Production strict mode therefore requires an operator-provisioned
-// file outside the repository under non-writable parent directories.
-func protectedExternalTrustStore(path string) error {
- path = filepath.Clean(path)
- for current := path; ; current = filepath.Dir(current) {
- info, err := os.Lstat(current)
- if err != nil || info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("external authority trust store path is missing or contains a symlink")
- }
- if current == path && !info.Mode().IsRegular() {
- return fmt.Errorf("external authority trust store is not a regular file")
- }
- if info.Mode().Perm()&0o022 != 0 {
- return fmt.Errorf("external authority trust store path is group- or world-writable")
- }
- uid, ok := ownerID(info)
- if !ok || uid == uint64(os.Geteuid()) {
- return fmt.Errorf("external authority trust store path is owned by the managed principal")
- }
- parent := filepath.Dir(current)
- if parent == current {
- break
- }
- }
- return nil
-}
-
-func loadExternalTrustStore(policy *ExternalAuthorityPolicy) (map[string]string, error) {
- path := filepath.Clean(policy.TrustStore)
- if err := authorityTrustStoreProtected(path); err != nil {
- return nil, err
- }
- info, err := os.Lstat(path)
- if err != nil || info.Size() > 64*1024 {
- return nil, fmt.Errorf("external authority trust store is missing or too large")
- }
- raw, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- var store externalAuthorityTrustStore
- if err := DecodeJSON("load external authority trust store", path, raw, &store); err != nil || store.SchemaVersion != 1 || len(store.Issuers) == 0 {
- return nil, fmt.Errorf("external authority trust store is malformed")
- }
- for issuer, encoded := range store.Issuers {
- key, err := base64.StdEncoding.DecodeString(encoded)
- if strings.TrimSpace(issuer) == "" || err != nil || len(key) != ed25519.PublicKeySize {
- return nil, fmt.Errorf("external authority trust store contains an invalid issuer")
- }
- }
- return store.Issuers, nil
-}
-
-func verifyAuthorityBoundary(repo string, policy *ExternalAuthorityPolicy) (string, string) {
- if normalizedAuthorityMode(policy) != "credential-enforced" {
- return AuthorityHookGuarded, "Boatstack hooks guard known irreversible operations; cloud authority is not externally attested."
- }
- trustedIssuers, trustErr := loadExternalTrustStore(policy)
- if trustErr != nil {
- return AuthorityHookGuarded, "external authority trust store is not protected or valid"
- }
- path := strings.TrimSpace(os.Getenv(AuthorityReceiptEnv))
- if path == "" || !filepath.IsAbs(path) {
- return AuthorityHookGuarded, "credential-enforced mode requires an absolute external authority receipt path"
- }
- info, err := os.Lstat(path)
- if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 64*1024 {
- return AuthorityHookGuarded, "external authority receipt is missing, unsafe, or too large"
- }
- raw, err := os.ReadFile(path)
- if err != nil {
- return AuthorityHookGuarded, "external authority receipt could not be read"
- }
- var receipt AuthorityBoundaryReceipt
- if err := DecodeJSON("load external authority receipt", path, raw, &receipt); err != nil {
- return AuthorityHookGuarded, "external authority receipt is malformed"
- }
- if receipt.SchemaVersion != 1 || receipt.AuthorityClass != AuthorityClassRepositoryOnly || receipt.CloudControlPlaneAuthority {
- return AuthorityHookGuarded, "external authority receipt does not attest repository-only authority"
- }
- switch receipt.EnforcedBy {
- case "service-iam", "credential-broker", "isolated-host":
- default:
- return AuthorityHookGuarded, "external authority receipt names an unsupported enforcement boundary"
- }
- context, err := ResolveAuthorityContext(repo)
- if err != nil || receipt.RepositoryFingerprint != context.RepositoryFingerprint || receipt.WorktreeFingerprint != context.WorktreeFingerprint {
- return AuthorityHookGuarded, "external authority receipt is bound to a different repository or worktree"
- }
- if receipt.HostSession == "" || receipt.HostSession != strings.TrimSpace(os.Getenv(AuthorityHostSessionEnv)) {
- return AuthorityHookGuarded, "external authority receipt is bound to a different host session"
- }
- if receipt.PrincipalFingerprint == "" || receipt.PrincipalFingerprint != strings.TrimSpace(os.Getenv(AuthorityPrincipalEnv)) {
- return AuthorityHookGuarded, "external authority receipt is bound to a different principal"
- }
- issued, issuedErr := time.Parse(time.RFC3339, receipt.IssuedAt)
- expires, expiresErr := time.Parse(time.RFC3339, receipt.ExpiresAt)
- now := authorityNow().UTC()
- if issuedErr != nil || expiresErr != nil || expires.Before(now) || issued.After(now.Add(time.Minute)) || !expires.After(issued) || expires.Sub(issued) > maxAuthorityReceiptLifetime {
- return AuthorityHookGuarded, "external authority receipt is stale or has an invalid lifetime"
- }
- encodedKey, ok := trustedIssuers[receipt.Issuer]
- if !ok {
- return AuthorityHookGuarded, "external authority receipt issuer is not trusted"
- }
- publicKey, keyErr := base64.StdEncoding.DecodeString(encodedKey)
- signature, signatureErr := base64.StdEncoding.DecodeString(receipt.Signature)
- payload, payloadErr := AuthorityReceiptSigningBytes(receipt)
- if keyErr != nil || signatureErr != nil || payloadErr != nil || len(publicKey) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(publicKey), payload, signature) {
- return AuthorityHookGuarded, "external authority receipt signature is invalid"
- }
- return AuthorityCredentialEnforced, "An external authority attests repository-only credentials for this repository, worktree, host session, and principal."
-}
diff --git a/boatstack/authority_test.go b/boatstack/authority_test.go
deleted file mode 100644
index 926757d..0000000
--- a/boatstack/authority_test.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package boatstack
-
-import (
- "crypto/ed25519"
- "crypto/rand"
- "encoding/base64"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func authorityFixture(t *testing.T, repo string) (ExternalAuthorityPolicy, AuthorityBoundaryReceipt, ed25519.PrivateKey) {
- t.Helper()
- publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- context, err := ResolveAuthorityContext(repo)
- if err != nil {
- t.Fatal(err)
- }
- now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
- authorityNow = func() time.Time { return now }
- t.Cleanup(func() { authorityNow = time.Now })
- t.Setenv(AuthorityHostSessionEnv, "session-123")
- t.Setenv(AuthorityPrincipalEnv, "principal-sha256")
- storeValue, err := MarshalJSON(externalAuthorityTrustStore{SchemaVersion: 1, Issuers: map[string]string{"broker": base64.StdEncoding.EncodeToString(publicKey)}})
- if err != nil {
- t.Fatal(err)
- }
- trustStore := filepath.Join(t.TempDir(), "authority-trust-store.json")
- if err := os.WriteFile(trustStore, storeValue, 0o600); err != nil {
- t.Fatal(err)
- }
- authorityTrustStoreProtected = func(string) error { return nil }
- t.Cleanup(func() { authorityTrustStoreProtected = protectedExternalTrustStore })
- policy := ExternalAuthorityPolicy{Mode: "credential-enforced", TrustStore: trustStore}
- receipt := AuthorityBoundaryReceipt{
- SchemaVersion: 1, RepositoryFingerprint: context.RepositoryFingerprint,
- WorktreeFingerprint: context.WorktreeFingerprint, HostSession: "session-123",
- PrincipalFingerprint: "principal-sha256", AuthorityClass: AuthorityClassRepositoryOnly,
- CloudControlPlaneAuthority: false, EnforcedBy: "credential-broker", Issuer: "broker",
- IssuedAt: now.Add(-time.Minute).Format(time.RFC3339), ExpiresAt: now.Add(10 * time.Minute).Format(time.RFC3339),
- }
- return policy, receipt, privateKey
-}
-
-// control-law: categorical-authority-requires-external-attestation
-func TestCredentialEnforcedAuthorityRejectsPrincipalOwnedTrustStore(t *testing.T) {
- publicKey, _, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- value, err := MarshalJSON(externalAuthorityTrustStore{SchemaVersion: 1, Issuers: map[string]string{"self": base64.StdEncoding.EncodeToString(publicKey)}})
- if err != nil {
- t.Fatal(err)
- }
- path := filepath.Join(t.TempDir(), "self-authored-trust-store.json")
- if err := os.WriteFile(path, value, 0o600); err != nil {
- t.Fatal(err)
- }
- status, reason := verifyAuthorityBoundary(runTestRepo(t), &ExternalAuthorityPolicy{Mode: "credential-enforced", TrustStore: path})
- if status != AuthorityHookGuarded || !strings.Contains(reason, "trust store") {
- t.Fatalf("principal-owned trust store was accepted: %s: %s", status, reason)
- }
-}
-
-func signAuthorityReceipt(t *testing.T, receipt AuthorityBoundaryReceipt, privateKey ed25519.PrivateKey) AuthorityBoundaryReceipt {
- t.Helper()
- payload, err := AuthorityReceiptSigningBytes(receipt)
- if err != nil {
- t.Fatal(err)
- }
- receipt.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, payload))
- return receipt
-}
-
-func writeAuthorityReceipt(t *testing.T, receipt AuthorityBoundaryReceipt) string {
- t.Helper()
- value, err := MarshalJSON(receipt)
- if err != nil {
- t.Fatal(err)
- }
- path := filepath.Join(t.TempDir(), "authority-receipt.json")
- if err := os.WriteFile(path, value, 0o600); err != nil {
- t.Fatal(err)
- }
- t.Setenv(AuthorityReceiptEnv, path)
- return path
-}
-
-// control-law: categorical-authority-requires-external-attestation
-func TestCredentialEnforcedAuthorityReceiptIsAccepted(t *testing.T) {
- repo := runTestRepo(t)
- policy, receipt, privateKey := authorityFixture(t, repo)
- writeAuthorityReceipt(t, signAuthorityReceipt(t, receipt, privateKey))
- status, reason := verifyAuthorityBoundary(repo, &policy)
- if status != AuthorityCredentialEnforced || !strings.Contains(reason, "external authority") {
- t.Fatalf("valid external authority receipt was rejected: %s: %s", status, reason)
- }
-}
-
-// control-law: categorical-authority-requires-external-attestation
-func TestCredentialEnforcedAuthorityReceiptRejectsInvalidBindings(t *testing.T) {
- tests := []struct {
- name string
- mutate func(*AuthorityBoundaryReceipt)
- env func(*testing.T)
- }{
- {"forged signature", func(r *AuthorityBoundaryReceipt) { r.PrincipalFingerprint = "changed-after-signing" }, func(t *testing.T) { t.Setenv(AuthorityPrincipalEnv, "changed-after-signing") }},
- {"stale", func(r *AuthorityBoundaryReceipt) {
- r.IssuedAt = "2026-08-04T10:00:00Z"
- r.ExpiresAt = "2026-08-04T10:10:00Z"
- }, nil},
- {"wrong worktree", func(r *AuthorityBoundaryReceipt) { r.WorktreeFingerprint = "wrong" }, nil},
- {"wrong session", func(r *AuthorityBoundaryReceipt) { r.HostSession = "other" }, nil},
- {"wrong principal", func(r *AuthorityBoundaryReceipt) { r.PrincipalFingerprint = "other" }, nil},
- {"overprivileged", func(r *AuthorityBoundaryReceipt) { r.CloudControlPlaneAuthority = true }, nil},
- {"self-authored issuer", func(r *AuthorityBoundaryReceipt) { r.Issuer = "untrusted" }, nil},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- repo := runTestRepo(t)
- policy, receipt, privateKey := authorityFixture(t, repo)
- receipt = signAuthorityReceipt(t, receipt, privateKey)
- test.mutate(&receipt)
- if test.name != "forged signature" {
- receipt = signAuthorityReceipt(t, receipt, privateKey)
- }
- if test.env != nil {
- test.env(t)
- }
- writeAuthorityReceipt(t, receipt)
- status, _ := verifyAuthorityBoundary(repo, &policy)
- if status != AuthorityHookGuarded {
- t.Fatalf("invalid %s receipt was accepted: %s", test.name, status)
- }
- })
- }
-}
-
-// control-law: categorical-authority-requires-external-attestation
-func TestCredentialEnforcedModeFailsClosedWithoutReceiptAndPreservesEffect(t *testing.T) {
- repo := runTestRepo(t)
- t.Setenv(AuthorityReceiptEnv, "")
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Adapters = []string{"cursor"}
- config.Workflow.ExternalAuthority = &ExternalAuthorityPolicy{Mode: "credential-enforced", TrustStore: filepath.Join(t.TempDir(), "trust-store.json")}
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- status := CheckRunPreflight(repo, "")
- externalEffectApplied := false
- if status.VerificationStatus == "VERIFIED" {
- externalEffectApplied = true
- }
- if status.Relation != "AUTHORITY_BOUNDARY" || status.AuthorityStatus != AuthorityHookGuarded || externalEffectApplied {
- t.Fatalf("missing receipt did not fail closed before the effect: %+v effect=%t", status, externalEffectApplied)
- }
-}
-
-// control-law: categorical-authority-requires-external-attestation
-func TestRunPreflightReportsCredentialEnforcedForValidReceipt(t *testing.T) {
- repo := runTestRepo(t)
- policy, receipt, privateKey := authorityFixture(t, repo)
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Adapters = []string{"cursor"}
- config.Workflow.ExternalAuthority = &policy
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- writeAuthorityReceipt(t, signAuthorityReceipt(t, receipt, privateKey))
- remote := filepath.Join(t.TempDir(), "origin.git")
- if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
- t.Fatalf("git init --bare: %v: %s", err, output)
- }
- for _, args := range [][]string{
- {"remote", "add", "origin", remote},
- {"push", "-u", "origin", "main"},
- {"switch", "-c", "feature"},
- } {
- if output, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput(); err != nil {
- t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output)
- }
- }
- status := CheckRunPreflight(repo, "")
- if status.VerificationStatus != "VERIFIED" || status.AuthorityStatus != AuthorityCredentialEnforced {
- t.Fatalf("valid receipt did not admit the managed run: %+v", status)
- }
-}
-
-func TestExternalAuthorityPolicyValidation(t *testing.T) {
- config := testConfig()
- config.Workflow.ExternalAuthority = &ExternalAuthorityPolicy{Mode: "credential-enforced"}
- if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "trust_store") {
- t.Fatalf("credential-enforced mode accepted no external trust store: %v", err)
- }
- config.Workflow.ExternalAuthority = &ExternalAuthorityPolicy{Mode: "unknown"}
- if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "external_authority.mode") {
- t.Fatalf("unknown external authority mode was accepted: %v", err)
- }
-}
diff --git a/boatstack/autonomy.go b/boatstack/autonomy.go
deleted file mode 100644
index 43b0b47..0000000
--- a/boatstack/autonomy.go
+++ /dev/null
@@ -1,344 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "path/filepath"
- "sort"
- "strings"
-)
-
-const (
- autonomyMarkerStart = ""
- autonomyMarkerEnd = ""
-)
-
-type RunTarget string
-
-var autonomyRecommendedPRAction = RecommendedPRAction
-
-const (
- RunTargetPlan RunTarget = "plan"
- RunTargetVerified RunTarget = "verified"
- RunTargetPR RunTarget = "pr"
-)
-
-type AutonomyImpact struct {
- PublicContract bool `json:"public_contract"`
- AcceptanceCriteria bool `json:"acceptance_criteria"`
- Security bool `json:"security"`
- Billing bool `json:"billing"`
- Migration bool `json:"migration"`
- HighRiskPath bool `json:"high_risk_path"`
- Destructive bool `json:"destructive"`
- ExternalTarget bool `json:"external_target"`
-}
-
-type AutonomyOption struct {
- ID string `json:"id"`
- Text string `json:"text"`
- Recommended bool `json:"recommended"`
-}
-
-type AutonomyVerification struct {
- Run string `json:"run"`
- Oracle string `json:"oracle"`
-}
-
-type AutonomyDecision struct {
- ID string `json:"id"`
- Question string `json:"question"`
- Resolution string `json:"resolution"`
- SelectedOption string `json:"selected_option"`
- Options []AutonomyOption `json:"options"`
- Material bool `json:"material"`
- WithinSpec bool `json:"within_spec"`
- Reversible bool `json:"reversible"`
- Impact AutonomyImpact `json:"impact"`
- EvidenceIDs []string `json:"evidence_ids"`
- Verification AutonomyVerification `json:"verification"`
- Rationale string `json:"rationale"`
-}
-
-type AutonomyReceipt struct {
- SchemaVersion int `json:"schema_version"`
- Feature string `json:"feature"`
- Target RunTarget `json:"target"`
- Repository string `json:"repository"`
- Branch string `json:"branch"`
- IssuingBranch string `json:"issuing_branch,omitempty"`
- PRAction string `json:"pr_action,omitempty"`
- PlanPath string `json:"plan_path"`
- PlanFingerprint string `json:"plan_fingerprint"`
- Decisions []AutonomyDecision `json:"decisions"`
- Evidence []EvidenceRecord `json:"evidence"`
- Fingerprint string `json:"fingerprint"`
-}
-
-type AutonomyRecordOptions struct {
- Repo string
- PlanPath string
- Target RunTarget
- OutputPath string
-}
-
-func autonomyPathIf(enabled bool, path string) string {
- if enabled {
- return path
- }
- return ""
-}
-
-func selectedAutonomyEvidence(decisions []AutonomyDecision, ledger map[string]EvidenceRecord) ([]EvidenceRecord, error) {
- selected := map[string]EvidenceRecord{}
- for _, decision := range decisions {
- for _, id := range decision.EvidenceIDs {
- record, ok := ledger[id]
- if !ok || record.ID == "" || record.Operation == "" || record.RepositoryRevision == "" || record.CreatedBy == "" {
- return nil, fmt.Errorf("autonomy evidence %s is missing required repository provenance", id)
- }
- selected[id] = record
- }
- }
- ids := make([]string, 0, len(selected))
- for id := range selected {
- ids = append(ids, id)
- }
- sort.Strings(ids)
- result := make([]EvidenceRecord, 0, len(ids))
- for _, id := range ids {
- result = append(result, selected[id])
- }
- return result, nil
-}
-
-func ParseRunTarget(value string) (RunTarget, error) {
- target := RunTarget(strings.ToLower(strings.TrimSpace(value)))
- switch target {
- case RunTargetPlan, RunTargetVerified, RunTargetPR:
- return target, nil
- default:
- return "", fmt.Errorf("run target must be plan, verified, or pr")
- }
-}
-
-func autonomyDecisions(plan map[string]any) ([]AutonomyDecision, error) {
- raw, present := plan["autonomy_decisions"]
- if !present {
- return []AutonomyDecision{}, nil
- }
- data, err := MarshalJSON(raw)
- if err != nil {
- return nil, fmt.Errorf("autonomy_decisions must be a list: %w", err)
- }
- var decisions []AutonomyDecision
- if err := DecodeJSON("autonomy decisions", "plan.md", data, &decisions); err != nil {
- return nil, fmt.Errorf("autonomy_decisions must be a typed list: %w", err)
- }
- return decisions, nil
-}
-
-func validateAutonomyDecisions(plan map[string]any, evidence map[string]EvidenceRecord) ([]AutonomyDecision, error) {
- decisions, err := autonomyDecisions(plan)
- if err != nil {
- return nil, err
- }
- seen := map[string]bool{}
- for _, decision := range decisions {
- if decision.ID == "" || seen[decision.ID] {
- return nil, fmt.Errorf("autonomy decision ids must be present and unique")
- }
- seen[decision.ID] = true
- if decision.Resolution != "RESOLVED_BY_POLICY" {
- return nil, fmt.Errorf("autonomy decision %s resolution must be RESOLVED_BY_POLICY", decision.ID)
- }
- if decision.Material || !decision.WithinSpec || !decision.Reversible {
- return nil, fmt.Errorf("autonomy decision %s must be non-material, within-spec, and reversible", decision.ID)
- }
- impact := decision.Impact
- if impact.PublicContract || impact.AcceptanceCriteria || impact.Security || impact.Billing || impact.Migration || impact.HighRiskPath || impact.Destructive || impact.ExternalTarget {
- return nil, fmt.Errorf("autonomy decision %s has a protected impact", decision.ID)
- }
- recommended := ""
- for _, option := range decision.Options {
- if option.ID == "" || option.Text == "" {
- return nil, fmt.Errorf("autonomy decision %s has a malformed option", decision.ID)
- }
- if option.Recommended {
- if recommended != "" {
- return nil, fmt.Errorf("autonomy decision %s must have exactly one recommendation", decision.ID)
- }
- recommended = option.ID
- }
- }
- if recommended == "" || decision.SelectedOption != recommended {
- return nil, fmt.Errorf("autonomy decision %s must select its single recommended option", decision.ID)
- }
- if len(decision.EvidenceIDs) == 0 || decision.Verification.Run == "" || decision.Verification.Oracle == "" || decision.Rationale == "" {
- return nil, fmt.Errorf("autonomy decision %s requires evidence, rationale, and a runnable verification oracle", decision.ID)
- }
- for _, id := range decision.EvidenceIDs {
- if evidence != nil {
- if _, ok := evidence[id]; !ok {
- return nil, fmt.Errorf("autonomy decision %s references unknown evidence %s", decision.ID, id)
- }
- }
- }
- }
- return decisions, nil
-}
-
-func autonomyFingerprint(receipt AutonomyReceipt) (string, error) {
- receipt.Fingerprint = ""
- data, err := MarshalJSON(receipt)
- if err != nil {
- return "", err
- }
- return SHA256Bytes(data), nil
-}
-
-func RecordAutonomy(options AutonomyRecordOptions) (AutonomyReceipt, error) {
- target, err := ParseRunTarget(string(options.Target))
- if err != nil {
- return AutonomyReceipt{}, err
- }
- check, err := CheckPlan(options.PlanPath)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- evidence, err := LoadEvidenceLedger(filepath.Join(filepath.Dir(options.PlanPath), "evidence.json"))
- if err != nil {
- return AutonomyReceipt{}, err
- }
- decisions, err := validateAutonomyDecisions(check.Plan, evidence)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- selectedEvidence, err := selectedAutonomyEvidence(decisions, evidence)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- feature := stringValue(check.Plan["feature_id"])
- if workspaceEnabled(repo) && needsFreshCut(repo, feature) {
- return AutonomyReceipt{}, fmt.Errorf("autonomy requires the feature workspace; run workspace-cut --repo %s --feature %s and continue from destination_repository", repo, feature)
- }
- branch, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD")
- branch = strings.TrimSpace(branch)
- if err != nil || branch == "" || branch == "HEAD" {
- return AutonomyReceipt{}, fmt.Errorf("autonomy requires an identifiable current branch")
- }
- issuingBranch := branch
- repository, err := gitCommand(repo, "remote", "get-url", "origin")
- if err != nil {
- return AutonomyReceipt{}, fmt.Errorf("autonomy requires an origin repository identity")
- }
- action := ""
- if target == RunTargetPR {
- action, _, err = autonomyRecommendedPRAction(repo)
- if err != nil {
- return AutonomyReceipt{}, fmt.Errorf("PR target requires a stable open or update action: %w", err)
- }
- if action != "open" && action != "update" {
- return AutonomyReceipt{}, fmt.Errorf("PR target requires a stable open or update action")
- }
- }
- receipt := AutonomyReceipt{SchemaVersion: 1, Feature: feature, Target: target, Repository: strings.TrimSpace(repository), Branch: branch, IssuingBranch: issuingBranch, PRAction: action, PlanPath: filepath.ToSlash(options.PlanPath), PlanFingerprint: check.Fingerprint, Decisions: decisions, Evidence: selectedEvidence}
- receipt.Fingerprint, err = autonomyFingerprint(receipt)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- if options.OutputPath == "" {
- options.OutputPath = filepath.Join(filepath.Dir(options.PlanPath), "autonomy.md")
- }
- expectedOutput := filepath.Join(filepath.Dir(options.PlanPath), "autonomy.md")
- actualOutput, _ := filepath.Abs(options.OutputPath)
- expectedOutput, _ = filepath.Abs(expectedOutput)
- if filepath.Clean(actualOutput) != filepath.Clean(expectedOutput) {
- return AutonomyReceipt{}, fmt.Errorf("autonomy receipt must be written beside plan.md as autonomy.md")
- }
- body, err := MarshalJSON(receipt)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- content := []byte("# Boatstack autonomous run\n\nThis receipt records scoped policy authority. It is not human plan approval.\n\n" + autonomyMarkerStart + "\n```json\n" + string(body) + "```\n" + autonomyMarkerEnd + "\n")
- if err := writeFile(options.OutputPath, content, 0o644); err != nil {
- return AutonomyReceipt{}, err
- }
- return receipt, nil
-}
-
-func CheckAutonomyReceipt(path string, check PlanCheck, repo string, minimumTarget RunTarget, action string) (AutonomyReceipt, error) {
- return checkAutonomyReceipt(path, check, repo, minimumTarget, action, false)
-}
-
-// CheckAutonomyReceiptForPlanning permits the receipt's issuing branch only
-// while resolving the pre-cut planning transition. Activation and publication
-// always require the exact feature branch bound into Branch.
-func CheckAutonomyReceiptForPlanning(path string, check PlanCheck, repo string, minimumTarget RunTarget) (AutonomyReceipt, error) {
- return checkAutonomyReceipt(path, check, repo, minimumTarget, "", true)
-}
-
-func checkAutonomyReceipt(path string, check PlanCheck, repo string, minimumTarget RunTarget, action string, allowIssuingBranch bool) (AutonomyReceipt, error) {
- value, err := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- data, err := MarshalJSON(value)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- var receipt AutonomyReceipt
- if err := DecodeJSON("autonomy receipt", path, data, &receipt); err != nil {
- return AutonomyReceipt{}, err
- }
- want, err := autonomyFingerprint(receipt)
- if err != nil || receipt.SchemaVersion != 1 || receipt.Fingerprint != want {
- return AutonomyReceipt{}, fmt.Errorf("autonomy receipt fingerprint is invalid")
- }
- if receipt.Feature != stringValue(check.Plan["feature_id"]) || receipt.PlanFingerprint != check.Fingerprint {
- return AutonomyReceipt{}, fmt.Errorf("autonomy receipt does not match the current plan")
- }
- currentRepo, err := gitCommand(repo, "remote", "get-url", "origin")
- if err != nil || strings.TrimSpace(currentRepo) != receipt.Repository {
- return AutonomyReceipt{}, fmt.Errorf("autonomy receipt repository identity changed")
- }
- branch, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD")
- branch = strings.TrimSpace(branch)
- branchMatches := branch == receipt.Branch || (allowIssuingBranch && receipt.IssuingBranch != "" && branch == receipt.IssuingBranch)
- if err != nil || !branchMatches {
- return AutonomyReceipt{}, fmt.Errorf("autonomy receipt branch identity changed")
- }
- rank := map[RunTarget]int{RunTargetPlan: 1, RunTargetVerified: 2, RunTargetPR: 3}
- if rank[receipt.Target] < rank[minimumTarget] {
- return AutonomyReceipt{}, fmt.Errorf("autonomy target %s does not authorize %s", receipt.Target, minimumTarget)
- }
- if action != "" && receipt.PRAction != action {
- return AutonomyReceipt{}, fmt.Errorf("autonomy receipt authorizes PR action %s, not %s", receipt.PRAction, action)
- }
- evidence, err := LoadEvidenceLedger(filepath.Join(filepath.Dir(check.PlanPath), "evidence.json"))
- if err != nil {
- return AutonomyReceipt{}, err
- }
- decisions, err := validateAutonomyDecisions(check.Plan, evidence)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- wantDecisions, _ := MarshalJSON(decisions)
- gotDecisions, _ := MarshalJSON(receipt.Decisions)
- if SHA256Bytes(wantDecisions) != SHA256Bytes(gotDecisions) {
- return AutonomyReceipt{}, fmt.Errorf("autonomy decision set changed")
- }
- selectedEvidence, err := selectedAutonomyEvidence(decisions, evidence)
- if err != nil {
- return AutonomyReceipt{}, err
- }
- wantEvidence, _ := MarshalJSON(selectedEvidence)
- gotEvidence, _ := MarshalJSON(receipt.Evidence)
- if SHA256Bytes(wantEvidence) != SHA256Bytes(gotEvidence) {
- return AutonomyReceipt{}, fmt.Errorf("autonomy evidence changed")
- }
- return receipt, nil
-}
diff --git a/boatstack/autonomy_conformance_test.go b/boatstack/autonomy_conformance_test.go
deleted file mode 100644
index d3ee558..0000000
--- a/boatstack/autonomy_conformance_test.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func eligibleAutonomyDecision() AutonomyDecision {
- return AutonomyDecision{
- ID: "Q-1", Question: "Which existing helper should this use?", Resolution: "RESOLVED_BY_POLICY",
- SelectedOption: "1a", Options: []AutonomyOption{{ID: "1a", Text: "Reuse helper", Recommended: true}, {ID: "1b", Text: "Inline logic"}},
- WithinSpec: true, Reversible: true, EvidenceIDs: []string{"ev_1"},
- Verification: AutonomyVerification{Run: "go test ./...", Oracle: "existing conformance test passes"},
- Rationale: "The repository already owns the helper.",
- }
-}
-
-// control-law: autonomy-receipt-binds-policy-activation-to-plan-repository-and-branch
-func TestAutonomyReceiptOverridesHumanPlanGateOnlyForExactPlan(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git")
- writeActivationConfig(t, root, true)
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record planning inputs")
- autonomyPath := filepath.Join(root, "autonomy.md")
- receipt, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified, OutputPath: autonomyPath})
- if err != nil {
- t.Fatal(err)
- }
- compiled := filepath.Join(root, "compiled")
- lockPath := filepath.Join(root, "plan.lock.json")
- if err := ActivatePlan(ActivationOptions{Repo: root, PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil {
- t.Fatal(err)
- }
- value, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- lock := map[string]any{}
- if err := json.Unmarshal(value, &lock); err != nil {
- t.Fatal(err)
- }
- if stringValue(lock["authorization_mode"]) != "policy" || stringValue(lock["autonomy_fingerprint"]) != receipt.Fingerprint || stringValue(lock["run_target"]) != "verified" {
- t.Fatalf("activation did not bind scoped policy authority: %#v", lock)
- }
- planBytes, _ := os.ReadFile(planPath)
- if err := os.WriteFile(planPath, append([]byte("changed\n"), planBytes...), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckAutonomyReceipt(autonomyPath, PlanCheck{Plan: validPlan(), PlanPath: planPath, Fingerprint: "changed"}, root, RunTargetVerified, ""); err == nil {
- t.Fatal("changed plan retained autonomous authority")
- }
-}
-
-// control-law: autonomy-receipt-binds-policy-activation-to-plan-repository-and-branch
-func TestAutonomyReceiptRequiresFreshWorkspaceBranch(t *testing.T) {
- root := workspaceRepo(t, defaultWorkspace())
- runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git")
- _, _, planPath := writePlanInputs(t, root, true)
- if _, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified}); err == nil || !strings.Contains(err.Error(), "workspace-cut") {
- t.Fatalf("pre-cut autonomy should name the workspace transition, got %v", err)
- }
-}
-
-// control-law: autonomous-pr-authority-correlates-one-repository-branch-and-action
-func TestAutonomyReceiptRejectsDifferentPRAction(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git")
- writeActivationConfig(t, root, true)
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record planning inputs")
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- receipt := AutonomyReceipt{SchemaVersion: 1, Feature: stringValue(check.Plan["feature_id"]), Target: RunTargetPR, Repository: "https://example.invalid/operatorstack/example.git", Branch: "main", PRAction: "open", PlanPath: planPath, PlanFingerprint: check.Fingerprint, Decisions: []AutonomyDecision{}, Evidence: []EvidenceRecord{}}
- receipt.Fingerprint, err = autonomyFingerprint(receipt)
- if err != nil {
- t.Fatal(err)
- }
- body, _ := MarshalJSON(receipt)
- path := filepath.Join(root, "autonomy.md")
- content := []byte("# Boatstack autonomous run\n\n" + autonomyMarkerStart + "\n```json\n" + string(body) + "```\n" + autonomyMarkerEnd + "\n")
- if err := os.WriteFile(path, content, 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckAutonomyReceipt(path, check, root, RunTargetPR, "open"); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckAutonomyReceipt(path, check, root, RunTargetPR, "update"); err == nil || !strings.Contains(err.Error(), "open, not update") {
- t.Fatalf("changed PR action retained authority: %v", err)
- }
-}
-
-func autonomyPlan(decision AutonomyDecision) map[string]any {
- return map[string]any{"autonomy_decisions": []AutonomyDecision{decision}}
-}
-
-// control-law: autonomous-decisions-stay-inside-the-declared-low-risk-envelope
-func TestAutonomyEligibilityAcceptsOnlyCompleteLowRiskDecision(t *testing.T) {
- decision := eligibleAutonomyDecision()
- got, err := validateAutonomyDecisions(autonomyPlan(decision), map[string]EvidenceRecord{"ev_1": {ID: "ev_1"}})
- if err != nil || len(got) != 1 || got[0].Resolution != "RESOLVED_BY_POLICY" {
- t.Fatalf("eligible decision was not accepted: %#v, %v", got, err)
- }
-}
-
-// control-law: autonomous-decisions-stay-inside-the-declared-low-risk-envelope
-func TestAutonomyEligibilityRejectsEveryProtectedImpact(t *testing.T) {
- cases := map[string]func(*AutonomyDecision){
- "material": func(d *AutonomyDecision) { d.Material = true },
- "outside-spec": func(d *AutonomyDecision) { d.WithinSpec = false },
- "irreversible": func(d *AutonomyDecision) { d.Reversible = false },
- "public-contract": func(d *AutonomyDecision) { d.Impact.PublicContract = true },
- "acceptance": func(d *AutonomyDecision) { d.Impact.AcceptanceCriteria = true },
- "security": func(d *AutonomyDecision) { d.Impact.Security = true },
- "billing": func(d *AutonomyDecision) { d.Impact.Billing = true },
- "migration": func(d *AutonomyDecision) { d.Impact.Migration = true },
- "high-risk": func(d *AutonomyDecision) { d.Impact.HighRiskPath = true },
- "destructive": func(d *AutonomyDecision) { d.Impact.Destructive = true },
- "external-target": func(d *AutonomyDecision) { d.Impact.ExternalTarget = true },
- "missing-evidence": func(d *AutonomyDecision) { d.EvidenceIDs = nil },
- "missing-oracle": func(d *AutonomyDecision) { d.Verification.Oracle = "" },
- "wrong-provenance": func(d *AutonomyDecision) { d.Resolution = "ANSWERED" },
- "wrong-selection": func(d *AutonomyDecision) { d.SelectedOption = "1b" },
- "two-recommendations": func(d *AutonomyDecision) { d.Options[1].Recommended = true },
- }
- for name, mutate := range cases {
- t.Run(name, func(t *testing.T) {
- decision := eligibleAutonomyDecision()
- mutate(&decision)
- if _, err := validateAutonomyDecisions(autonomyPlan(decision), map[string]EvidenceRecord{"ev_1": {ID: "ev_1"}}); err == nil {
- t.Fatal("ineligible decision was accepted")
- }
- })
- }
-}
-
-// control-law: every-question-resolution-path-reaches-the-shared-decision-boundary
-func TestQuestionResolutionPathInventory(t *testing.T) {
- paths := []struct {
- name string
- input PlanDecisionInput
- want DecisionOperator
- }{
- {"verified repository fact", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceVerified}, OperatorInfer},
- {"supported fact", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceSupported}, OperatorVerify},
- {"material intent", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent, IsMaterial: true}, OperatorQuery},
- {"eligible policy choice", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent, AutonomyEligible: true}, OperatorPolicy},
- {"invalid premise", PlanDecisionInput{PremiseStatus: PremiseInvalid}, OperatorReject},
- {"conflicting evidence", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceConflicting}, OperatorEscalate},
- {"unknown uncertainty", PlanDecisionInput{PremiseStatus: PremiseUnknown, EvidenceLevel: EvidenceAbsent}, OperatorEscalate},
- }
- for _, path := range paths {
- t.Run(path.name, func(t *testing.T) {
- if got := ResolvePlanDecision(path.input).Operator; got != path.want {
- t.Fatalf("resolution = %s, want %s", got, path.want)
- }
- })
- }
- if len(paths) != 7 || strings.TrimSpace(string(OperatorPolicy)) == "" {
- t.Fatal("question-resolution inventory is incomplete")
- }
-}
-
-// control-law: every-question-resolution-path-reaches-the-shared-decision-boundary
-func TestQuestionResolutionEntryPointInventory(t *testing.T) {
- entries := map[string][]string{
- "decision.go": {"ResolvePlanDecision", "eligible-nonmaterial-policy-resolution"},
- "plan_validation.go": {"validatePlanAutonomy", "validateAutonomyDecisions"},
- "autonomy.go": {"RecordAutonomy", "CheckAutonomyReceipt", "validateAutonomyDecisions"},
- "export.go": {"shared decision boundary", "RESOLVED_BY_POLICY"},
- "SKILL.md": {"RESOLVED_BY_POLICY", "Any failed or unknown condition pauses for the human"},
- }
- for path, snippets := range entries {
- value, err := os.ReadFile(path)
- if err != nil {
- t.Fatalf("read %s: %v", path, err)
- }
- for _, snippet := range snippets {
- if !strings.Contains(string(value), snippet) {
- t.Fatalf("question-resolution entry %s bypasses boundary marker %q", path, snippet)
- }
- }
- }
-}
-
-func TestRunTargetClosedVocabulary(t *testing.T) {
- for _, value := range []string{"plan", "verified", "pr"} {
- if _, err := ParseRunTarget(value); err != nil {
- t.Fatalf("%s: %v", value, err)
- }
- }
- if _, err := ParseRunTarget("merge"); err == nil {
- t.Fatal("merge became an autonomous target")
- }
-}
diff --git a/boatstack/bootstrap.go b/boatstack/bootstrap.go
deleted file mode 100644
index 9a28800..0000000
--- a/boatstack/bootstrap.go
+++ /dev/null
@@ -1,271 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-const bootstrapPrescriptionSchemaVersion = 1
-
-// Tests may replace this seam. Nil selects the production installation health
-// check immediately before a bootstrap prescription is rendered.
-var bootstrapInstallationHealth func(string) error
-
-type BootstrapShell string
-
-const (
- BootstrapShellPOSIX BootstrapShell = "posix"
- BootstrapShellPowerShell BootstrapShell = "powershell"
-)
-
-type BootstrapOptions struct {
- Repo string
- Feature string
- SourcePlan string
- Artifact string
- Shell BootstrapShell
- Document []byte
-}
-
-// BootstrapPrescription is the read-only, mode-aware answer for the first
-// managed planning write. It binds creation intent, source-plan freshness, the
-// selected worktree, and the exact helper into one literal shell envelope.
-// control-law: bootstrap-command-authority-is-workspace-bound
-type BootstrapPrescription struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- Disposition string `json:"disposition"`
- SupervisionMode SupervisionMode `json:"supervision_mode"`
- Repository string `json:"repository"`
- RepositoryID string `json:"repository_id,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- ControllerRoot string `json:"controller_root"`
- HelperPath string `json:"helper_path"`
- Feature string `json:"feature"`
- SourcePlan string `json:"source_plan"`
- SourcePlanSHA256 string `json:"source_plan_sha256"`
- Artifact string `json:"artifact"`
- ArtifactPath string `json:"artifact_path"`
- DocumentSHA256 string `json:"document_sha256"`
- LifecycleState string `json:"lifecycle_state,omitempty"`
- LifecycleSHA256 string `json:"lifecycle_sha256,omitempty"`
- ObservationID string `json:"observation_id,omitempty"`
- PreviousPlanLock string `json:"previous_plan_lock_sha256,omitempty"`
- Shell BootstrapShell `json:"shell"`
- Argv []string `json:"argv"`
- PlanningEnvelope string `json:"planning_envelope"`
-}
-
-func normalizedPlanningDocument(document []byte) ([]byte, error) {
- document = normalizePlanningTransportBytes(document)
- if reason := validPlanningBody(string(document)); reason != "" {
- return nil, fmt.Errorf("planning document is invalid: %s", reason)
- }
- value := append([]byte(nil), document...)
- if len(value) == 0 || value[len(value)-1] != '\n' {
- value = append(value, '\n')
- }
- return value, nil
-}
-
-func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, feature string) (string, *LifecycleSnapshot, error) {
- directory := workspace.FeatureDir(feature)
- info, err := os.Lstat(directory)
- if os.IsNotExist(err) {
- return "CREATE_CANDIDATE", nil, nil
- }
- if err != nil {
- return "", nil, err
- }
- if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
- return "", nil, fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature)
- }
- statePath, stateErr := deliveryStatePath(repo, feature)
- if stateErr != nil {
- return "", nil, stateErr
- }
- managed := false
- for _, path := range []string{
- statePath,
- filepath.Join(directory, "plan.lock.json"),
- filepath.Join(directory, "pr.md"),
- filepath.Join(directory, "approval.md"),
- filepath.Join(directory, "autonomy.md"),
- } {
- if fileExists(path) {
- managed = true
- break
- }
- }
- if managed {
- snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, feature)
- if snapshotErr != nil {
- return "", nil, fmt.Errorf("feature %s carries managed authority that cannot be verified: %w", feature, snapshotErr)
- }
- if !amendmentLifecycleState(snapshot.State) {
- return "", nil, fmt.Errorf("feature %s already carries managed authority; use flow next --feature %s", feature, feature)
- }
- return "AMEND_ACTIVE", &snapshot, nil
- }
- entries, err := os.ReadDir(directory)
- if err != nil {
- return "", nil, err
- }
- for _, entry := range entries {
- if entry.IsDir() || !planningArtifacts[entry.Name()] {
- return "", nil, fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature)
- }
- }
- if fileExists(filepath.Join(directory, "plan.md")) {
- if _, err := CheckPlan(filepath.Join(directory, "plan.md")); err != nil {
- return "", nil, fmt.Errorf("feature %s has an invalid saved plan; run recovery-status before bootstrapping: %w", feature, err)
- }
- }
- return "RESUME_CANDIDATE", nil, nil
-}
-
-func bootstrapProgram(workspace WorkspaceContext, shell BootstrapShell) string {
- if workspace.Mode == SupervisionDetached {
- return workspace.HelperPath()
- }
- return workspace.LauncherPath(shell == BootstrapShellPowerShell)
-}
-
-func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string, lifecycle *LifecycleSnapshot) []string {
- argv := []string{
- program, "planning-write",
- "--repo", repo,
- "--feature", feature,
- "--artifact", artifact,
- "--source-plan", sourcePlan,
- "--source-plan-sha256", sourceSHA,
- }
- if lifecycle != nil {
- argv = append(argv,
- "--expected-lifecycle-sha256", lifecycle.Fingerprint,
- "--expected-plan-lock-sha256", lifecycle.PlanLockSHA256,
- "--expected-observation", lifecycle.ObservationID,
- )
- }
- return argv
-}
-
-func posixPlanningEnvelopeFor(argv []string, document []byte) string {
- words := make([]string, len(argv))
- for index, word := range argv {
- words[index] = posixPlanningWord(word)
- }
- delimiter := "BOATSTACK_PLAN_" + strings.ToUpper(SHA256Bytes(document)[:16])
- return strings.Join(words, " ") + " <<'" + delimiter + "'\n" + string(document) + delimiter + "\n"
-}
-
-func powerShellPlanningWord(value string) string {
- return "'" + strings.ReplaceAll(value, "'", "''") + "'"
-}
-
-func powerShellPlanningEnvelopeFor(argv []string, document []byte) (string, error) {
- for _, line := range strings.Split(strings.ReplaceAll(string(document), "\r\n", "\n"), "\n") {
- if strings.HasPrefix(line, "'@") {
- return "", fmt.Errorf("PowerShell cannot represent a document line beginning with '@; use --shell posix with Git Bash")
- }
- }
- words := make([]string, len(argv))
- for index, word := range argv {
- if strings.Contains(word, "'") {
- return "", fmt.Errorf("PowerShell cannot safely bind an argument containing a single quote; use --shell posix with Git Bash")
- }
- words[index] = powerShellPlanningWord(word)
- }
- return "& {\n" + powerShellPlanningEncodingLine + "\n@'\n" + string(document) + "'@ | & " + strings.Join(words, " ") + "\n" + powerShellPlanningExitLine + "\n}\n", nil
-}
-
-// ResolvePlanningBootstrap is pure with respect to repository and controller
-// state: it validates current evidence and returns bytes to execute, but writes
-// nothing. The later planning-write rechecks the source-plan digest before its
-// atomic first write.
-// control-law: bootstrap-command-authority-is-workspace-bound
-func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription, error) {
- if !featureSlugPattern.MatchString(options.Feature) {
- return BootstrapPrescription{}, fmt.Errorf("feature must be a lowercase kebab-case slug")
- }
- if !planningArtifacts[options.Artifact] {
- return BootstrapPrescription{}, fmt.Errorf("unsupported planning artifact %q; use one of: %s", options.Artifact, strings.Join(planningArtifactNames(), ", "))
- }
- if options.Shell != BootstrapShellPOSIX && options.Shell != BootstrapShellPowerShell {
- return BootstrapPrescription{}, fmt.Errorf("shell must be posix or powershell")
- }
- document, err := normalizedPlanningDocument(options.Document)
- if err != nil {
- return BootstrapPrescription{}, err
- }
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return BootstrapPrescription{}, err
- }
- healthCheck := CheckInstallationHealth
- if bootstrapInstallationHealth != nil {
- healthCheck = bootstrapInstallationHealth
- }
- if err := healthCheck(repo); err != nil {
- return BootstrapPrescription{}, fmt.Errorf("bootstrap requires a healthy Boatstack installation: %w", DoctorRepairHint(err))
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return BootstrapPrescription{}, err
- }
- sourcePlan, err := DiscoverSourcePlan(repo, options.SourcePlan)
- if err != nil {
- return BootstrapPrescription{}, err
- }
- sourceAbsolute := filepath.Join(repo, filepath.FromSlash(sourcePlan))
- if err := rejectSymlinkComponents(repo, sourceAbsolute); err != nil {
- return BootstrapPrescription{}, fmt.Errorf("source plan must be a regular in-repository file without symlink indirection: %w", err)
- }
- if pathWithin(workspace.FeatureDir(options.Feature), sourceAbsolute) {
- return BootstrapPrescription{}, fmt.Errorf("source plan must remain outside the generated feature package")
- }
- sourceSHA, err := SHA256File(sourceAbsolute)
- if err != nil {
- return BootstrapPrescription{}, err
- }
- disposition, lifecycle, err := bootstrapFeatureDisposition(repo, workspace, options.Feature)
- if err != nil {
- return BootstrapPrescription{}, err
- }
- program := bootstrapProgram(workspace, options.Shell)
- argv := planningArgv(program, repo, options.Feature, options.Artifact, sourcePlan, sourceSHA, lifecycle)
- if options.Shell == BootstrapShellPOSIX {
- // Git Bash accepts Windows drive paths in slash form. Keep the typed argv
- // identical to the bytes rendered for that shell.
- argv[0] = filepath.ToSlash(argv[0])
- argv[3] = filepath.ToSlash(argv[3])
- }
- var envelope string
- if options.Shell == BootstrapShellPowerShell {
- envelope, err = powerShellPlanningEnvelopeFor(argv, document)
- } else {
- envelope = posixPlanningEnvelopeFor(argv, document)
- }
- if err != nil {
- return BootstrapPrescription{}, err
- }
- prescription := BootstrapPrescription{
- SchemaVersion: bootstrapPrescriptionSchemaVersion, VerificationStatus: "VERIFIED",
- Disposition: disposition, SupervisionMode: workspace.Mode,
- Repository: repo, RepositoryID: workspace.RepoID, WorktreeID: workspace.WorktreeID,
- ControllerRoot: workspace.ExportRoot(), HelperPath: program,
- Feature: options.Feature, SourcePlan: sourcePlan, SourcePlanSHA256: sourceSHA,
- Artifact: options.Artifact, ArtifactPath: filepath.Join(workspace.FeatureDir(options.Feature), options.Artifact),
- DocumentSHA256: SHA256Bytes(document), Shell: options.Shell, Argv: argv,
- PlanningEnvelope: envelope,
- }
- if lifecycle != nil {
- prescription.LifecycleState = string(lifecycle.State)
- prescription.LifecycleSHA256 = lifecycle.Fingerprint
- prescription.ObservationID = lifecycle.ObservationID
- prescription.PreviousPlanLock = lifecycle.PlanLockSHA256
- }
- return prescription, nil
-}
diff --git a/boatstack/bootstrap_conformance_test.go b/boatstack/bootstrap_conformance_test.go
deleted file mode 100644
index f88bfe8..0000000
--- a/boatstack/bootstrap_conformance_test.go
+++ /dev/null
@@ -1,404 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-func bootstrapTestShell() BootstrapShell {
- if runtime.GOOS == "windows" {
- return BootstrapShellPowerShell
- }
- return BootstrapShellPOSIX
-}
-
-func writeBootstrapSourcePlan(t *testing.T, repo string) string {
- t.Helper()
- path := filepath.Join(repo, "docs", "source plan.md")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("# Source plan\n\nBuild the bounded feature.\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- return filepath.ToSlash(filepath.Join("docs", "source plan.md"))
-}
-
-func executePlanningEnvelopeOutput(repo, command string) ([]byte, error) {
- if runtime.GOOS == "windows" {
- powershell, err := exec.LookPath("powershell")
- if err != nil {
- return nil, err
- }
- process := exec.Command(powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command)
- process.Dir = repo
- return process.CombinedOutput()
- }
- process := exec.Command("bash", "-c", command)
- process.Dir = repo
- return process.CombinedOutput()
-}
-
-func executePlanningEnvelopeWith(repo, executable string, shell BootstrapShell, command string) ([]byte, error) {
- var process *exec.Cmd
- if shell == BootstrapShellPowerShell {
- process = exec.Command(executable, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command)
- } else {
- process = exec.Command(executable, "-c", command)
- }
- process.Dir = repo
- return process.CombinedOutput()
-}
-
-func bootstrapInputEnvelope(t *testing.T, workspace WorkspaceContext, repo, feature, sourcePlan, artifact string, shell BootstrapShell, body []byte) string {
- t.Helper()
- program := bootstrapProgram(workspace, shell)
- argv := []string{
- program, "flow", "bootstrap", "--repo", repo, "--feature", feature,
- "--source-plan", sourcePlan, "--artifact", artifact, "--shell", string(shell),
- }
- if shell == BootstrapShellPowerShell {
- envelope, err := powerShellPlanningEnvelopeFor(argv, body)
- if err != nil {
- t.Fatal(err)
- }
- return envelope
- }
- return posixPlanningEnvelopeFor(argv, body)
-}
-
-// Positive, relation, and bypass conformance for control-law:
-// bootstrap-command-authority-is-workspace-bound. The exact stdin envelope
-// admitted by every host runs the real bootstrap CLI; its exact stdout envelope
-// is admitted again, runs in a real shell, and creates the intended artifact.
-func TestBootstrapOracleRunsInputHookRendererOutputHookShellHelper(t *testing.T) {
- repo := safetyTestRepo(t)
- installPlanningTransportFixture(t, repo)
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- t.Fatal(err)
- }
- sourcePlan := writeBootstrapSourcePlan(t, repo)
- body := []byte("# Synthetic plan\n\nCommands such as `rm -rf /` and `git reset --hard` are inert text.\n")
- shell := bootstrapTestShell()
- bootstrapCommand := bootstrapInputEnvelope(t, workspace, repo, "bootstrap-oracle", sourcePlan, "source-plan.md", shell, body)
-
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, bootstrapCommand)}); denied {
- t.Fatalf("%s denied the bootstrap input envelope: %s", host, output)
- }
- }
- rendered, err := executePlanningEnvelopeOutput(repo, bootstrapCommand)
- if err != nil {
- t.Fatalf("execute bootstrap renderer: %v: %s", err, rendered)
- }
- planningEnvelope := string(rendered)
- transport := inspectPlanningWriteTransport(planningEnvelope)
- if !transport.Matched || transport.InvalidReason != "" || !strings.Contains(transport.Header, "--source-plan-sha256") || planningTransportBinding(repo, transport) != "" || string(transport.Content) != string(body) {
- t.Fatalf("bootstrap output lost source evidence or workspace launcher: %s", planningEnvelope)
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, planningEnvelope)}); denied {
- t.Fatalf("%s denied the rendered planning envelope: %s", host, output)
- }
- }
- if output, err := executePlanningEnvelopeOutput(repo, planningEnvelope); err != nil {
- t.Fatalf("execute rendered planning envelope: %v: %s", err, output)
- }
- written, err := os.ReadFile(filepath.Join(workspace.FeatureDir("bootstrap-oracle"), "source-plan.md"))
- if err != nil || string(written) != string(body) {
- t.Fatalf("bootstrap artifact mismatch: %v %q", err, written)
- }
-}
-
-func TestBootstrapNormalizesPowerShellTransportEncoding(t *testing.T) {
- document := append([]byte{0xef, 0xbb, 0xbf}, []byte("# Plan\r\n\r\nExact bytes.\r\n")...)
- normalized, err := normalizedPlanningDocument(document)
- if err != nil {
- t.Fatal(err)
- }
- if string(normalized) != "# Plan\n\nExact bytes.\n" {
- t.Fatalf("PowerShell transport encoding reached the prescription: %q", normalized)
- }
-
- envelope, err := powerShellPlanningEnvelopeFor([]string{
- `.product-loop\boatstack.ps1`, "planning-write", "--repo", ".", "--feature", "transport-encoding", "--artifact", "plan.md",
- }, []byte("# Plan\r\n"))
- if err != nil {
- t.Fatal(err)
- }
- inspection := inspectPlanningWriteTransport(envelope)
- if !inspection.Matched || inspection.InvalidReason != "" || string(inspection.Content) != "# Plan\n" {
- t.Fatalf("PowerShell transport parser did not return canonical Markdown: %+v", inspection)
- }
-}
-
-// Positive and relation conformance for control-law:
-// bootstrap-command-authority-is-workspace-bound. Detached mode must render the
-// external bound helper and keep every controller byte outside the product repo.
-func TestDetachedBootstrapOracleUsesExternalHelperAcrossHosts(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/bootstrap-oracle.git")
- t.Setenv(stateRootEnv, filepath.Join(t.TempDir(), "Application Support"))
- invalidateWorkspaceCache()
- sourcePlan := writeBootstrapSourcePlan(t, repo)
- source := buildPlanningHelperAt(t, filepath.Join(t.TempDir(), helperName()))
- result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: source})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach detached fixture: %+v %v", result, err)
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- t.Fatal(err)
- }
- body := []byte("# Detached bootstrap\n\nThe controller stays external.\n")
- prescription, err := ResolvePlanningBootstrap(BootstrapOptions{
- Repo: repo, Feature: "detached-bootstrap", SourcePlan: sourcePlan,
- Artifact: "source-plan.md", Shell: bootstrapTestShell(), Document: body,
- })
- if err != nil {
- t.Fatal(err)
- }
- if prescription.Disposition != "CREATE_CANDIDATE" || prescription.HelperPath != workspace.HelperPath() || !strings.Contains(prescription.HelperPath, "Application Support") {
- t.Fatalf("detached prescription is not bound to the external workspace: %+v", prescription)
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, prescription.PlanningEnvelope)}); denied {
- t.Fatalf("%s denied detached bootstrap output: %s", host, output)
- }
- }
- if output, err := executePlanningEnvelopeOutput(repo, prescription.PlanningEnvelope); err != nil {
- t.Fatalf("execute detached bootstrap output: %v: %s", err, output)
- }
- if _, err := os.Stat(filepath.Join(workspace.FeatureDir("detached-bootstrap"), "source-plan.md")); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(filepath.Join(repo, productLoopDirName)); !os.IsNotExist(err) {
- t.Fatal("detached bootstrap leaked controller state into the product repository")
- }
-}
-
-// Negative and failure-state conformance for control-law:
-// bootstrap-command-authority-is-workspace-bound. A raw first write and stale
-// source evidence both fail without creating managed feature state.
-func TestBootstrapFirstWriteRequiresFreshSourceEvidence(t *testing.T) {
- repo := safetyTestRepo(t)
- installPlanningTransportFixture(t, repo)
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := WritePlanningArtifact(PlanningWriteOptions{
- Repo: repo, Feature: "raw-first-write", Artifact: "source-plan.md", Content: []byte("# Raw\n"),
- }); err == nil || !strings.Contains(err.Error(), "flow bootstrap") {
- t.Fatalf("raw first write did not require bootstrap evidence: %v", err)
- }
- if _, err := os.Stat(workspace.FeatureDir("raw-first-write")); !os.IsNotExist(err) {
- t.Fatal("rejected raw first write created feature state")
- }
-
- sourcePlan := writeBootstrapSourcePlan(t, repo)
- prescription, err := ResolvePlanningBootstrap(BootstrapOptions{
- Repo: repo, Feature: "stale-source", SourcePlan: sourcePlan,
- Artifact: "source-plan.md", Shell: bootstrapTestShell(), Document: []byte("# Candidate\n"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Changed\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- output, runErr := executePlanningEnvelopeOutput(repo, prescription.PlanningEnvelope)
- if runErr == nil || !strings.Contains(string(output), "source plan changed after bootstrap") {
- t.Fatalf("stale source evidence was not rejected: %v %s", runErr, output)
- }
- if _, err := os.Stat(workspace.FeatureDir("stale-source")); !os.IsNotExist(err) {
- t.Fatal("stale prescription created feature state")
- }
-}
-
-// Negative, bypass, and failure-state conformance for control-law:
-// bootstrap-command-authority-is-workspace-bound.
-func TestBootstrapOracleRejectsInvalidIdentityStateAndShell(t *testing.T) {
- repo := safetyTestRepo(t)
- installPlanningTransportFixture(t, repo)
- sourcePlan := writeBootstrapSourcePlan(t, repo)
- base := BootstrapOptions{
- Repo: repo, Feature: "safe-bootstrap", SourcePlan: sourcePlan,
- Artifact: "source-plan.md", Shell: bootstrapTestShell(), Document: []byte("# Candidate\n"),
- }
- tests := []struct {
- name string
- mutate func(*BootstrapOptions)
- }{
- {"invalid slug", func(o *BootstrapOptions) { o.Feature = "Wrong Slug" }},
- {"unknown artifact", func(o *BootstrapOptions) { o.Artifact = "state.json" }},
- {"unknown shell", func(o *BootstrapOptions) { o.Shell = "cmd" }},
- {"missing source", func(o *BootstrapOptions) { o.SourcePlan = "docs/missing.md" }},
- {"powershell collision", func(o *BootstrapOptions) {
- o.Shell = BootstrapShellPowerShell
- o.Document = []byte("# Body\n'@ collision\n")
- }},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- options := base
- test.mutate(&options)
- if _, err := ResolvePlanningBootstrap(options); err == nil {
- t.Fatal("invalid bootstrap input was accepted")
- }
- })
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(workspace.FeatureDir(base.Feature), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(workspace.FeatureDir(base.Feature), "plan.lock.json"), []byte("{}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := ResolvePlanningBootstrap(base); err == nil || !strings.Contains(err.Error(), "managed authority") {
- t.Fatalf("managed feature was allowed back into bootstrap: %v", err)
- }
-}
-
-func TestFlowBootstrapJSONMatchesCanonicalPrescription(t *testing.T) {
- repo := safetyTestRepo(t)
- installPlanningTransportFixture(t, repo)
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- t.Fatal(err)
- }
- sourcePlan := writeBootstrapSourcePlan(t, repo)
- body := []byte("# JSON prescription\n")
- command := exec.Command(workspace.HelperPath(), "flow", "bootstrap", "--repo", repo, "--feature", "json-bootstrap", "--source-plan", sourcePlan, "--artifact", "source-plan.md", "--shell", string(bootstrapTestShell()), "--json")
- command.Dir = repo
- command.Stdin = strings.NewReader(string(body))
- output, err := command.CombinedOutput()
- if err != nil {
- t.Fatalf("flow bootstrap --json: %v: %s", err, output)
- }
- var prescription BootstrapPrescription
- if err := json.Unmarshal(output, &prescription); err != nil {
- t.Fatal(err)
- }
- if prescription.VerificationStatus != "VERIFIED" || prescription.Feature != "json-bootstrap" || prescription.SourcePlanSHA256 == "" || len(prescription.Argv) == 0 || prescription.PlanningEnvelope == "" {
- t.Fatalf("incomplete bootstrap JSON: %+v", prescription)
- }
-}
-
-// Relation and bypass conformance for control-law:
-// bootstrap-command-authority-is-workspace-bound. Generated host instructions
-// may name the oracle, but may never reconstruct a mutation helper path.
-func TestGeneratedPlanningInstructionsUseBootstrapOracle(t *testing.T) {
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- paths := []string{
- ".product-loop/workflow.md",
- ".cursor/commands/auto-plan.md",
- ".claude/skills/auto-plan/SKILL.md",
- ".agents/skills/auto-plan/SKILL.md",
- ".gemini/skills/auto-plan/SKILL.md",
- }
- for _, path := range paths {
- content := string(bundle.Files[path])
- if !strings.Contains(content, "flow bootstrap") || !strings.Contains(content, "planning_envelope") {
- t.Fatalf("%s does not consume the canonical bootstrap oracle", path)
- }
- for _, forbidden := range []string{
- ".product-loop/boatstack planning-write",
- `.product-loop\boatstack.ps1' planning-write`,
- ".product-loop/bin/boatstack-helper planning-write",
- } {
- if strings.Contains(content, forbidden) {
- t.Fatalf("%s reconstructs a planning mutation command: %q", path, forbidden)
- }
- }
- }
-}
-
-func TestBootstrapRendererQuotesLiteralPathsForBothShells(t *testing.T) {
- body := []byte("# Quoted paths\n")
- argv := []string{
- filepath.Join(string(filepath.Separator), "Application Support", "Boat's helper", helperName()),
- "planning-write", "--repo", filepath.Join(string(filepath.Separator), "work trees", "consumer's repo"),
- "--feature", "quoted-bootstrap", "--artifact", "plan.md",
- "--source-plan", "docs/operator's plan.md", "--source-plan-sha256", strings.Repeat("a", 64),
- }
- posix := inspectPlanningWriteTransport(posixPlanningEnvelopeFor(argv, body))
- if !posix.Matched || posix.InvalidReason != "" || posix.Executable != argv[0] || posix.Repository != argv[3] {
- t.Fatalf("POSIX literal quoting drifted: %+v", posix)
- }
- powerShellArgv := append([]string(nil), argv...)
- powerShellArgv[0] = filepath.Join(string(filepath.Separator), "Application Support", "Boat helper", helperName())
- powerShellArgv[3] = filepath.Join(string(filepath.Separator), "work trees", "consumer repo")
- powerShellArgv[9] = "docs/operator plan.md"
- powerShellEnvelope, err := powerShellPlanningEnvelopeFor(powerShellArgv, body)
- if err != nil {
- t.Fatal(err)
- }
- powerShell := inspectPlanningWriteTransport(powerShellEnvelope)
- if !powerShell.Matched || powerShell.InvalidReason != "" || powerShell.Executable != powerShellArgv[0] || powerShell.Repository != powerShellArgv[3] {
- t.Fatalf("PowerShell literal quoting drifted: %+v", powerShell)
- }
- if _, err := powerShellPlanningEnvelopeFor(argv, body); err == nil {
- t.Fatal("PowerShell renderer accepted an ambiguously quoted path")
- }
-}
-
-// Positive platform conformance for control-law:
-// bootstrap-command-authority-is-workspace-bound. Required CI executes the
-// canonical output in Bash on Unix, zsh on macOS, and both PowerShell and Git
-// Bash on Windows without a provider API or installed coding host.
-func TestBootstrapEnvelopeExecutesInRequiredRealShells(t *testing.T) {
- repo := safetyTestRepo(t)
- installPlanningTransportFixture(t, repo)
- sourcePlan := writeBootstrapSourcePlan(t, repo)
- type shellCase struct {
- name string
- executable string
- shell BootstrapShell
- }
- cases := []shellCase{{name: "bash", executable: "bash", shell: BootstrapShellPOSIX}}
- if runtime.GOOS == "windows" {
- cases = append(cases, shellCase{name: "powershell", executable: "powershell", shell: BootstrapShellPowerShell})
- }
- if runtime.GOOS == "darwin" {
- cases = append(cases, shellCase{name: "zsh", executable: "zsh", shell: BootstrapShellPOSIX})
- }
- for _, test := range cases {
- t.Run(test.name, func(t *testing.T) {
- if _, err := exec.LookPath(test.executable); err != nil {
- t.Fatalf("required shell %s is unavailable: %v", test.executable, err)
- }
- feature := "real-shell-" + test.name
- prescription, err := ResolvePlanningBootstrap(BootstrapOptions{
- Repo: repo, Feature: feature, SourcePlan: sourcePlan,
- Artifact: "source-plan.md", Shell: test.shell, Document: []byte("# Real shell\n"),
- })
- if err != nil {
- t.Fatal(err)
- }
- if output, err := executePlanningEnvelopeWith(repo, test.executable, test.shell, prescription.PlanningEnvelope); err != nil {
- t.Fatalf("%s execution failed: %v: %s", test.name, err, output)
- }
- artifact, err := os.ReadFile(filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "source-plan.md"))
- if err != nil || string(artifact) != "# Real shell\n" {
- t.Fatalf("%s did not preserve exact artifact bytes: %v %q", test.name, err, artifact)
- }
- })
- }
-}
diff --git a/boatstack/capability.go b/boatstack/capability.go
deleted file mode 100644
index 8e7b76f..0000000
--- a/boatstack/capability.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "strings"
-)
-
-// Capability describes a named producer of PR evidence. It is the generic spine
-// that concrete evidence types register on: detection ("can this repository
-// produce the evidence?"), capture orchestration ("what command runs it, and
-// when?"), and provisioning ("if it is unavailable, how do we help?") all read
-// this registry instead of hard-coding a single evidence type.
-//
-// Boatstack ships the contract in this registry, not the capture harness. The
-// harness is authored in the user's repository and invoked through the resolved
-// repository command; Boatstack only records what the command must satisfy.
-type Capability struct {
- // Name is the canonical capability identifier, e.g. "visual".
- Name string
- // CommandAliases are the project.commands keys that satisfy the capability,
- // in priority order. The first non-empty command wins.
- CommandAliases []string
- // AdmittedStages are the delivery stages in which a capture attempt may run.
- AdmittedStages []string
- // RetryClass is the operation retry class recorded for a capture attempt.
- RetryClass string
-}
-
-// CapabilityResolution is the portable capability cut for one capability: it
-// reports whether the repository owns a command that produces this evidence.
-type CapabilityResolution struct {
- Name string `json:"name"`
- Kind string `json:"kind"` // repository-command | unavailable
- Command string `json:"command,omitempty"`
-}
-
-// capabilityRegistry holds every evidence capability Boatstack knows about.
-// Adding a provider is a single entry here plus its tenant-specific manifest
-// contract (see visual_evidence.go for the first tenant).
-var capabilityRegistry = map[string]Capability{
- "visual": {
- Name: "visual",
- CommandAliases: []string{"visual", "screenshot", "e2e"},
- AdmittedStages: []string{"BUILD", "TEST_PASSED", "REVIEW_PASSED", "PR_PREVIEW"},
- RetryClass: "IDEMPOTENT_EXTERNAL",
- },
-}
-
-// LookupCapability returns the registered capability metadata for a name.
-func LookupCapability(name string) (Capability, bool) {
- capability, ok := capabilityRegistry[strings.TrimSpace(name)]
- return capability, ok
-}
-
-// ResolveCapability performs the repository-owned capability cut: it selects the
-// first project command alias that is set. It is the generic detection primitive
-// shared by capture orchestration and provisioning. Kind is "repository-command"
-// when the repository owns a command, otherwise "unavailable".
-func ResolveCapability(name string, config ProjectConfig) (CapabilityResolution, error) {
- capability, ok := LookupCapability(name)
- if !ok {
- return CapabilityResolution{}, fmt.Errorf("unknown evidence capability %q", name)
- }
- for _, alias := range capability.CommandAliases {
- if command := strings.TrimSpace(config.Project.Commands[alias]); command != "" {
- return CapabilityResolution{Name: capability.Name, Kind: "repository-command", Command: command}, nil
- }
- }
- return CapabilityResolution{Name: capability.Name, Kind: "unavailable"}, nil
-}
-
-// ResolveCapabilityForSurface resolves a capability command for one product
-// surface: the surface-scoped key ("visual:web") outranks the global alias
-// ladder, and an empty or unregistered surface falls back to it exactly — a
-// repository with only a global command keeps serving every surface.
-func ResolveCapabilityForSurface(name, surface string, config ProjectConfig) (CapabilityResolution, error) {
- capability, ok := LookupCapability(name)
- if !ok {
- return CapabilityResolution{}, fmt.Errorf("unknown evidence capability %q", name)
- }
- if surface = strings.TrimSpace(surface); surface != "" {
- if command := strings.TrimSpace(config.Project.Commands[capability.Name+":"+surface]); command != "" {
- return CapabilityResolution{Name: capability.Name, Kind: "repository-command", Command: command}, nil
- }
- }
- return ResolveCapability(name, config)
-}
diff --git a/boatstack/capability_test.go b/boatstack/capability_test.go
deleted file mode 100644
index 21b5a66..0000000
--- a/boatstack/capability_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package boatstack
-
-import "testing"
-
-func TestResolveCapabilitySelectsRepositoryCommandByAliasPriority(t *testing.T) {
- config := testConfig()
- delete(config.Project.Commands, "visual")
- delete(config.Project.Commands, "screenshot")
- delete(config.Project.Commands, "e2e")
-
- // Lowest-priority alias still resolves the repository-owned cut.
- config.Project.Commands["e2e"] = "npm run e2e"
- resolution, err := ResolveCapability("visual", config)
- if err != nil {
- t.Fatalf("resolve visual: %v", err)
- }
- if resolution.Kind != "repository-command" || resolution.Command != "npm run e2e" || resolution.Name != "visual" {
- t.Fatalf("alias fallback did not resolve: %#v", resolution)
- }
-
- // A higher-priority alias wins over a lower one.
- config.Project.Commands["visual"] = "npm run capture:visual"
- resolution, err = ResolveCapability("visual", config)
- if err != nil {
- t.Fatalf("resolve visual: %v", err)
- }
- if resolution.Command != "npm run capture:visual" {
- t.Fatalf("higher-priority alias did not win: %#v", resolution)
- }
-}
-
-func TestResolveCapabilityReportsUnavailableWithoutCommand(t *testing.T) {
- config := testConfig()
- delete(config.Project.Commands, "visual")
- delete(config.Project.Commands, "screenshot")
- delete(config.Project.Commands, "e2e")
-
- resolution, err := ResolveCapability("visual", config)
- if err != nil {
- t.Fatalf("resolve visual: %v", err)
- }
- if resolution.Kind != "unavailable" || resolution.Command != "" {
- t.Fatalf("expected unavailable resolution: %#v", resolution)
- }
-}
-
-func TestResolveCapabilityRejectsUnknownCapability(t *testing.T) {
- if _, err := ResolveCapability("does-not-exist", testConfig()); err == nil {
- t.Fatal("expected error for unknown capability")
- }
-}
-
-func TestLookupCapabilityExposesRegisteredMetadata(t *testing.T) {
- capability, ok := LookupCapability("visual")
- if !ok {
- t.Fatal("visual capability is not registered")
- }
- if len(capability.CommandAliases) == 0 || capability.RetryClass == "" || len(capability.AdmittedStages) == 0 {
- t.Fatalf("visual capability metadata is incomplete: %#v", capability)
- }
-}
-
-// Invariant: a surface-scoped command outranks the global alias, and its
-// absence falls back to the global ladder exactly — a repository with one
-// global harness keeps serving every surface (zero-value behavior).
-func TestResolveCapabilityForSurfacePrefersSurfaceScopedCommand(t *testing.T) {
- config := testConfig()
- delete(config.Project.Commands, "visual")
- delete(config.Project.Commands, "screenshot")
- delete(config.Project.Commands, "e2e")
- config.Project.Commands["visual"] = "npm run capture:visual"
- config.Project.Commands["visual:web"] = "npm run capture:web"
-
- resolution, err := ResolveCapabilityForSurface("visual", "web", config)
- if err != nil {
- t.Fatal(err)
- }
- if resolution.Command != "npm run capture:web" {
- t.Fatalf("surface key did not outrank the global alias: %#v", resolution)
- }
- for _, surface := range []string{"", "ops"} {
- resolution, err = ResolveCapabilityForSurface("visual", surface, config)
- if err != nil {
- t.Fatal(err)
- }
- if resolution.Command != "npm run capture:visual" {
- t.Fatalf("surface %q did not fall back to the global alias: %#v", surface, resolution)
- }
- }
- delete(config.Project.Commands, "visual")
- resolution, err = ResolveCapabilityForSurface("visual", "ops", config)
- if err != nil {
- t.Fatal(err)
- }
- if resolution.Kind != "unavailable" {
- t.Fatalf("unregistered surface with no global command must be unavailable: %#v", resolution)
- }
-}
diff --git a/boatstack/capture.go b/boatstack/capture.go
deleted file mode 100644
index 1146a12..0000000
--- a/boatstack/capture.go
+++ /dev/null
@@ -1,387 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "fmt"
- "image/png"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-)
-
-const captureMaxAttempts = 3
-
-// CaptureRequest is the framework-agnostic contract Boatstack passes to the
-// repository-owned capture harness for one scenario. Boatstack ships this
-// contract; the harness (authored in the user's repository) satisfies it by
-// writing exactly one PNG to OutputPath. The contract is surfaced to the harness
-// as environment variables (see execCaptureRunner).
-type CaptureRequest struct {
- Repo string
- Capability string
- Command string
- Scenario PRVisualScenario
- OutputPath string
- ReceiptPath string
-}
-
-// CaptureRunner runs one scenario's repository capture command. It must produce
-// a valid PNG at request.OutputPath. It is an interface so tests can drive
-// capture deterministically without a real browser or dev server.
-type CaptureRunner interface {
- Run(request CaptureRequest) error
-}
-
-// execCaptureRunner invokes the repository-owned command through the shell,
-// exposing the capture contract as environment variables. The command is taken
-// from trusted project configuration.
-type execCaptureRunner struct{}
-
-func (execCaptureRunner) Run(request CaptureRequest) error {
- command := exec.Command("sh", "-c", request.Command)
- command.Dir = request.Repo
- command.Env = append(os.Environ(),
- "BOATSTACK_CAPTURE_CAPABILITY="+request.Capability,
- "BOATSTACK_CAPTURE_SCENARIO_ID="+request.Scenario.ID,
- "BOATSTACK_CAPTURE_ENTRY="+request.Scenario.Entry,
- "BOATSTACK_CAPTURE_STATE="+request.Scenario.State,
- "BOATSTACK_CAPTURE_VIEWPORT="+request.Scenario.Viewport,
- "BOATSTACK_CAPTURE_SURFACE="+request.Scenario.Surface,
- "BOATSTACK_CAPTURE_OUTPUT="+request.OutputPath,
- "BOATSTACK_CAPTURE_RECEIPT="+request.ReceiptPath,
- )
- // The harness's authoritative output is the PNG on disk, not stdout; only
- // stderr is retained, as bounded diagnostics for a failed capture.
- var diagnostics bytes.Buffer
- command.Stdout = nil
- command.Stderr = &diagnostics
- if err := command.Run(); err != nil {
- return fmt.Errorf("capture command failed: %w: %s", err, boundedObservation(diagnostics.String()))
- }
- return nil
-}
-
-// CaptureEvidenceOptions configures a managed capture run.
-type CaptureEvidenceOptions struct {
- Repo string
- Capability string
- Feature string
- Base string
- Runner CaptureRunner
-}
-
-// CaptureEvidence orchestrates capture for a managed feature. It resolves the
-// repository-owned capability command, reads the plan-declared scenarios, runs
-// each scenario as a supervised operation, stamps the manifest to the current
-// head commit and product diff, and ingests it through SavePRVisualEvidence.
-// The manifest is trusted only if it conforms; a non-conformant manifest is a
-// blocking error, never a silent PASS.
-func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- name := strings.TrimSpace(options.Capability)
- if name == "" {
- name = "visual"
- }
- capability, ok := LookupCapability(name)
- if !ok {
- return PRVisualEvidenceManifest{}, fmt.Errorf("unknown evidence capability %q", name)
- }
- feature := strings.TrimSpace(options.Feature)
- if feature == "" {
- return PRVisualEvidenceManifest{}, fmt.Errorf("capture requires a managed --feature")
- }
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- return PRVisualEvidenceManifest{}, fmt.Errorf("capture requires a valid Boatstack project configuration: %w", err)
- }
- relevance, source, scenarios, err := planVisualDecision(repo, feature)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- if relevance == "not_relevant" {
- return PRVisualEvidenceManifest{}, fmt.Errorf("%s evidence is marked not_relevant for %q; nothing to capture", name, feature)
- }
- if len(scenarios) == 0 {
- return PRVisualEvidenceManifest{}, fmt.Errorf("no %s scenarios declared in the plan (pr_visual_evidence.scenarios)", name)
- }
- // Every scenario's command must resolve before any capture runs — a
- // surface-scoped key outranks the global alias; a missing surface key
- // with no global fallback is named exactly, never captured around.
- commands, err := resolveScenarioCaptureCommands(name, scenarios, config)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
-
- head, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD")
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- base := strings.TrimSpace(options.Base)
- if base == "" {
- base = strings.TrimSpace(config.Project.DefaultBranch)
- }
- if base == "" {
- base = defaultPRBase(repo)
- }
- base, err = canonicalPRBase(repo, base)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- headCommit, diffHash, err := captureProductDiff(repo, base, feature, head)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
-
- key, err := visualEvidenceKey("managed", feature, head)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- stagingDir, err := captureStagingDirectory(repo, key)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- runner := options.Runner
- if runner == nil {
- runner = execCaptureRunner{}
- }
-
- items := make([]PRVisualEvidenceItem, 0, len(scenarios))
- for _, scenario := range scenarios {
- outputPath := filepath.Join(stagingDir, scenario.ID+".png")
- receiptPath := filepath.Join(stagingDir, scenario.ID+".receipt.json")
- if err := captureScenario(repo, capability, commands[scenario.ID], scenario, outputPath, receiptPath, feature, head, headCommit, diffHash, runner); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- verificationStatus := "CAPTURED"
- var receipt *PRVisualScenarioReceipt
- if parsed, receiptErr := loadVisualScenarioReceipt(receiptPath, scenario.ID); receiptErr == nil {
- receipt, verificationStatus = parsed, "SCENARIO_VERIFIED"
- } else if !os.IsNotExist(receiptErr) {
- return PRVisualEvidenceManifest{}, receiptErr
- }
- items = append(items, PRVisualEvidenceItem{
- ScenarioID: scenario.ID,
- Path: outputPath,
- MIMEType: "image/png",
- Viewport: scenario.Viewport,
- CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
- Status: "CAPTURED",
- PrivacyStatus: "clean",
- VerificationStatus: verificationStatus,
- Receipt: receipt,
- })
- }
- scenarioRaw, err := MarshalJSON(scenarios)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- commandRaw, err := MarshalJSON(commands)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
-
- manifest := PRVisualEvidenceManifest{
- Key: key,
- // The manifest records the configured policy verbatim (informational);
- // the effective policy — including plan-escalated require semantics —
- // is re-derived by resolvePRVisualEvidence at every decode.
- Policy: config.Workflow.PRVisualEvidence,
- Relevance: relevance,
- RelevanceSource: source,
- Status: "PASS",
- SourceCommit: headCommit,
- ProductDiffSHA256: diffHash,
- ScenarioDefinitionSHA256: SHA256Bytes(scenarioRaw),
- CaptureCommandSHA256: SHA256Bytes(commandRaw),
- Scenarios: scenarios,
- Items: items,
- }
- saved, err := SavePRVisualEvidence(repo, manifest)
- if err != nil {
- return PRVisualEvidenceManifest{}, fmt.Errorf("captured %s evidence is non-conformant (BLOCKED): %w", name, err)
- }
- return saved, nil
-}
-
-// resolveScenarioCaptureCommands resolves the harness command for every
-// scenario up front (surface key first, global alias fallback), so capture
-// either runs with a complete command map or fails naming the exact missing
-// registration before any harness executes.
-func resolveScenarioCaptureCommands(name string, scenarios []PRVisualScenario, config ProjectConfig) (map[string]string, error) {
- commands := make(map[string]string, len(scenarios))
- for _, scenario := range scenarios {
- resolution, err := ResolveCapabilityForSurface(name, scenario.Surface, config)
- if err != nil {
- return nil, err
- }
- if resolution.Kind != "repository-command" {
- if surface := strings.TrimSpace(scenario.Surface); surface != "" {
- return nil, fmt.Errorf("evidence capability %q is unavailable for surface %q: register project.commands[%q] (capability-register --capability %s --surface %s --command ) or a global command", name, surface, name+":"+surface, name, surface)
- }
- return nil, fmt.Errorf("evidence capability %q is unavailable: register a repository command (capability-register --capability %s --command ) or provision it first", name, name)
- }
- commands[scenario.ID] = resolution.Command
- }
- return commands, nil
-}
-
-// captureProductDiff reproduces the pr-context product-diff fingerprint so a
-// captured manifest is trusted (PASS) by resolvePRVisualEvidence: same product
-// diff. The head commit is recorded for provenance only — trust is keyed to
-// product identity so committing the reviewed pr.md never stales evidence.
-func captureProductDiff(repo, base, feature, head string) (headCommit, diffHash string, err error) {
- baseCommit, err := resolveBaseCommit(repo, base)
- if err != nil {
- return "", "", err
- }
- mergeBaseCommit, err := gitCommand(repo, "merge-base", baseCommit, "HEAD")
- if err != nil || mergeBaseCommit == "" {
- return "", "", fmt.Errorf("cannot determine the merge base between %s and %s", base, head)
- }
- headCommit, err = gitCommand(repo, "rev-parse", "HEAD")
- if err != nil {
- return "", "", err
- }
- previewPath, err := expectedPRPreviewPath("managed", feature, head)
- if err != nil {
- return "", "", err
- }
- diff, _, err := productDiff(repo, mergeBaseCommit, previewPath)
- if err != nil {
- return "", "", err
- }
- return headCommit, SHA256Bytes(diff), nil
-}
-
-func captureStagingDirectory(repo, key string) (string, error) {
- directory, err := visualEvidenceDirectory(repo, key)
- if err != nil {
- return "", err
- }
- staging := filepath.Join(directory, "capture-staging")
- if err := os.MkdirAll(staging, 0o700); err != nil {
- return "", err
- }
- return staging, nil
-}
-
-// captureScenario runs one scenario as a supervised operation with a bounded
-// retry budget. The fingerprint is stable for a given command, scenario, and
-// product diff, so a successful capture on the same commit is reused rather than
-// re-run.
-func captureScenario(repo string, capability Capability, command string, scenario PRVisualScenario, outputPath, receiptPath, feature, head, headCommit, diffHash string, runner CaptureRunner) error {
- scenarioRaw, err := MarshalJSON(scenario)
- if err != nil {
- return err
- }
- fingerprint := SHA256Bytes([]byte(strings.Join([]string{
- command, string(scenarioRaw), diffHash,
- }, "\x00")))
- kind := "capture:" + capability.Name
- postcondition := fmt.Sprintf("valid PNG captured for scenario %s (%s)", scenario.ID, scenario.Viewport)
-
- prepared, err := PrepareOperation(OperationPrepareOptions{
- Repo: repo,
- Kind: kind,
- Scope: OperationScope{Feature: feature, HeadBranch: head},
- Target: outputPath,
- PackageFingerprint: fingerprint,
- AuthorizationFingerprint: fingerprint,
- RetryClass: capability.RetryClass,
- MaxAttempts: captureMaxAttempts,
- ExpectedPostcondition: postcondition,
- })
- if err != nil {
- return fmt.Errorf("prepare capture of %s: %w", scenario.ID, err)
- }
- if prepared.State == OperationSucceeded {
- if verifyCapturedPNG(outputPath) == nil {
- return nil
- }
- return fmt.Errorf("capture of %s already succeeded but its artifact is missing; run operation-status and reconcile", scenario.ID)
- }
-
- var lastErr error
- for attempt := 0; attempt < captureMaxAttempts; attempt++ {
- begun, beginErr := BeginOperation(repo, prepared.OperationID, fmt.Sprintf("%s@%d", scenario.ID, attempt), kind)
- if beginErr != nil {
- return fmt.Errorf("begin capture of %s: %w", scenario.ID, beginErr)
- }
- if begun.Receipt.State == OperationSucceeded {
- if verifyCapturedPNG(outputPath) == nil {
- return nil
- }
- return fmt.Errorf("capture of %s reports success but its artifact is missing", scenario.ID)
- }
- // A new package fingerprint must not inherit an optional receipt left by
- // an older harness run. PNG-only remains CAPTURED.
- if err := os.Remove(receiptPath); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("clear stale receipt for %s: %w", scenario.ID, err)
- }
- runErr := runner.Run(CaptureRequest{
- Repo: repo, Capability: capability.Name, Command: command, Scenario: scenario, OutputPath: outputPath, ReceiptPath: receiptPath,
- })
- if runErr == nil {
- runErr = verifyCapturedPNG(outputPath)
- }
- if runErr == nil {
- if _, err := CompleteOperation(repo, prepared.OperationID, begun.LeaseToken, "SUCCEEDED", "captured "+scenario.ID, outputPath); err != nil {
- return fmt.Errorf("record capture success for %s: %w", scenario.ID, err)
- }
- return nil
- }
- lastErr = runErr
- receipt, completeErr := CompleteOperation(repo, prepared.OperationID, begun.LeaseToken, "RETRYABLE", runErr.Error(), "")
- if completeErr != nil {
- return fmt.Errorf("record capture retry for %s: %w", scenario.ID, completeErr)
- }
- if receipt.State == OperationFailedFinal {
- break
- }
- }
- return fmt.Errorf("capture of scenario %s failed after %d attempts: %w", scenario.ID, captureMaxAttempts, lastErr)
-}
-
-func loadVisualScenarioReceipt(path, scenarioID string) (*PRVisualScenarioReceipt, error) {
- raw, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- var receipt PRVisualScenarioReceipt
- if err := DecodeJSON("visual scenario receipt", path, raw, &receipt); err != nil {
- return nil, err
- }
- if receipt.ScenarioID != scenarioID || strings.TrimSpace(receipt.Reached) == "" || len(receipt.Checks) == 0 || !strings.EqualFold(receipt.OverallResult, "PASS") {
- return nil, fmt.Errorf("visual scenario receipt for %s is invalid or failing", scenarioID)
- }
- for _, check := range receipt.Checks {
- if strings.TrimSpace(check.Name) == "" || !strings.EqualFold(check.Result, "PASS") {
- return nil, fmt.Errorf("visual scenario receipt for %s contains an unnamed or failing check", scenarioID)
- }
- }
- return &receipt, nil
-}
-
-func verifyCapturedPNG(path string) error {
- info, err := os.Lstat(path)
- if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("capture output is missing or unsafe: %s", path)
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- configuration, err := png.DecodeConfig(bytes.NewReader(value))
- if err != nil {
- return fmt.Errorf("capture output is not a valid PNG: %w", err)
- }
- if configuration.Width < 1 || configuration.Height < 1 {
- return fmt.Errorf("capture output has no pixels: %s", path)
- }
- return nil
-}
diff --git a/boatstack/capture_test.go b/boatstack/capture_test.go
deleted file mode 100644
index d856d27..0000000
--- a/boatstack/capture_test.go
+++ /dev/null
@@ -1,333 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// stubCaptureRunner drives capture deterministically without a browser or dev
-// server. write decides what (if anything) lands at the requested output path.
-type stubCaptureRunner struct {
- calls int
- write func(request CaptureRequest) error
-}
-
-func (runner *stubCaptureRunner) Run(request CaptureRequest) error {
- runner.calls++
- return runner.write(request)
-}
-
-// captureTestRepo builds a managed feature whose plan declares one relevant
-// visual scenario and registers a visual capability command. The command itself
-// is never executed in tests — the injected runner stands in for the repo-owned
-// harness.
-func captureTestRepo(t *testing.T, feature string) string {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
-
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Workflow.PRVisualEvidence = "require"
- config.Project.Commands["visual"] = "exit 1" // proves the runner, not the shell command, drives capture
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
-
- runGit(t, repo, "switch", "-c", "feat/"+feature)
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = feature
- plan["pr_visual_evidence"] = map[string]any{
- "relevance": "relevant",
- "scenarios": []any{map[string]any{
- "id": "warning", "entry": "/onboarding", "state": "picker open", "viewport": "1440x900",
- "expected": []any{"warning visible"},
- }},
- }
- writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true)
- if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "feature work")
- return repo
-}
-
-func TestCaptureEvidenceProducesManifestTrustedByPRContext(t *testing.T) {
- repo := captureTestRepo(t, "reviewer-ready")
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- if request.Capability != "visual" || request.Scenario.ID != "warning" || request.OutputPath == "" {
- t.Fatalf("runner received an ill-formed capture request: %#v", request)
- }
- writeTestPNG(t, request.OutputPath)
- return nil
- }}
-
- manifest, err := CaptureEvidence(CaptureEvidenceOptions{
- Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner,
- })
- if err != nil {
- t.Fatalf("capture failed: %v", err)
- }
- if manifest.Status != "PASS" || len(manifest.Items) != 1 || manifest.Items[0].PrivacyStatus != "clean" {
- t.Fatalf("capture did not produce a conformant PASS manifest: %#v", manifest)
- }
- if !strings.Contains(manifest.Items[0].Path, filepath.Join("boatstack", "visual-evidence")) {
- t.Fatalf("captured PNG was not ingested into Boatstack state: %s", manifest.Items[0].Path)
- }
-
- // Capture must leave the product tree untouched (evidence lives in Git-common state).
- if status := runGit(t, repo, "status", "--short"); status != "" {
- t.Fatalf("capture mutated the product tree: %s", status)
- }
-
- // The manifest must be trusted by the same resolver pr-context uses: an
- // identical product diff → status is the manifest's PASS, not NOT_VERIFIED.
- head := runGit(t, repo, "rev-parse", "--abbrev-ref", "HEAD")
- _, diffHash, err := captureProductDiff(repo, "main", "reviewer-ready", head)
- if err != nil {
- t.Fatal(err)
- }
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- _, status, count, _, _, _, _, resolved, err := resolvePRVisualEvidence(repo, config, "managed", "reviewer-ready", head, diffHash)
- if err != nil {
- t.Fatal(err)
- }
- if status != "PASS" || count != 1 || resolved == nil {
- t.Fatalf("captured evidence was not trusted by pr-context: status=%s count=%d", status, count)
- }
-
- // A second capture on the same commit is idempotent: it reuses the supervised
- // operation's successful artifact instead of re-running the harness.
- priorCalls := runner.calls
- if _, err := CaptureEvidence(CaptureEvidenceOptions{
- Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner,
- }); err != nil {
- t.Fatalf("idempotent re-capture failed: %v", err)
- }
- if runner.calls != priorCalls {
- t.Fatalf("re-capture re-ran the harness (%d extra calls) instead of reusing the receipt", runner.calls-priorCalls)
- }
-}
-
-// control-law: scenario-verification-requires-a-current-receipt
-func TestCaptureEvidenceDoesNotReuseStaleOptionalReceipt(t *testing.T) {
- repo := captureTestRepo(t, "reviewer-ready")
- withReceipt := true
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- writeTestPNG(t, request.OutputPath)
- if withReceipt {
- return os.WriteFile(request.ReceiptPath, []byte(`{"scenario_id":"warning","reached_state_or_url":"/onboarding","checks":[{"name":"warning visible","result":"PASS"}],"overall_result":"PASS"}`), 0o600)
- }
- return nil
- }}
- first, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner})
- if err != nil {
- t.Fatal(err)
- }
- if first.Items[0].VerificationStatus != "SCENARIO_VERIFIED" {
- t.Fatalf("valid receipt was not verified: %#v", first.Items[0])
- }
-
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- config.Project.Commands["visual"] = "changed-capture-command"
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".product-loop/project.json")
- runGit(t, repo, "commit", "-m", "change capture command")
- withReceipt = false
- second, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner})
- if err != nil {
- t.Fatal(err)
- }
- if second.Items[0].VerificationStatus != "CAPTURED" || second.Items[0].Receipt != nil {
- t.Fatalf("PNG-only rerun inherited a stale receipt: %#v", second.Items[0])
- }
-}
-
-func TestCaptureEvidenceFailsClosedOnNonConformantOutput(t *testing.T) {
- repo := captureTestRepo(t, "reviewer-ready")
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- // The harness reports success but writes bytes that are not a valid PNG.
- return os.WriteFile(request.OutputPath, []byte("not a png"), 0o600)
- }}
-
- _, err := CaptureEvidence(CaptureEvidenceOptions{
- Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner,
- })
- if err == nil {
- t.Fatal("capture accepted a non-conformant artifact instead of failing closed")
- }
- if runner.calls != captureMaxAttempts {
- t.Fatalf("capture did not exhaust the supervised retry budget: %d attempts", runner.calls)
- }
- // Nothing may be persisted: a failed capture must not leave trusted evidence.
- if _, err := LoadPRVisualEvidence(repo, "reviewer-ready"); err == nil {
- t.Fatal("a failed capture persisted a manifest")
- }
-}
-
-func TestCaptureEvidenceRequiresAResolvedCapabilityCommand(t *testing.T) {
- repo := captureTestRepo(t, "reviewer-ready")
- // Remove every command alias so the capability resolves to unavailable.
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- delete(config.Project.Commands, "visual")
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
-
- _, err = CaptureEvidence(CaptureEvidenceOptions{
- Repo: repo, Capability: "visual", Feature: "reviewer-ready",
- Runner: &stubCaptureRunner{write: func(CaptureRequest) error { return nil }},
- })
- if err == nil || !strings.Contains(err.Error(), "unavailable") {
- t.Fatalf("capture ran without a resolved repository command: %v", err)
- }
-}
-
-// Invariant: capture resolves the harness per scenario surface — each
-// scenario runs its own registered command with the surface in the request —
-// and an unresolvable surface fails before any harness runs, naming the exact
-// missing registration key.
-func TestCaptureEvidenceResolvesPerSurfaceCommands(t *testing.T) {
- repo := captureTestRepo(t, "reviewer-ready")
- directory := filepath.Join(repo, ".product-loop", "features", "reviewer-ready")
- plan := validPlan()
- plan["feature_id"] = "reviewer-ready"
- plan["pr_visual_evidence"] = map[string]any{
- "relevance": "relevant",
- "scenarios": []any{
- map[string]any{
- "id": "warning", "entry": "/onboarding", "state": "picker open", "viewport": "1440x900",
- "expected": []any{"warning visible"}, "surface": "web",
- },
- map[string]any{
- "id": "console", "entry": "/ops/queues", "state": "backlog shown", "viewport": "1280x800",
- "expected": []any{"queue depth visible"}, "surface": "ops",
- },
- },
- }
- writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true)
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "declare per-surface scenarios")
-
- configPath := filepath.Join(repo, ".product-loop", "project.json")
- config, _, err := LoadConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- config.Project.Commands["visual:web"] = "run-web-harness"
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(configPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "register web surface harness")
-
- // One surface key missing and no usable... the global "visual" command is
- // still registered in the fixture, so ops falls back to it: capture runs.
- commandsSeen := map[string]string{}
- surfacesSeen := map[string]string{}
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- commandsSeen[request.Scenario.ID] = request.Command
- surfacesSeen[request.Scenario.ID] = request.Scenario.Surface
- writeTestPNG(t, request.OutputPath)
- return nil
- }}
- if _, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner}); err != nil {
- t.Fatalf("per-surface capture failed: %v", err)
- }
- if commandsSeen["warning"] != "run-web-harness" || surfacesSeen["warning"] != "web" {
- t.Fatalf("web scenario did not run its surface harness: %q (%q)", commandsSeen["warning"], surfacesSeen["warning"])
- }
- if commandsSeen["console"] != "exit 1" || surfacesSeen["console"] != "ops" {
- t.Fatalf("ops scenario did not fall back to the global command: %q (%q)", commandsSeen["console"], surfacesSeen["console"])
- }
-
- // Remove the global fallback: the ops surface now has no registration and
- // capture refuses up front, naming the exact missing key.
- delete(config.Project.Commands, "visual")
- delete(config.Project.Commands, "screenshot")
- delete(config.Project.Commands, "e2e")
- value, err = MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(configPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "remove global harness")
- priorCalls := runner.calls
- if _, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner}); err == nil || !strings.Contains(err.Error(), "visual:ops") {
- t.Fatalf("unresolvable surface was not named: %v", err)
- }
- if runner.calls != priorCalls {
- t.Fatal("capture ran a harness despite an unresolvable surface")
- }
-}
-
-// Invariant: capability-register --surface writes the surface-scoped command
-// key, and the registered command is what surface resolution selects.
-func TestRegisterCapabilityCommandWithSurface(t *testing.T) {
- repo := captureTestRepo(t, "reviewer-ready")
- registered, err := RegisterCapabilityCommand(repo, "visual", "ops", "npm run capture:ops")
- if err != nil {
- t.Fatal(err)
- }
- if registered.Alias != "visual:ops" {
- t.Fatalf("surface registration wrote the wrong key: %#v", registered)
- }
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- resolution, err := ResolveCapabilityForSurface("visual", "ops", config)
- if err != nil || resolution.Command != "npm run capture:ops" {
- t.Fatalf("registered surface command did not resolve: %#v %v", resolution, err)
- }
- if _, err := RegisterCapabilityCommand(repo, "visual", "Web Ops", "x"); err == nil || !strings.Contains(err.Error(), "kebab") {
- t.Fatalf("invalid surface slug was not rejected: %v", err)
- }
-}
diff --git a/boatstack/changelog.go b/boatstack/changelog.go
deleted file mode 100644
index b75378f..0000000
--- a/boatstack/changelog.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-)
-
-const changelogPath = "CHANGELOG.md"
-
-var changelogCategories = map[string]bool{
- "Added": true,
- "Changed": true,
- "Fixed": true,
- "Removed": true,
- "Security": true,
- "Documentation": true,
- "Maintenance": true,
-}
-
-func isUnreleasedHeading(line string) bool {
- if line == "## Unreleased" || line == "## [Unreleased]" {
- return true
- }
- const prefix = "## [Unreleased] - "
- if !strings.HasPrefix(line, prefix) {
- return false
- }
- _, err := time.Parse("2006-01-02", strings.TrimPrefix(line, prefix))
- return err == nil
-}
-
-// changelogEntries returns the categorized bullets in the Unreleased section.
-// Historical release sections are intentionally ignored: the policy requires a
-// new reader-facing entry for the change being prepared, not rewritten history.
-func changelogEntries(value []byte) (map[string]int, error) {
- entries := map[string]int{}
- inUnreleased := false
- category := ""
- foundUnreleased := false
- for _, rawLine := range strings.Split(strings.ReplaceAll(string(value), "\r\n", "\n"), "\n") {
- line := strings.TrimSpace(rawLine)
- if strings.HasPrefix(line, "## ") {
- if isUnreleasedHeading(line) {
- if foundUnreleased {
- return nil, fmt.Errorf("%s must contain exactly one ## Unreleased section", changelogPath)
- }
- foundUnreleased = true
- inUnreleased = true
- category = ""
- continue
- }
- if inUnreleased {
- inUnreleased = false
- }
- continue
- }
- if !inUnreleased || line == "" {
- continue
- }
- if strings.HasPrefix(line, "### ") {
- category = strings.TrimSpace(strings.TrimPrefix(line, "### "))
- if !changelogCategories[category] {
- return nil, fmt.Errorf("%s uses unsupported Unreleased category %q", changelogPath, category)
- }
- continue
- }
- if strings.HasPrefix(line, "-") {
- entry := strings.TrimSpace(strings.TrimPrefix(line, "-"))
- if category == "" {
- return nil, fmt.Errorf("%s Unreleased entries must appear under an allowed category", changelogPath)
- }
- if entry == "" {
- return nil, fmt.Errorf("%s contains an empty Unreleased entry", changelogPath)
- }
- entries[category+"\x00"+entry]++
- }
- }
- if !foundUnreleased {
- return nil, fmt.Errorf("%s must contain a ## Unreleased section", changelogPath)
- }
- return entries, nil
-}
-
-func readFileAtCommit(repo, commit, path string) ([]byte, bool, error) {
- command := exec.Command("git", "-C", repo, "show", commit+":"+path)
- value, err := command.Output()
- if err == nil {
- return value, true, nil
- }
- if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() != 0 {
- return nil, false, nil
- }
- return nil, false, fmt.Errorf("cannot read %s at base commit: %w", path, err)
-}
-
-func validateChangelogChange(repo, baseCommit string, config ProjectConfig) error {
- if !config.Workflow.MaintainChangelog {
- return nil
- }
- current, err := os.ReadFile(filepath.Join(repo, changelogPath))
- if err != nil {
- if os.IsNotExist(err) {
- return fmt.Errorf("changelog policy requires %s with a new entry under ## Unreleased", changelogPath)
- }
- return err
- }
- currentEntries, err := changelogEntries(current)
- if err != nil {
- return err
- }
- baseEntries := map[string]int{}
- base, exists, err := readFileAtCommit(repo, baseCommit, changelogPath)
- if err != nil {
- return err
- }
- if exists {
- parsed, parseErr := changelogEntries(base)
- if parseErr == nil {
- baseEntries = parsed
- }
- }
- for entry, count := range currentEntries {
- if count > baseEntries[entry] {
- return nil
- }
- }
- return fmt.Errorf("changelog policy requires a new categorized entry under ## Unreleased in %s", changelogPath)
-}
-
-// changelogComparisonBase makes each managed slice prove its own entry. Later
-// slices compare with the previous slice's reviewed head, even when both slices
-// use the same Git base and earlier Unreleased entries are still present. The
-// comparison is anchored to the slice actually being gated or shipped (the
-// addressable slice), not the BUILD pointer — a published-open earlier slice
-// corrected in place compares against ITS predecessor, not the active slice's.
-func changelogComparisonBase(repo, feature, sliceID, mergeBase string) (string, error) {
- if strings.TrimSpace(feature) == "" {
- return mergeBase, nil
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return "", err
- }
- index, _, err := resolveAddressableSlice(state, sliceID)
- if err != nil {
- return "", err
- }
- if index <= 0 {
- return mergeBase, nil
- }
- previous := state.Slices[index-1]
- receipt, err := readDeliveryReceipt(repo, feature, previous.ID, "review")
- if err != nil {
- return "", fmt.Errorf("cannot establish changelog baseline for delivery slice %s: %w", state.Slices[index].ID, err)
- }
- if strings.TrimSpace(receipt.HeadCommit) == "" {
- return "", fmt.Errorf("previous delivery slice %s has no reviewed head commit", previous.ID)
- }
- return receipt.HeadCommit, nil
-}
diff --git a/boatstack/changelog_test.go b/boatstack/changelog_test.go
deleted file mode 100644
index 3d72ad3..0000000
--- a/boatstack/changelog_test.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func changelogConfig() ProjectConfig {
- config := testConfig()
- config.Workflow.MaintainChangelog = true
- return config
-}
-
-func writeChangelog(t *testing.T, repo, value string) {
- t.Helper()
- if err := os.WriteFile(filepath.Join(repo, changelogPath), []byte(value), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestUnreleasedHeadingCompatibility(t *testing.T) {
- for _, test := range []struct {
- heading string
- want bool
- }{
- {heading: "## Unreleased", want: true},
- {heading: "## [Unreleased]", want: true},
- {heading: "## [Unreleased] - 2026-07-19", want: true},
- {heading: "## [Unreleased] - 2026-7-19", want: false},
- {heading: "## [Unreleased] - not-a-date", want: false},
- {heading: "## [1.0.0] - 2026-07-19", want: false},
- } {
- if got := isUnreleasedHeading(test.heading); got != test.want {
- t.Errorf("isUnreleasedHeading(%q) = %v, want %v", test.heading, got, test.want)
- }
- }
-}
-
-func TestChangelogPolicyAcceptsExistingAndFirstEntries(t *testing.T) {
- for _, test := range []struct {
- name string
- base string
- head string
- }{
- {
- name: "existing changelog",
- base: "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing capability.\n\n## 1.0.0\n\n- First release.\n",
- head: "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing capability.\n- New reader-visible capability.\n\n## 1.0.0\n\n- First release.\n",
- },
- {
- name: "first dated changelog",
- head: "# Changelog\n\n## [Unreleased] - 2026-07-19\n\n### Maintenance\n\n- Document the supported delivery workflow.\n",
- },
- {
- name: "existing legacy changelog adopts policy",
- base: "# Changes\n\n## 1.0.0\n\n- First release.\n",
- head: "# Changes\n\n## Unreleased\n\n### Changed\n\n- Adopt readable unreleased entries.\n\n## 1.0.0\n\n- First release.\n",
- },
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if test.base != "" {
- writeChangelog(t, repo, test.base)
- } else if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- base := runGit(t, repo, "rev-parse", "HEAD")
- writeChangelog(t, repo, test.head)
- if err := validateChangelogChange(repo, base, changelogConfig()); err != nil {
- t.Fatalf("valid changelog rejected: %v", err)
- }
- })
- }
-}
-
-func TestChangelogPolicyRejectsMissingMalformedAndHistoricalOnlyChanges(t *testing.T) {
- base := "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing capability.\n\n## 1.0.0\n\n- First release.\n"
- tests := []struct {
- name string
- head string
- want string
- }{
- {name: "no new entry", head: base, want: "new categorized entry"},
- {name: "historical only", head: strings.Replace(base, "- First release.", "- First release.\n- Rewritten history.", 1), want: "new categorized entry"},
- {name: "entry outside category", head: "# Changelog\n\n## Unreleased\n\n- Missing category.\n", want: "allowed category"},
- {name: "empty entry", head: "# Changelog\n\n## Unreleased\n\n### Fixed\n\n- \n", want: "empty"},
- {name: "unsupported category", head: "# Changelog\n\n## Unreleased\n\n### Internal\n\n- Hidden work.\n", want: "unsupported"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- writeChangelog(t, repo, base)
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- baseCommit := runGit(t, repo, "rev-parse", "HEAD")
- writeChangelog(t, repo, test.head)
- if err := validateChangelogChange(repo, baseCommit, changelogConfig()); err == nil || !strings.Contains(err.Error(), test.want) {
- t.Fatalf("expected %q failure, got %v", test.want, err)
- }
- })
- }
-
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- if err := validateChangelogChange(repo, runGit(t, repo, "rev-parse", "HEAD"), changelogConfig()); err == nil || !strings.Contains(err.Error(), "requires CHANGELOG.md") {
- t.Fatalf("missing changelog did not fail: %v", err)
- }
-}
-
-func TestDisabledChangelogPolicyLeavesRepositoriesUnchanged(t *testing.T) {
- if err := validateChangelogChange(t.TempDir(), "unused", testConfig()); err != nil {
- t.Fatalf("disabled changelog policy affected repository: %v", err)
- }
-}
-
-func TestAdHocPRContextEnforcesConfiguredChangelog(t *testing.T) {
- repo := prTestRepo(t)
- configPath := filepath.Join(repo, ".product-loop", "project.json")
- config := changelogConfig()
- config.Project.DefaultBranch = "main"
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(configPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".product-loop/project.json")
- runGit(t, repo, "commit", "-m", "enable changelog policy")
- if _, err := PreparePRContext(PRContextOptions{Repo: repo}); err == nil || !strings.Contains(err.Error(), "requires CHANGELOG.md") {
- t.Fatalf("ad-hoc PR ignored missing changelog: %v", err)
- }
- writeChangelog(t, repo, "# Changelog\n\n## Unreleased\n\n### Added\n\n- Make reviewer-visible behavior predictable.\n")
- runGit(t, repo, "add", changelogPath)
- runGit(t, repo, "commit", "-m", "add changelog entry")
- if _, err := PreparePRContext(PRContextOptions{Repo: repo}); err != nil {
- t.Fatalf("ad-hoc PR rejected valid changelog: %v", err)
- }
-}
diff --git a/boatstack/cmd/boatstack-helper/command_trace.go b/boatstack/cmd/boatstack-helper/command_trace.go
deleted file mode 100644
index e254c77..0000000
--- a/boatstack/cmd/boatstack-helper/command_trace.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package main
-
-import (
- "strings"
- "time"
-
- boatstack "github.com/operatorstack/boatstack/boatstack"
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-type commandTracePolicy struct {
- Category string
- ExcludedReason string
-}
-
-// commandTracePolicies is the declared helper surface inventory. Safety hooks
-// are intentionally excluded because telemetry must not add latency or writes
-// to the enforcement path; every other dispatch is recorded once by run().
-var commandTracePolicies = map[string]commandTracePolicy{
- "attach": {Category: "supervision"}, "detach": {Category: "supervision"},
- "detached-status": {Category: "supervision"}, "engagement-status": {Category: "supervision"}, "config-rebind": {Category: "supervision"}, "context": {Category: "supervision"},
- "activate": {Category: "supervision"}, "deactivate": {Category: "supervision"},
- "init": {Category: "installation"}, "update": {Category: "installation"},
- "check-update": {Category: "installation"}, "repair-status": {Category: "installation"},
- "prepare-update-pr": {Category: "update-publication"}, "publish-update-pr": {Category: "update-publication"},
- "release-classify": {Category: "release"}, "next-patch": {Category: "release"},
- "export": {Category: "installation"}, "migrate-config": {Category: "installation"},
- "hydrate-runtime": {Category: "installation"}, "activate-worktree-runtime": {Category: "installation"}, "doctor": {Category: "readiness"},
- "check-source-plan": {Category: "planning"}, "check-plan": {Category: "planning"},
- "planning-write": {Category: "planning"}, "record-approval": {Category: "planning"},
- "record-autonomy": {Category: "planning"}, "activate-plan": {Category: "delivery"},
- "delivery-status": {Category: "delivery"}, "next-status": {Category: "delivery"},
- "recovery-status": {Category: "recovery"}, "repair-state": {Category: "recovery"},
- "mutation-status": {Category: "recovery"}, "undo": {Category: "recovery"},
- "run-preflight": {Category: "readiness"}, "authority-context": {Category: "readiness"},
- "record-change": {Category: "recovery"}, "record-journey-results": {Category: "evidence"},
- "ignore-delivery": {Category: "delivery"}, "discard-delivery": {Category: "recovery"},
- "record-delivery-gate": {Category: "delivery"}, "record-pr-visual-evidence": {Category: "visual-evidence"},
- "review-pr-visual-evidence": {Category: "visual-evidence"}, "capture-evidence": {Category: "visual-evidence"},
- "provision-capability": {Category: "capability"}, "capability-register": {Category: "capability"},
- "record-pr-visual-publication": {Category: "visual-evidence"}, "attach-evidence": {Category: "visual-evidence"},
- "pr-context": {Category: "publication"}, "check-pr": {Category: "publication"},
- "publish-pr": {Category: "publication"}, "operation-status": {Category: "publication"},
- "diagnose-hook": {Category: "diagnostic"}, "render-denial": {Category: "diagnostic"},
- "check-safety": {Category: "readiness"}, "workspace-cut": {Category: "workspace"},
- "workspace-cleanup": {Category: "workspace"}, "workspace-reap": {Category: "workspace"},
- "workspace-status": {Category: "workspace"}, "workspace-sync": {Category: "workspace"},
- "flow": {Category: "flow"}, "retro": {Category: "analysis"},
- "insight": {Category: "insight"}, "version": {Category: "diagnostic"},
- "safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"},
- "engagement-probe": {Category: "safety", ExcludedReason: "latency-sensitive engagement path"},
- "bootstrap-safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"},
-}
-
-func traceFlag(arguments []string, name string) string {
- for index, argument := range arguments {
- if argument == name && index+1 < len(arguments) {
- return strings.TrimSpace(arguments[index+1])
- }
- if strings.HasPrefix(argument, name+"=") {
- return strings.TrimSpace(strings.TrimPrefix(argument, name+"="))
- }
- }
- return ""
-}
-
-func traceTransition(verb string, arguments []string) deliverycontrol.TransitionID {
- if verb == "record-delivery-gate" {
- switch strings.ToLower(traceFlag(arguments, "--gate")) {
- case "test":
- return "delivery.record_gate_test"
- case "review":
- return "delivery.record_gate_review"
- }
- }
- for _, transition := range deliverycontrol.Transitions() {
- if transition.CLIVerb == verb {
- return transition.ID
- }
- }
- return ""
-}
-
-func commandTraceCompletion(verb string, arguments []string) func(int) {
- policy, ok := commandTracePolicies[verb]
- if !ok || policy.ExcludedReason != "" {
- return nil
- }
- started := time.Now()
- recordedVerb := verb
- if (verb == "flow" || verb == "retro" || verb == "insight") && len(arguments) > 0 && !strings.HasPrefix(arguments[0], "-") {
- recordedVerb += "/" + arguments[0]
- }
- repo := traceFlag(arguments, "--repo")
- if repo == "" {
- repo = "."
- }
- feature := traceFlag(arguments, "--feature")
- slice := traceFlag(arguments, "--slice")
- transition := traceTransition(verb, arguments)
- return func(exitCode int) {
- boatstack.RecordCommandEvent(boatstack.CommandTraceInput{
- Repo: repo, Verb: recordedVerb, Category: policy.Category, Feature: feature, Slice: slice,
- Transition: transition, StartedAt: started, FinishedAt: time.Now(), ExitCode: exitCode,
- })
- }
-}
diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go
deleted file mode 100644
index 7ddc568..0000000
--- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go
+++ /dev/null
@@ -1,268 +0,0 @@
-package main
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "sort"
- "strings"
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: registry-covers-real-delivery-machine
-//
-// The deliverycontrol registry is the authoritative projection of the real
-// delivery state machine: the flow commands resolve the prescribed CLI command
-// through it (Transition(id).CLIVerb). For that projection to be trustworthy, the
-// registry must cover EXACTLY the real delivery machine — every registry CLIVerb
-// must name a real dispatch verb, and every real delivery-mutation/observe
-// dispatch verb must have a registry row. Nothing asserted this before, so a new
-// delivery CLI verb could ship green with no registry row, invisible to the
-// liveness/deadlock guarantee. These tests close that gap in both directions.
-//
-// The real dispatch inventory is read from the actual `run()` switch in main.go
-// by parsing its AST, so it cannot drift from what the binary really accepts.
-// Every dispatch verb must be classified: either it is a delivery-machine verb
-// (a registry CLIVerb) or it is explicitly declared out of the delivery machine
-// in nonDeliveryVerbs below. A new verb that is neither fails the suite — the
-// author must consciously register it or declare it non-delivery (the Bypass
-// guard). This list is behavior describing, not behavior defining: it names the
-// dispatch verbs that are not transitions of the delivery state machine.
-var nonDeliveryVerbs = map[string]bool{
- // Update / release / distribution lifecycle (not the per-feature delivery machine).
- "init": true,
- "update": true,
- "check-update": true,
- "prepare-update-pr": true,
- "publish-update-pr": true,
- "release-classify": true,
- "next-patch": true,
- "export": true,
- "migrate-config": true,
- "hydrate-runtime": true,
- "activate-worktree-runtime": true,
- "version": true,
- // Planning phase, before a plan is activated into a delivery.
- "check-source-plan": true,
- "check-plan": true,
- "record-autonomy": true,
- // Read-only status / diagnostics (observe helpers, not modeled transitions).
- "repair-status": true,
- "operation-status": true,
- "mutation-status": true,
- "run-preflight": true,
- "authority-context": true,
- "check-safety": true,
- "doctor": true,
- "diagnose-hook": true,
- "render-denial": true,
- "workspace-status": true,
- // Evidence / capability substrate (a separate tenant, not the delivery graph).
- "record-pr-visual-evidence": true,
- "review-pr-visual-evidence": true,
- "capture-evidence": true,
- "provision-capability": true,
- "capability-register": true,
- "record-pr-visual-publication": true,
- "attach-evidence": true,
- // PR construction / verification helpers reached around the ship gate.
- "check-pr": true,
- // Detached Supervision lifecycle (control-plane ownership, not delivery moves).
- "attach": true,
- "detach": true,
- "detached-status": true,
- "engagement-status": true,
- "config-rebind": true,
- "context": true,
- "activate": true,
- "deactivate": true,
- // Safety hooks and workspace management (guard/scaffold, not delivery moves).
- "safety-hook": true,
- "engagement-probe": true,
- "bootstrap-safety-hook": true,
- "workspace-cut": true,
- "workspace-cleanup": true,
- "workspace-reap": true,
- "workspace-sync": true,
- // Flow layer itself is read-only navigation over the machine, not a transition.
- "flow": true,
- // Insight capture is a detached control-plane tenant. Its append-only events
- // observe delivery evidence but never transition the delivery machine.
- "insight": true,
- // Retro derivation reads operator-supplied transcripts and proposes typed
- // promotions; it mutates nothing, so it registers no delivery transition.
- // control-law: retro-proposes-never-enforces
- "retro": true,
-}
-
-// dispatchVerbs parses main.go and returns the set of command verbs the run()
-// switch actually dispatches. It fails loudly rather than returning an empty set,
-// so the coverage guarantee can never silently pass by finding nothing.
-func dispatchVerbs(t *testing.T) map[string]bool {
- t.Helper()
- fset := token.NewFileSet()
- file, err := parser.ParseFile(fset, "main.go", nil, 0)
- if err != nil {
- t.Fatalf("parse main.go: %v", err)
- }
- verbs := map[string]bool{}
- ast.Inspect(file, func(n ast.Node) bool {
- fn, ok := n.(*ast.FuncDecl)
- if !ok || fn.Name.Name != "run" {
- return true
- }
- ast.Inspect(fn.Body, func(inner ast.Node) bool {
- sw, ok := inner.(*ast.SwitchStmt)
- if !ok || !switchesOnArgs(sw.Tag) {
- return true
- }
- for _, stmt := range sw.Body.List {
- clause, ok := stmt.(*ast.CaseClause)
- if !ok {
- continue
- }
- for _, expr := range clause.List {
- if lit, ok := expr.(*ast.BasicLit); ok && lit.Kind == token.STRING {
- verbs[mustUnquote(t, lit.Value)] = true
- }
- }
- }
- return false
- })
- return false
- })
- if len(verbs) < 10 {
- t.Fatalf("dispatch switch parse found only %d verbs; expected the full run() command set — the coverage guard would be vacuous", len(verbs))
- }
- return verbs
-}
-
-// control-law: every-helper-dispatch-is-observed-or-explicitly-excluded
-func TestCommandTraceInventoryCoversDispatchSurfaceExactly(t *testing.T) {
- dispatch := dispatchVerbs(t)
- for verb := range dispatch {
- policy, ok := commandTracePolicies[verb]
- if !ok {
- t.Errorf("dispatch verb %q has no command trace policy", verb)
- continue
- }
- _ = policy
- }
- for verb := range commandTracePolicies {
- if !dispatch[verb] {
- t.Errorf("command trace policy names stale verb %q", verb)
- }
- }
- allowedExcluded := map[string]bool{"safety-hook": true, "engagement-probe": true, "bootstrap-safety-hook": true}
- for verb, policy := range commandTracePolicies {
- if policy.ExcludedReason != "" && !allowedExcluded[verb] {
- t.Errorf("only latency-sensitive safety hooks may be excluded; found %q", verb)
- }
- if allowedExcluded[verb] && strings.TrimSpace(policy.ExcludedReason) == "" {
- t.Errorf("excluded safety hook %q has no reason", verb)
- }
- }
-}
-
-// switchesOnArgs reports whether a switch tag is an index into os.Args (the
-// command dispatch), e.g. `os.Args[1]`.
-func switchesOnArgs(tag ast.Expr) bool {
- index, ok := tag.(*ast.IndexExpr)
- if !ok {
- return false
- }
- sel, ok := index.X.(*ast.SelectorExpr)
- return ok && sel.Sel.Name == "Args"
-}
-
-func mustUnquote(t *testing.T, quoted string) string {
- t.Helper()
- if len(quoted) < 2 {
- t.Fatalf("malformed string literal %q in dispatch switch", quoted)
- }
- return quoted[1 : len(quoted)-1]
-}
-
-func registryVerbs() map[string]bool {
- verbs := map[string]bool{}
- for _, tr := range deliverycontrol.Transitions() {
- if tr.CLIVerb != "" {
- verbs[tr.CLIVerb] = true
- }
- }
- return verbs
-}
-
-// Positive: every CLIVerb the registry declares names a real dispatch verb, so
-// the prescribed command a resolver emits through Transition(id).CLIVerb is
-// always a command the binary actually accepts.
-func TestRegistryCLIVerbsAreRealDispatchVerbs(t *testing.T) {
- dispatch := dispatchVerbs(t)
- for verb := range registryVerbs() {
- if !dispatch[verb] {
- t.Errorf("registry declares CLIVerb %q that main.go does not dispatch (prescribed command would be unrunnable)", verb)
- }
- }
-}
-
-// Bypass / Negative: every dispatch verb must be classified — a delivery-machine
-// verb (registry CLIVerb) or explicitly non-delivery. A new delivery CLI verb
-// added to the dispatch switch without a registry row (or a conscious
-// non-delivery declaration) fails here; it cannot ship invisibly to the machine.
-func TestEveryDispatchVerbIsClassified(t *testing.T) {
- dispatch := dispatchVerbs(t)
- registry := registryVerbs()
- for verb := range dispatch {
- if registry[verb] {
- continue
- }
- if nonDeliveryVerbs[verb] {
- continue
- }
- t.Errorf("dispatch verb %q is neither a registry delivery transition nor declared in nonDeliveryVerbs; register it or classify it before shipping", verb)
- }
-}
-
-// Relation: the delivery-machine dispatch verbs (all dispatch verbs minus the
-// declared non-delivery ones) equal the registry CLIVerb set exactly — the two
-// inventories agree with no orphan on either side.
-func TestDeliveryDispatchVerbsEqualRegistry(t *testing.T) {
- dispatch := dispatchVerbs(t)
- registry := registryVerbs()
-
- deliveryDispatch := map[string]bool{}
- for verb := range dispatch {
- if !nonDeliveryVerbs[verb] {
- deliveryDispatch[verb] = true
- }
- }
- if diff := symmetricDiff(deliveryDispatch, registry); len(diff) != 0 {
- sort.Strings(diff)
- t.Errorf("delivery dispatch verbs and registry CLIVerbs disagree: %v", diff)
- }
-
- // A non-delivery declaration must name a verb that is actually dispatched;
- // a stale entry (verb renamed/removed) is drift and must be cleaned up.
- for verb := range nonDeliveryVerbs {
- if !dispatch[verb] {
- t.Errorf("nonDeliveryVerbs names %q which main.go no longer dispatches (stale allowlist entry)", verb)
- }
- }
-}
-
-func symmetricDiff(a, b map[string]bool) []string {
- var diff []string
- for k := range a {
- if !b[k] {
- diff = append(diff, "only-in-dispatch:"+k)
- }
- }
- for k := range b {
- if !a[k] {
- diff = append(diff, "only-in-registry:"+k)
- }
- }
- return diff
-}
diff --git a/boatstack/cmd/boatstack-helper/flow.go b/boatstack/cmd/boatstack-helper/flow.go
deleted file mode 100644
index bc9e78d..0000000
--- a/boatstack/cmd/boatstack-helper/flow.go
+++ /dev/null
@@ -1,313 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "io"
- "os"
- "time"
-
- boatstack "github.com/operatorstack/boatstack/boatstack"
-)
-
-// flowCommand is the read-only entry point for delivery-flow navigation:
-// `flow check` gates the owned model, `flow next` advises the lowest-cost next
-// move. Both are additive and side-effect free; they change no existing command,
-// gate, authority, or exit code.
-func flowCommand(arguments []string) int {
- if len(arguments) == 0 {
- fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ")
- return 2
- }
- switch arguments[0] {
- case "bootstrap":
- return flowBootstrapCommand(arguments[1:])
- case "check":
- return flowCheckCommand(arguments[1:])
- case "next":
- return flowNextCommand(arguments[1:])
- case "tasks":
- return flowTasksCommand(arguments[1:])
- case "frontier":
- return flowFrontierCommand(arguments[1:])
- case "watch":
- return flowWatchCommand(arguments[1:])
- case "report":
- return flowReportCommand(arguments[1:])
- default:
- fmt.Fprintln(os.Stderr, "unknown flow subcommand:", arguments[0])
- return 2
- }
-}
-
-// flowBootstrapCommand renders the exact literal planning envelope for one
-// workspace. It reads the proposed Markdown from stdin and changes no state.
-// control-law: bootstrap-command-authority-is-workspace-bound
-func flowBootstrapCommand(arguments []string) int {
- flags := flag.NewFlagSet("flow bootstrap", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose planning workspace should be resolved")
- feature := flags.String("feature", "", "new or saved planning feature slug")
- sourcePlan := flags.String("source-plan", "", "required in-repo source plan")
- artifact := flags.String("artifact", "", "planning artifact to write")
- shell := flags.String("shell", "", "target shell: posix or powershell")
- jsonOutput := flags.Bool("json", false, "print the structured bootstrap prescription")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *feature == "" || *sourcePlan == "" || *artifact == "" || *shell == "" {
- return fail(fmt.Errorf("flow bootstrap requires --feature, --source-plan, --artifact, and --shell posix|powershell; Markdown is read from stdin"))
- }
- document, err := io.ReadAll(os.Stdin)
- if err != nil {
- return fail(err)
- }
- prescription, err := boatstack.ResolvePlanningBootstrap(boatstack.BootstrapOptions{
- Repo: *repo, Feature: *feature, SourcePlan: *sourcePlan, Artifact: *artifact,
- Shell: boatstack.BootstrapShell(*shell), Document: document,
- })
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(prescription)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(prescription.PlanningEnvelope)
- }
- return 0
-}
-
-// flowCheckCommand runs the static conformance + liveness gate over the delivery
-// model and exits non-zero on drift. It reads no repository state.
-func flowCheckCommand(arguments []string) int {
- flags := flag.NewFlagSet("flow check", flag.ContinueOnError)
- jsonOutput := flags.Bool("json", false, "print the structured check result")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result := boatstack.FlowCheck()
- if *jsonOutput {
- value, err := boatstack.MarshalJSON(result)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(boatstack.FormatFlowCheck(result))
- }
- if !result.OK {
- return 1
- }
- return 0
-}
-
-// flowNextCommand advises the lowest-cost next move toward a published delivery.
-// It is purely advisory and never fails on flow position — an unresolved flow
-// still prints the authoritative recommendation. With --execute (opt-in,
-// default-off), it additionally runs any move the driver proves safe and fully
-// state-derivable, and prescribes-and-stops at the first human/evidence gate.
-func flowNextCommand(arguments []string) int {
- flags := flag.NewFlagSet("flow next", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose delivery flow should be advised")
- feature := flags.String("feature", "", "optional specific managed feature to advise")
- jsonOutput := flags.Bool("json", false, "print the structured advisory")
- execute := flags.Bool("execute", false, "opt in to auto-running safe, state-derivable moves (default off; stops at human/evidence gates)")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- next, err := boatstack.NextControl(*repo, *feature)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(next)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(boatstack.FormatFlowNext(next))
- }
- if *execute {
- return driveExecute(*repo, *feature, next)
- }
- return 0
-}
-
-// driveExecute runs the opt-in execute driver: it takes the oracle's lowest-cost
-// edge and runs it only when the driver proves the move is safe and fully
-// state-derivable, re-resolving after each executed step. At the first move that
-// owes human input, is off the auto-drive allowlist, or when the kill switch is
-// set, it prescribes-and-stops. It never fabricates evidence, a gate status, a
-// preview fingerprint, or reviewer identity. The loop is bounded as a backstop
-// against any cycle in the model.
-func driveExecute(repo, feature string, next boatstack.FlowNext) int {
- const maxDriveSteps = 32
- for step := 0; step < maxDriveSteps; step++ {
- decision := boatstack.DecideDrive(next, true, boatstack.FlowDriveKilled())
- switch decision.Action {
- case boatstack.DriveNone:
- fmt.Println("drive: nothing to run —", decision.Reason)
- return 0
- case boatstack.DrivePrescribe:
- fmt.Println("drive: stopping —", decision.Reason)
- if decision.Command != nil {
- fmt.Println("drive: run by hand:", decision.Command.CommandLine())
- }
- return 0
- case boatstack.DriveExecute:
- fmt.Println("drive: running", decision.Command.CommandLine())
- if err := executePrescribed(decision.Command); err != nil {
- return fail(err)
- }
- // Re-resolve from ground truth so the next decision reflects the mutation
- // the executed command committed, never an assumed position.
- resolved, err := boatstack.NextControl(repo, feature)
- if err != nil {
- return fail(err)
- }
- next = resolved
- default:
- return fail(fmt.Errorf("drive: unknown decision %q", decision.Action))
- }
- }
- fmt.Println("drive: step budget exhausted; stopping")
- return 0
-}
-
-// executePrescribed is the driver's second, independent gate: even a move the pure
-// decision blessed as auto-drivable runs only if an executor is explicitly
-// registered here for its verb. It never synthesizes arguments; it only invokes the
-// same verb dispatch a human would run.
-func executePrescribed(cmd *boatstack.PrescribedCommand) error {
- switch cmd.Verb {
- case "publish-pr":
- if code := publishPRCommand(cmd.Args); code != 0 {
- return fmt.Errorf("publish-pr exited with status %d", code)
- }
- return nil
- default:
- return fmt.Errorf("no registered auto-executor for verb %q; run it by hand: %s", cmd.Verb, cmd.CommandLine())
- }
-}
-
-// flowFrontierCommand renders the cross-delivery frontier dashboard: one row
-// per managed delivery slice with its observed position and the actor who owes
-// the next step. Strictly read-only — it performs zero writes, including the
-// terminal PR-state cache that next/recovery maintain.
-// control-law: frontier-reports-never-mutates
-func flowFrontierCommand(arguments []string) int {
- flags := flag.NewFlagSet("flow frontier", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose delivery frontier should be reported")
- jsonOutput := flags.Bool("json", false, "print the structured frontier report")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- frontier, err := boatstack.ResolveFrontier(*repo)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(frontier)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(boatstack.FormatFlowFrontier(frontier))
- }
- return 0
-}
-
-// flowWatchCommand runs the bounded observe-compare loop: re-observe the
-// frontier on an interval, exit 0 the moment it changes (or when nothing can
-// move), exit 1 when the timeout passes with no change. It observes and
-// exits; it never acts on what it sees.
-// control-law: watch-observes-and-exits-never-acts
-func flowWatchCommand(arguments []string) int {
- flags := flag.NewFlagSet("flow watch", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose delivery frontier should be watched")
- interval := flags.Duration("interval", 30*time.Second, "time between frontier observations")
- timeout := flags.Duration("timeout", 30*time.Minute, "maximum time to wait for a frontier change")
- jsonOutput := flags.Bool("json", false, "print the structured watch result")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result, err := boatstack.WatchFrontier(boatstack.FlowWatchOptions{
- Repo: *repo, Interval: *interval, Timeout: *timeout,
- })
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(result)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(boatstack.FormatFlowWatch(result))
- }
- if result.Outcome == boatstack.WatchOutcomeTimeout {
- return 1
- }
- return 0
-}
-
-// flowTasksCommand renders the active delivery slice's sub-actions from the
-// compiled plan task DAG, in dependency order, with the one to start pointed at.
-// It is read-only and never fails on flow position — an unresolved slice or an
-// unreadable task graph prints its reason rather than a guessed sub-action.
-func flowTasksCommand(arguments []string) int {
- flags := flag.NewFlagSet("flow tasks", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose active-slice sub-actions should be listed")
- feature := flags.String("feature", "", "managed Boatstack feature slug")
- jsonOutput := flags.Bool("json", false, "print the structured task ordering")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- tasks, err := boatstack.FlowTasksForActiveSlice(*repo, *feature)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(tasks)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(boatstack.FormatFlowTasks(tasks))
- }
- return 0
-}
-
-// flowReportCommand renders the session's flow-navigation regret and coding-effort
-// telemetry from the shadow logs. It is read-only and never fails on an empty
-// session — it simply reports zero steps.
-func flowReportCommand(arguments []string) int {
- flags := flag.NewFlagSet("flow report", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose flow session should be reported")
- feature := flags.String("feature", "", "optional managed feature used to filter command telemetry")
- jsonOutput := flags.Bool("json", false, "print the structured report")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- report, err := boatstack.FlowReportFor(*repo, *feature)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(report)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(boatstack.FormatFlowReport(report))
- }
- return 0
-}
diff --git a/boatstack/cmd/boatstack-helper/insight.go b/boatstack/cmd/boatstack-helper/insight.go
deleted file mode 100644
index 8c99bbd..0000000
--- a/boatstack/cmd/boatstack-helper/insight.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "io"
- "os"
- "strings"
-
- boatstack "github.com/operatorstack/boatstack/boatstack"
-)
-
-func readInsightInput(path string) ([]byte, error) {
- path = strings.TrimSpace(path)
- if path == "" || path == "-" {
- return io.ReadAll(os.Stdin)
- }
- return os.ReadFile(path)
-}
-
-func printInsightView(view boatstack.InsightView, jsonOutput bool) int {
- if jsonOutput {
- return emitJSON(view)
- }
- fmt.Printf("Insight %s: %s\n", view.Capture.ID, view.Evaluation.State)
- fmt.Println(view.Evaluation.Reason)
- fmt.Printf("Repository diff: %s\n", view.RepositoryPath)
- return 0
-}
-
-func insightCommand(arguments []string) int {
- if len(arguments) == 0 {
- fmt.Fprintln(os.Stderr, "usage: boatstack-helper insight ")
- return 2
- }
- switch arguments[0] {
- case "check":
- flags := flag.NewFlagSet("insight check", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose insight inbox should be checked")
- input := flags.String("input", "-", "capture JSON file, or - for stdin")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- value, err := readInsightInput(*input)
- if err != nil {
- return fail(err)
- }
- result, err := boatstack.CheckInsightCapture(*repo, value)
- if err != nil {
- return fail(err)
- }
- return emitJSON(result)
- case "save":
- flags := flag.NewFlagSet("insight save", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose tracked insight inbox should receive the capture")
- input := flags.String("input", "-", "capture JSON file, or - for stdin")
- nonce := flags.String("preview-nonce", "", "nonce returned by insight check")
- fingerprint := flags.String("preview-fingerprint", "", "fingerprint returned by insight check")
- jsonOutput := flags.Bool("json", false, "print the structured capture")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- value, err := readInsightInput(*input)
- if err != nil {
- return fail(err)
- }
- view, err := boatstack.SaveInsightCapture(*repo, value, *nonce, *fingerprint)
- if err != nil {
- return fail(err)
- }
- return printInsightView(view, *jsonOutput)
- case "list":
- flags := flag.NewFlagSet("insight list", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose captures should be listed")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- views, err := boatstack.ListInsights(*repo)
- if err != nil {
- return fail(err)
- }
- return emitJSON(views)
- case "show":
- flags := flag.NewFlagSet("insight show", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose capture should be shown")
- id := flags.String("id", "", "insight capture id")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- view, err := boatstack.ShowInsight(*repo, *id)
- if err != nil {
- return fail(err)
- }
- return emitJSON(view)
- case "associate":
- flags := flag.NewFlagSet("insight associate", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose capture should be associated")
- id := flags.String("id", "", "insight capture id")
- primary := flags.String("primary-topic", "", "human-confirmed primary feature topic")
- var related stringList
- flags.Var(&related, "related-topic", "related feature topic (repeatable)")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- view, err := boatstack.AssociateInsight(*repo, *id, *primary, related)
- if err != nil {
- return fail(err)
- }
- return emitJSON(view)
- case "bind":
- flags := flag.NewFlagSet("insight bind", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose capture should be bound")
- id := flags.String("id", "", "insight capture id")
- feature := flags.String("feature", "", "managed feature id")
- var criteria stringList
- flags.Var(&criteria, "criterion", "mapped acceptance criterion id (repeatable)")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- view, err := boatstack.BindInsight(*repo, *id, *feature, criteria)
- if err != nil {
- return fail(err)
- }
- return emitJSON(view)
- case "evaluate":
- flags := flag.NewFlagSet("insight evaluate", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose capture should be evaluated")
- id := flags.String("id", "", "insight capture id")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- result, err := boatstack.EvaluateInsight(*repo, *id)
- if err != nil {
- return fail(err)
- }
- return emitJSON(result)
- case "frontier":
- flags := flag.NewFlagSet("insight frontier", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose pending insight frontier should be shown")
- jsonOutput := flags.Bool("json", false, "print the structured frontier")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- report, err := boatstack.InsightFrontier(*repo)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- return emitJSON(report)
- }
- fmt.Print(boatstack.FormatInsightFrontier(report))
- return 0
- case "disposition":
- flags := flag.NewFlagSet("insight disposition", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose capture should be dispositioned")
- id := flags.String("id", "", "insight capture id")
- outcome := flags.String("outcome", "", "completed, deferred, rejected, or duplicate")
- reason := flags.String("reason", "", "human reason, required for non-ready completion and non-complete outcomes")
- duplicateOf := flags.String("duplicate-of", "", "original capture id for duplicate outcomes")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- view, err := boatstack.DisposeInsight(*repo, *id, *outcome, *reason, *duplicateOf)
- if err != nil {
- return fail(err)
- }
- return emitJSON(view)
- default:
- fmt.Fprintln(os.Stderr, "unknown insight subcommand:", arguments[0])
- return 2
- }
-}
diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go
index f2a8c7d..c0bccd7 100644
--- a/boatstack/cmd/boatstack-helper/main.go
+++ b/boatstack/cmd/boatstack-helper/main.go
@@ -1,1798 +1,548 @@
package main
import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
+ "errors"
"flag"
"fmt"
"io"
"os"
+ "os/signal"
"path/filepath"
- "sort"
+ "runtime/debug"
"strings"
"time"
boatstack "github.com/operatorstack/boatstack/boatstack"
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
+ "github.com/operatorstack/boatstack/boatstack/analysis"
+ "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/surfaces"
)
-func fail(err error) int {
- fmt.Fprintln(os.Stderr, boatstack.FormatBlocked(os.Stderr, err.Error()))
- return 1
-}
-
-const cursorHookSettleDelay = 50 * time.Millisecond
+type stringList []string
-var hookOutputSleep = time.Sleep
-
-func emitHookOutput(writer io.Writer, host string, value []byte) error {
- if len(value) == 0 {
- return nil
- }
- if _, err := writer.Write(value); err != nil {
- return err
- }
- // Cursor currently has a host-side race that can lose output from compiled
- // hooks which exit immediately. Keep the workaround isolated to its adapter.
- if strings.EqualFold(strings.TrimSpace(host), "cursor") {
- hookOutputSleep(cursorHookSettleDelay)
- }
+func (s *stringList) String() string { return strings.Join(*s, ",") }
+func (s *stringList) Set(value string) error {
+ *s = append(*s, value)
return nil
}
-func failSafetyHook(err error) int {
- fmt.Fprintln(os.Stderr, boatstack.FormatBlocked(os.Stderr, err.Error()))
- // Claude Code and Codex both define exit 2 as a blocking PreToolUse error.
- // Exit 1 is non-blocking in Claude and must never represent policy failure.
- return 2
-}
-
-func initCommand(arguments []string) int {
- flags := flag.NewFlagSet("init", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to initialize")
- binary := flags.String("binary", "", "helper binary to install project-locally; must self-report this process's version (use the target binary's own update for a different version)")
- integrations := flags.String("integrations", "", "core, gstack, spec-kit, or both")
- yes := flags.Bool("yes", false, "accept the generated-file preview; optional integrations still default to core")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- err := boatstack.RunInit(boatstack.InitOptions{Repo: *repo, BinaryPath: *binary, IntegrationChoice: *integrations, Yes: *yes})
- if err != nil {
- return fail(err)
- }
- return 0
-}
-
-func updateCommand(arguments []string) int {
- flags := flag.NewFlagSet("update", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to update")
- binary := flags.String("binary", "", "helper binary to install; its own self-reported version is installed (cross-version updates re-exec it)")
- yes := flags.Bool("yes", false, "accept the generated-file preview")
- repair := flags.Bool("repair", false, "repair only fingerprinted Boatstack-owned control state")
- allowDowngrade := flags.Bool("allow-downgrade", false, "permit an explicitly repaired downgrade")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- err := boatstack.RunUpdate(boatstack.InitOptions{Repo: *repo, BinaryPath: *binary, Yes: *yes, Repair: *repair, AllowDowngrade: *allowDowngrade})
- if err != nil {
- return fail(err)
- }
- return 0
-}
-
-func emitJSON(value any) int {
- raw, err := boatstack.MarshalJSON(value)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(raw))
- return 0
+type commandOptions struct {
+ repository string
+ format string
+ goalID string
+ goalKind string
+ deliveryID string
+ flowID string
+ transitionID string
+ idempotencyKey string
+ humanActor string
+ repositoryPolicy bool
+ parameters stringList
+ authorityReceipts stringList
+ follow bool
+ host string
+ command string
}
-func applyStateRoot(override string) {
- if strings.TrimSpace(override) != "" {
- _ = os.Setenv("BOATSTACK_STATE_ROOT", override)
+func main() {
+ if err := run(os.Args[1:]); err != nil {
+ fmt.Fprintln(os.Stderr, "boatstack:", err)
+ os.Exit(1)
}
}
-func attachCommand(arguments []string) int {
- flags := flag.NewFlagSet("attach", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to attach")
- mode := flags.String("mode", "detached", "supervision mode; only \"detached\" is supported by attach")
- stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)")
- config := flags.String("config", "", "external project configuration to validate and copy into detached control state")
- force := flags.Bool("force", false, "re-attach even if the repository is already attached")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *mode != "detached" {
- return fail(fmt.Errorf("attach supports only --mode detached"))
+func run(arguments []string) error {
+ if len(arguments) == 0 {
+ return usageError()
}
- applyStateRoot(*stateRoot)
- result, err := boatstack.AttachDetached(boatstack.AttachOptions{Repo: *repo, ConfigPath: *config, Force: *force})
- if err != nil {
- return fail(err)
+ command := arguments[0]
+ if command == "version" || command == "--version" {
+ fmt.Println(boatstack.Version)
+ return nil
}
- code := emitJSON(result)
- if result.VerificationStatus == "BLOCKED" {
- return 1
+ if command == "rpc" {
+ return runRPC()
}
- return code
-}
-
-func detachCommand(arguments []string) int {
- flags := flag.NewFlagSet("detach", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to detach")
- stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)")
- preserve := flags.Bool("preserve-state", false, "keep the external controller state instead of removing it")
- if err := flags.Parse(arguments); err != nil {
- return 2
+ if command == "retro" {
+ return runRetrospective(arguments[1:])
}
- applyStateRoot(*stateRoot)
- result, err := boatstack.DetachDetached(boatstack.DetachOptions{Repo: *repo, PreserveState: *preserve})
+ operation, transition, defaults, err := classifyCommand(command)
if err != nil {
- return fail(err)
- }
- code := emitJSON(result)
- if result.VerificationStatus == "BLOCKED" {
- return 1
- }
- return code
-}
-
-func detachedStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("detached-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to inspect")
- stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)")
- _ = flags.Bool("json", true, "emit the versioned JSON projection (always JSON)")
- if err := flags.Parse(arguments); err != nil {
- return 2
+ return err
}
- applyStateRoot(*stateRoot)
- result, err := boatstack.DetachedStatus(*repo)
+ options, err := parseOptions(command, arguments[1:], transition, defaults)
if err != nil {
- return fail(err)
- }
- return emitJSON(result)
-}
-
-func engagementStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("engagement-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose current engagement should be resolved")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- return emitJSON(boatstack.ResolveEngagement(*repo, boatstack.EngagementRequest{}))
-}
-
-func configRebindCommand(arguments []string) int {
- flags := flag.NewFlagSet("config-rebind", flag.ContinueOnError)
- repo := flags.String("repo", ".", "attached repository whose configuration authority should be rebound")
- source := flags.String("source", "", "authoritative source: repository, controller, or file")
- configPath := flags.String("config", "", "external configuration path used with --source file")
- apply := flags.Bool("apply", false, "apply the fingerprinted preview")
- expectedFingerprint := flags.String("expected-fingerprint", "", "exact preview fingerprint required by --apply")
- jsonOutput := flags.Bool("json", false, "render the result as JSON")
- if err := flags.Parse(arguments); err != nil {
- return 2
+ return err
}
- result, err := boatstack.ConfigRebind(boatstack.ConfigRebindOptions{
- Repo: *repo, Source: *source, ConfigPath: *configPath,
- Apply: *apply, ExpectedFingerprint: *expectedFingerprint,
- })
+ request, err := buildRequest(operation, options)
if err != nil {
- return fail(err)
+ return err
}
- value, err := json.Marshal(result)
+ kernel, err := boatstack.NewV2Kernel("")
if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- fmt.Println(string(value))
- } else {
- fmt.Println(result.Reason)
- if result.NextOperation != "" {
- fmt.Println("NEXT=" + result.NextOperation)
- }
- }
- if result.VerificationStatus != "VERIFIED" {
- return 1
- }
- return 0
-}
-
-func activateCommand(arguments []string) int {
- flags := flag.NewFlagSet("activate", flag.ContinueOnError)
- repo := flags.String("repo", ".", "attached repository to activate")
- host := flags.String("host", "", "limit to one coding agent (cursor|claude|codex|gemini); default all")
- stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)")
- print := flags.Bool("print", false, "only print the per-agent config to add; do not install it")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- applyStateRoot(*stateRoot)
- var hosts []string
- if strings.TrimSpace(*host) != "" {
- hosts = []string{*host}
+ return err
}
- if *print {
- result, err := boatstack.DetachedActivationPlan(*repo, hosts)
- if err != nil {
- return fail(err)
+ response, handleErr := kernel.Handle(context.Background(), request)
+ if command == "events" && options.follow {
+ if options.format != "jsonl" {
+ return fmt.Errorf("events --follow requires --format jsonl")
}
- return emitJSON(result)
- }
- result, err := boatstack.InstallEngagementProbes(*repo, hosts)
- if err != nil {
- return fail(err)
- }
- code := emitJSON(result)
- if result.VerificationStatus == "BLOCKED" {
- return 1
- }
- return code
-}
-
-func deactivateCommand(arguments []string) int {
- flags := flag.NewFlagSet("deactivate", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to deactivate")
- host := flags.String("host", "", "limit to one coding agent (cursor|claude|codex|gemini); default all")
- stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- applyStateRoot(*stateRoot)
- var hosts []string
- if strings.TrimSpace(*host) != "" {
- hosts = []string{*host}
- }
- result, err := boatstack.RemoveEngagementProbes(*repo, hosts)
- if err != nil {
- return fail(err)
- }
- code := emitJSON(result)
- if result.VerificationStatus == "BLOCKED" {
- return 1
- }
- return code
-}
-
-func contextCommand(arguments []string) int {
- flags := flag.NewFlagSet("context", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to project context for")
- operation := flags.String("operation", "", "the operation about to run (advisory)")
- host := flags.String("host", "", "the coding-agent host (advisory)")
- stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)")
- _ = flags.Bool("json", true, "emit the versioned JSON projection (always JSON)")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- applyStateRoot(*stateRoot)
- result, err := boatstack.ProjectOperatorContext(*repo, *operation, *host)
- if err != nil {
- return fail(err)
- }
- return emitJSON(result)
-}
-
-func repairStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("repair-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository installation to inspect")
- allowDowngrade := flags.Bool("allow-downgrade", false, "include explicit downgrade authority in the projection")
- jsonOutput := flags.Bool("json", false, "emit the versioned JSON projection")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- config, _, err := boatstack.LoadConfig(boatstack.WorkspaceFor(*repo).SourceConfigPath())
- if err != nil {
- return fail(err)
- }
- result, err := boatstack.ClassifyInstallationRepair(*repo, config.Adapters, *allowDowngrade)
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(result)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- fmt.Print(string(value))
- } else {
- fmt.Printf("REPAIR_STATUS=%s\nDIRECTION=%s\nPACKAGE_FINGERPRINT=%s\nNEXT_OPERATION=%s\n", result.VerificationStatus, result.Direction, result.PackageFingerprint, result.NextOperation)
+ return followEvents(kernel, request)
}
- if result.VerificationStatus == "BLOCKED" {
- return 1
+ if renderErr := renderResponse(response, options.format); renderErr != nil {
+ return renderErr
}
- return 0
+ return handleErr
}
-func checkUpdateCommand(arguments []string) int {
- flags := flag.NewFlagSet("check-update", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Boatstack release should be checked")
- force := flags.Bool("force", false, "ignore the 24-hour release cache")
- notify := flags.Bool("notify", false, "record a bounded post-ship notification")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result, err := boatstack.CheckForUpdate(boatstack.UpdateCheckOptions{Repo: *repo, Force: *force, Notify: *notify})
- if err != nil {
- return fail(err)
- }
- fmt.Printf("UPDATE_STATUS=%s\nCURRENT_VERSION=%s\nLATEST_VERSION=%s\nRELEASE_NAME=%q\nRELEASE_NOTES=%q\nRELEASE_URL=%s\nUPDATE_NOTIFY=%t\nUPDATE_FROM_CACHE=%t\n", result.Status, result.CurrentVersion, result.LatestVersion, result.ReleaseName, result.ReleaseNotes, result.ReleaseURL, result.ShouldNotify, result.FromCache)
- return 0
+func usageError() error {
+ return errors.New("usage: boatstack [flags]")
}
-func operationStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("operation-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose durable operation state should be inspected")
- operationID := flags.String("operation-id", "", "specific operation identity; omit only when the current branch has at most one unfinished operation")
- jsonOutput := flags.Bool("json", false, "emit the versioned JSON projection")
- if err := flags.Parse(arguments); err != nil {
- return 2
+func runRPC() error {
+ decoder := json.NewDecoder(io.LimitReader(os.Stdin, 4<<20))
+ decoder.DisallowUnknownFields()
+ var request surfaces.Request
+ if err := decoder.Decode(&request); err != nil {
+ return fmt.Errorf("decode V2 RPC request: %w", err)
}
- status, err := boatstack.ResolveOperationStatus(*repo, *operationID)
- if err != nil {
- return fail(err)
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return fmt.Errorf("V2 RPC request contains trailing JSON")
}
- value, err := boatstack.MarshalJSON(status)
+ kernel, err := boatstack.NewV2Kernel("")
if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- fmt.Print(string(value))
- } else if status.Operation == nil {
- fmt.Printf("OPERATION_STATUS=%s\nNEXT_OPERATION=%s\n", status.VerificationStatus, status.NextOperation)
- } else {
- fmt.Printf("OPERATION_STATUS=%s\nOPERATION_ID=%s\nSTATE=%s\nATTEMPT=%d/%d\nNEXT_OPERATION=%s\n", status.VerificationStatus, status.Operation.OperationID, status.Operation.State, status.Operation.Attempt, status.Operation.MaxAttempts, status.NextOperation)
+ return err
}
- if status.VerificationStatus == "AMBIGUOUS" {
- return 1
+ response, handleErr := kernel.Handle(context.Background(), request)
+ encoder := json.NewEncoder(os.Stdout)
+ encoder.SetIndent("", " ")
+ if err := encoder.Encode(response); err != nil {
+ return err
}
- return 0
+ return handleErr
}
-func prepareUpdatePRCommand(arguments []string) int {
- flags := flag.NewFlagSet("prepare-update-pr", flag.ContinueOnError)
- repo := flags.String("repo", ".", "updated Boatstack repository")
- version := flags.String("version", "", "exact installed stable version")
- jsonOutput := flags.Bool("json", false, "emit the fingerprinted preview as JSON")
+func runRetrospective(arguments []string) error {
+ flags := flag.NewFlagSet("retro", flag.ContinueOnError)
+ flags.SetOutput(os.Stderr)
+ input, format, source := "-", "", "stdin"
+ flags.StringVar(&input, "input", input, "transcript path or - for stdin")
+ flags.StringVar(&format, "transcript-format", format, "events, claudecode, plaintext, or empty for detection")
+ flags.StringVar(&source, "source", source, "privacy-safe source label")
if err := flags.Parse(arguments); err != nil {
- return 2
- }
- preview, err := boatstack.PrepareUpdatePublication(*repo, *version)
- if err != nil {
- return fail(err)
+ return err
}
- value, err := boatstack.MarshalJSON(preview)
- if err != nil {
- return fail(err)
+ if flags.NArg() != 0 {
+ return fmt.Errorf("unexpected retro arguments: %s", strings.Join(flags.Args(), " "))
}
- if *jsonOutput {
- fmt.Print(string(value))
+ var content []byte
+ var err error
+ if input == "-" {
+ content, err = io.ReadAll(io.LimitReader(os.Stdin, 16<<20))
} else {
- fmt.Printf("UPDATE_PREVIEW=%s\nPREVIEW_FINGERPRINT=%s\nPACKAGE_FINGERPRINT=%s\n", preview.PreviewPath, preview.Fingerprint, preview.PackageFingerprint)
- }
- return 0
-}
-
-func publishUpdatePRCommand(arguments []string) int {
- flags := flag.NewFlagSet("publish-update-pr", flag.ContinueOnError)
- repo := flags.String("repo", ".", "updated Boatstack repository")
- preview := flags.String("preview", "", "exact machine-local update preview path")
- fingerprint := flags.String("preview-fingerprint", "", "fingerprint confirmed by the human")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *preview == "" || *fingerprint == "" {
- return fail(fmt.Errorf("publish-update-pr requires --preview and --preview-fingerprint"))
- }
- url, err := boatstack.PublishUpdatePublication(boatstack.UpdatePublishOptions{Repo: *repo, PreviewPath: *preview, ExpectedFingerprint: *fingerprint})
- if err != nil {
- return fail(err)
- }
- fmt.Printf("PR_URL=%s\n", url)
- return 0
-}
-
-func releaseClassifyCommand(arguments []string) int {
- flags := flag.NewFlagSet("release-classify", flag.ContinueOnError)
- repo := flags.String("repo", ".", "projected Boatstack repository")
- base := flags.String("base", "", "latest released tag or commit")
- head := flags.String("head", "HEAD", "candidate release commit")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- classification, err := boatstack.ClassifyReleaseDiff(*repo, *base, *head)
- if err != nil {
- return fail(err)
- }
- fmt.Printf("release_required=%t\nrelease_paths=%s\n", classification.Required, strings.Join(classification.Paths, ","))
- return 0
-}
-
-func nextPatchCommand(arguments []string) int {
- flags := flag.NewFlagSet("next-patch", flag.ContinueOnError)
- version := flags.String("version", "", "current stable vMAJOR.MINOR.PATCH version")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- next, err := boatstack.NextPatchVersion(*version)
- if err != nil {
- return fail(err)
- }
- fmt.Println(next)
- return 0
-}
-
-func exportCommand(arguments []string) int {
- flags := flag.NewFlagSet("export", flag.ContinueOnError)
- repo := flags.String("repo", "", "repository to export into")
- configPath := flags.String("config", "", "Boatstack project config")
- adapterName := flags.String("adapter-name", "boatstack", "generated adapter slug")
- adapters := flags.String("adapters", "", "comma-separated adapter override")
- write := flags.Bool("write", false, "write generated files")
- check := flags.Bool("check", false, "check generated files for drift")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *repo == "" || *configPath == "" || (*write && *check) {
- return fail(fmt.Errorf("export requires --repo and --config; --write and --check are mutually exclusive"))
- }
- if err := boatstack.ValidateConfigurationExport(*repo, *configPath, *write); err != nil {
- return fail(err)
+ content, err = os.ReadFile(input)
+ if err == nil && len(content) > 16<<20 {
+ return fmt.Errorf("retrospective input exceeds 16 MiB")
+ }
}
- config, raw, err := boatstack.LoadConfig(*configPath)
if err != nil {
- return fail(err)
- }
- if *adapters != "" {
- config.Adapters = strings.Split(*adapters, ",")
+ return err
}
- bundle, err := boatstack.BuildExportBundle(*configPath, config, raw, *adapterName)
+ report, err := analysis.DeriveRetrospective(format, source, content)
if err != nil {
- return fail(err)
+ return err
}
- if *check {
- if err := boatstack.CheckExport(*repo, bundle.Files); err != nil {
- return fail(err)
- }
- if err := boatstack.CheckHostHooks(*repo, bundle.Config.Adapters); err != nil {
- return fail(err)
+ encoder := json.NewEncoder(os.Stdout)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(report)
+}
+
+func classifyCommand(command string) (surfaces.Operation, catalog.TransitionID, map[string]string, error) {
+ aliases := map[string]catalog.TransitionID{
+ "init": "installation.initialize", "update": "installation.update", "attach": "repository.attach", "detach": "repository.detach",
+ "hydrate-runtime": "runtime.hydrate", "configure": "configuration.mutate", "goal-configure": "goal.configure",
+ "plan-create": "plan.create", "plan-validate": "plan.validate", "plan-approve": "plan.approve", "plan-activate": "plan.activate", "plan-amend": "plan.amend",
+ "workspace-cut": "workspace.cut", "workspace-sync": "workspace.sync", "workspace-cleanup": "workspace.cleanup", "workspace-reap": "workspace.reap",
+ "record-build": "gate.build.record", "record-test": "gate.test.record", "record-review": "gate.review.record", "record-change": "gate.change.record", "record-journey": "gate.journey.record",
+ "publication-preview": "publication.preview", "publish-pr": "publication.execute", "observe-pr": "publication.observe", "correct-pr": "publication.correct",
+ "abandon": "plan.abandon",
+ }
+ switch command {
+ case "status", "next", "next-status":
+ return surfaces.OperationResolve, "", nil, nil
+ case "apply":
+ return surfaces.OperationApply, "", nil, nil
+ case "recover":
+ return surfaces.OperationRecover, "", nil, nil
+ case "doctor":
+ return surfaces.OperationDoctor, "", nil, nil
+ case "events":
+ return surfaces.OperationEvents, "", nil, nil
+ case "catalog":
+ return surfaces.OperationCatalog, "", nil, nil
+ case "guard":
+ return surfaces.OperationGuard, "", nil, nil
+ }
+ if transition, ok := aliases[command]; ok {
+ defaults := map[string]string{}
+ if command == "init" {
+ defaults["goal-kind"], defaults["delivery"], defaults["goal-id"] = string(model.GoalApprovedPlan), "bootstrap", "bootstrap"
}
- fmt.Printf("PASS: %d generated files match Boatstack %s\n", len(bundle.Files), boatstack.Version)
- return 0
- }
- if *write {
- if err := boatstack.WriteExport(*repo, bundle.Files); err != nil {
- return fail(err)
+ return surfaces.OperationApply, transition, defaults, nil
+ }
+ return "", "", nil, fmt.Errorf("unknown command %q", command)
+}
+
+func parseOptions(command string, arguments []string, transition catalog.TransitionID, defaults map[string]string) (commandOptions, error) {
+ flags := flag.NewFlagSet(command, flag.ContinueOnError)
+ flags.SetOutput(os.Stderr)
+ options := commandOptions{format: "json", transitionID: string(transition), host: "cli"}
+ if defaults != nil {
+ options.goalKind, options.deliveryID, options.goalID = defaults["goal-kind"], defaults["delivery"], defaults["goal-id"]
+ }
+ flags.StringVar(&options.repository, "repo", ".", "explicit invoking repository or worktree")
+ flags.StringVar(&options.format, "format", options.format, "json, text, or jsonl")
+ flags.StringVar(&options.goalID, "goal-id", options.goalID, "configured goal identity")
+ flags.StringVar(&options.goalKind, "goal-kind", options.goalKind, "approved-plan, verified-implementation, open-or-updated-pr, merged-delivery, or safely-abandoned")
+ flags.StringVar(&options.deliveryID, "delivery", options.deliveryID, "delivery identity")
+ flags.StringVar(&options.flowID, "flow", "", "flow identity")
+ flags.StringVar(&options.transitionID, "transition", options.transitionID, "stable semantic transition id")
+ flags.StringVar(&options.idempotencyKey, "idempotency-key", "", "exact prior admission idempotency key for safe replay")
+ flags.StringVar(&options.humanActor, "human", "", "explicit command-scoped human authority actor")
+ flags.BoolVar(&options.repositoryPolicy, "repository-authority", false, "derive repository-policy authority from the V2 project configuration")
+ flags.Var(&options.parameters, "param", "transition parameter name=value (repeatable)")
+ flags.Var(&options.authorityReceipts, "authority-receipt", "authority receipt JSON path (repeatable)")
+ flags.BoolVar(&options.follow, "follow", false, "follow passive process events (events with jsonl only)")
+ flags.StringVar(&options.host, "host", options.host, "cli, cursor, codex, claude, gemini, or mcp")
+ flags.StringVar(&options.command, "command", "", "raw command to classify at the guard boundary")
+ if err := flags.Parse(arguments); err != nil {
+ return commandOptions{}, err
+ }
+ if flags.NArg() != 0 {
+ return commandOptions{}, fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " "))
+ }
+ switch command {
+ case "init":
+ if err := populateInitParameters(&options); err != nil {
+ return commandOptions{}, err
}
- if err := boatstack.InstallHostHooks(*repo, bundle.Config.Adapters); err != nil {
- return fail(err)
+ case "update", "hydrate-runtime":
+ if err := populateRuntimeParameters(&options); err != nil {
+ return commandOptions{}, err
}
- fmt.Printf("PASS: wrote %d generated files to %s\n", len(bundle.Files), *repo)
- return 0
- }
- fmt.Printf("dry run: would generate %d files in %s\n", len(bundle.Files), *repo)
- for _, path := range func() []string {
- paths := make([]string, 0, len(bundle.Files))
- for path := range bundle.Files {
- paths = append(paths, path)
+ case "correct-pr":
+ if err := populateFileFingerprint(&options, "body_path", "body_sha256"); err != nil {
+ return commandOptions{}, err
}
- sort.Strings(paths)
- return paths
- }() {
- fmt.Println(" " + path)
- }
- for _, path := range boatstack.HostHookPaths(bundle.Config.Adapters) {
- fmt.Println(" " + path + " (merge inert engagement probe)")
}
- return 0
+ return options, nil
}
-func checkPlanCommand(arguments []string) int {
- flags := flag.NewFlagSet("check-plan", flag.ContinueOnError)
- plan := flags.String("plan", "", "Markdown structured plan")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *plan == "" {
- return fail(fmt.Errorf("check-plan requires --plan"))
- }
- check, err := boatstack.CheckPlanForRepository(".", *plan)
- if err != nil {
- return fail(fmt.Errorf("invalid Markdown plan: %w", err))
- }
- baseline, err := boatstack.PlanningBaselineForRepository(".", *plan)
- if err != nil {
- return fail(fmt.Errorf("cannot fingerprint the pre-activation product baseline: %w", err))
- }
- readinessFingerprint := ""
- if version, _ := check.Plan["schema_version"].(float64); version >= 3 {
- readiness, readinessErr := boatstack.CheckPlanReadinessForRepository(".", *plan)
- repo, _ := boatstack.ResolveRepository(".")
- if readinessErr != nil {
- boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, true, readinessErr.Error())
- return fail(readinessErr)
+func followEvents(kernel boatstack.V2Kernel, request surfaces.Request) error {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stop()
+ encoder := json.NewEncoder(os.Stdout)
+ seen := 0
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+ for {
+ response, err := kernel.Handle(ctx, request)
+ if err != nil {
+ return err
+ }
+ if len(response.Events) < seen {
+ seen = 0
+ }
+ for _, event := range response.Events[seen:] {
+ if err := encoder.Encode(event); err != nil {
+ return err
+ }
+ }
+ seen = len(response.Events)
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-ticker.C:
}
- readinessFingerprint = readiness.Fingerprint
- boatstack.RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, false, "current")
- }
- paths, _ := json.Marshal(baseline.ChangedPaths)
- fmt.Printf("PASS: Markdown plan is structurally valid\nPLAN_FINGERPRINT=%s\nREADINESS_FINGERPRINT=%s\nSOURCE_PLAN=%s\nSPEC=%s\nBASELINE_DIFF_SHA256=%s\nBASELINE_CHANGED_PATHS=%s\n", check.Fingerprint, readinessFingerprint, check.SourcePlanPath, check.SpecPath, baseline.DiffSHA256, paths)
- return 0
-}
-
-func checkSourcePlanCommand(arguments []string) int {
- flags := flag.NewFlagSet("check-source-plan", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository the source plan is validated against")
- plan := flags.String("plan", "", "required in-repo path to the plan produced in the host conversation")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- discovered, err := boatstack.DiscoverSourcePlan(*repo, *plan)
- if err != nil {
- return fail(err)
- }
- fmt.Printf("PASS: source plan is present\nSOURCE_PLAN=%s\n", discovered)
- return 0
-}
-
-func activatePlanCommand(arguments []string) int {
- flags := flag.NewFlagSet("activate-plan", flag.ContinueOnError)
- options := boatstack.ActivationOptions{Repo: "."}
- flags.StringVar(&options.PlanPath, "plan", "", "approved Markdown plan")
- flags.StringVar(&options.ApprovalPath, "approval", "", "Markdown approval receipt")
- flags.StringVar(&options.OutDir, "out-dir", "", "compiled artifact directory")
- flags.StringVar(&options.OutputPath, "output", "", "plan lock path")
- flags.StringVar(&options.SourceCommit, "source-commit", "", "source Git commit")
- flags.StringVar(&options.AutonomyPath, "autonomy", "", "fingerprinted autonomy.md receipt")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if options.PlanPath == "" || options.OutDir == "" || options.OutputPath == "" {
- return fail(fmt.Errorf("activate-plan requires --plan, --out-dir, and --output; human_plan_approval additionally requires --approval unless a valid --autonomy receipt targets verified or pr"))
- }
- if err := boatstack.ActivatePlan(options); err != nil {
- return fail(fmt.Errorf("plan activation failed: %w", err))
- }
- boatstack.RecordFlowAttribution(filepath.Dir(options.PlanPath), "authorization_freshness", deliverycontrol.CostQuery, false, "immutable lock current")
- fmt.Printf("PASS: approved Markdown plan activated and locked: %s\n", options.OutputPath)
- return 0
-}
-
-func recordAutonomyCommand(arguments []string) int {
- flags := flag.NewFlagSet("record-autonomy", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the feature package")
- plan := flags.String("plan", "", "validated Markdown plan")
- target := flags.String("target", "", "plan, verified, or pr")
- output := flags.String("output", "", "autonomy.md path; defaults beside plan.md")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *plan == "" || *target == "" {
- return fail(fmt.Errorf("record-autonomy requires --plan and --target plan|verified|pr"))
- }
- parsed, err := boatstack.ParseRunTarget(*target)
- if err != nil {
- return fail(err)
- }
- receipt, err := boatstack.RecordAutonomy(boatstack.AutonomyRecordOptions{Repo: *repo, PlanPath: *plan, Target: parsed, OutputPath: *output})
- if err != nil {
- return fail(err)
}
- fmt.Printf("PASS: scoped autonomous run recorded\nRUN_TARGET=%s\nAUTONOMY_FINGERPRINT=%s\n", receipt.Target, receipt.Fingerprint)
- return 0
}
-func planningWriteCommand(arguments []string) int {
- flags := flag.NewFlagSet("planning-write", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the feature package")
- feature := flags.String("feature", "", "lowercase kebab-case feature slug")
- artifact := flags.String("artifact", "", "known Markdown planning artifact name")
- sourcePlan := flags.String("source-plan", "", "in-repo source plan bound by flow bootstrap")
- sourcePlanSHA256 := flags.String("source-plan-sha256", "", "source-plan digest bound by flow bootstrap")
- expectedLifecycleSHA256 := flags.String("expected-lifecycle-sha256", "", "active-delivery lifecycle fingerprint bound by flow bootstrap")
- expectedPlanLockSHA256 := flags.String("expected-plan-lock-sha256", "", "active plan-lock digest bound by flow bootstrap")
- expectedObservation := flags.String("expected-observation", "", "active amendment observation bound by flow bootstrap")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *feature == "" || *artifact == "" {
- return fail(fmt.Errorf("planning-write requires --feature and --artifact; Markdown content is read from stdin"))
- }
- content, err := io.ReadAll(os.Stdin)
- if err != nil {
- return fail(err)
+func populateInitParameters(options *commandOptions) error {
+ if err := populateRuntimeParameters(options); err != nil {
+ return err
}
- path, err := boatstack.WritePlanningArtifact(boatstack.PlanningWriteOptions{
- Repo: *repo, Feature: *feature, Artifact: *artifact, Content: content,
- SourcePlan: *sourcePlan, SourcePlanSHA256: *sourcePlanSHA256,
- ExpectedLifecycleSHA256: *expectedLifecycleSHA256,
- ExpectedPlanLockSHA256: *expectedPlanLockSHA256,
- ExpectedObservation: *expectedObservation,
- })
+ parameters, err := parseParameters(options.parameters)
if err != nil {
- return fail(err)
- }
- fmt.Printf("PASS: wrote bounded planning Markdown: %s\n", path)
- return 0
-}
-
-func recordApprovalCommand(arguments []string) int {
- flags := flag.NewFlagSet("record-approval", flag.ContinueOnError)
- plan := flags.String("plan", "", "approved Markdown plan")
- output := flags.String("output", "", "approval.md path; defaults beside plan.md")
- approvedBy := flags.String("approved-by", "", "named human approver")
- approvedAt := flags.String("approved-at", "", "RFC3339 approval timestamp")
- fingerprint := flags.String("fingerprint", "", "exact fingerprint displayed before approval")
- baselineDiffSHA256 := flags.String("baseline-diff-sha256", "", "exact product baseline fingerprint displayed before approval; omit only when clean")
- expectedLifecycleSHA256 := flags.String("expected-lifecycle-sha256", "", "exact active lifecycle fingerprint displayed before amendment approval")
- expectedPlanLockSHA256 := flags.String("expected-plan-lock-sha256", "", "exact prior plan-lock fingerprint displayed before amendment approval")
- expectedObservation := flags.String("expected-observation", "", "exact active amendment observation displayed before approval")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *plan == "" || *approvedBy == "" || *approvedAt == "" || *fingerprint == "" {
- return fail(fmt.Errorf("record-approval requires --plan, --approved-by, --approved-at, and --fingerprint"))
- }
- if err := boatstack.RecordApproval(boatstack.ApprovalRecordOptions{
- Repo: ".",
- PlanPath: *plan, OutputPath: *output, ApprovedBy: *approvedBy,
- ApprovedAt: *approvedAt, Fingerprint: *fingerprint, BaselineDiffSHA256: *baselineDiffSHA256,
- ExpectedLifecycleSHA256: *expectedLifecycleSHA256, ExpectedPlanLockSHA256: *expectedPlanLockSHA256,
- ExpectedObservation: *expectedObservation,
- }); err != nil {
- return fail(err)
- }
- fmt.Println("PASS: exact Markdown plan approval recorded")
- return 0
-}
-
-func recordDeliveryGateCommand(arguments []string) int {
- flags := flag.NewFlagSet("record-delivery-gate", flag.ContinueOnError)
- options := boatstack.DeliveryGateOptions{}
- flags.StringVar(&options.Repo, "repo", ".", "repository containing the managed delivery")
- flags.StringVar(&options.Feature, "feature", "", "managed Boatstack feature slug")
- flags.StringVar(&options.SliceID, "slice", "", "delivery slice id; redirects to the named active or published-open slice (default: active slice)")
- flags.StringVar(&options.Gate, "gate", "", "test or review")
- flags.StringVar(&options.Status, "status", "", "PASS or PASS_WITH_GAPS")
- flags.StringVar(&options.BaseBranch, "base", "", "delivery base branch; defaults from the active slice or project")
- flags.StringVar(&options.EvidencePath, "evidence", "", "current evidence ledger")
- flags.StringVar(&options.ReviewerIdentity, "reviewer-identity", "", "reviewer identity required for configured high-risk independent review")
- flags.StringVar(&options.ReviewMethod, "review-method", "", "human_peer or separate_agent")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if options.Feature == "" || options.SliceID == "" || options.Gate == "" || options.Status == "" {
- return fail(fmt.Errorf("record-delivery-gate requires --feature, --slice, --gate, and --status"))
+ return err
}
- transition := boatstack.GateTransition(options.Gate)
- guard := boatstack.GuardFlowMove(options.Repo, options.Feature, transition)
- if !guard.Allow {
- boatstack.RecordFlowTransition(options.Repo, guard.Transition, guard.From, false)
- return fail(fmt.Errorf("%s", guard.Message))
+ if _, ok := parameters.Get("config_path"); !ok {
+ return fmt.Errorf("init requires --param config_path=")
}
- receipt, err := boatstack.RecordDeliveryGate(options)
- boatstack.RecordFlowTransition(options.Repo, transition, guard.From, err == nil)
+ configPath, _ := parameters.Get("config_path")
+ configRaw, err := os.ReadFile(configPath)
if err != nil {
- return fail(err)
- }
- fmt.Printf("PASS: %s gate recorded for delivery slice %s\nSLICE=%s\nGATE=%s\nSTATUS=%s\nHEAD_COMMIT=%s\nDIFF_SHA256=%s\n", strings.ToUpper(receipt.Gate), receipt.SliceID, receipt.SliceID, receipt.Gate, receipt.Status, receipt.HeadCommit, receipt.DiffSHA256)
- return 0
-}
-
-func recordPRVisualEvidenceCommand(arguments []string) int {
- flags := flag.NewFlagSet("record-pr-visual-evidence", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence")
- manifest := flags.String("manifest", "", "JSON manifest containing local PNG paths")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *manifest == "" {
- return fail(fmt.Errorf("record-pr-visual-evidence requires --manifest"))
+ return err
}
- recorded, err := boatstack.ImportPRVisualEvidence(*repo, *manifest)
- if err != nil {
- return fail(err)
+ if _, ok := parameters.Get("config_sha256"); !ok {
+ _, fingerprint, fingerprintErr := protocol.ProjectConfigFingerprint(configRaw)
+ if fingerprintErr != nil {
+ return fingerprintErr
+ }
+ options.parameters = append(options.parameters, "config_sha256="+fingerprint)
}
- value, err := boatstack.MarshalJSON(recorded)
- if err != nil {
- return fail(err)
+ if options.humanActor == "" {
+ return fmt.Errorf("init requires explicit --human ")
}
- fmt.Print(string(value))
- return 0
+ return nil
}
-func reviewPRVisualEvidenceCommand(arguments []string) int {
- flags := flag.NewFlagSet("review-pr-visual-evidence", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence")
- key := flags.String("key", "", "managed feature or ad-hoc visual evidence key")
- evidenceFingerprint := flags.String("evidence-fingerprint", "", "exact capture manifest fingerprint reviewed")
- reviewerIdentity := flags.String("reviewer-identity", "", "human reviewer identity")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *key == "" || *evidenceFingerprint == "" || *reviewerIdentity == "" {
- return fail(fmt.Errorf("review-pr-visual-evidence requires --key, --evidence-fingerprint, and --reviewer-identity"))
- }
- review, err := boatstack.RecordPRVisualPrivacyReview(*repo, *key, *evidenceFingerprint, *reviewerIdentity)
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(review)
+// populateRuntimeParameters binds runtime transitions to the exact bytes and
+// source revision of the process that is requesting admission. Installers may
+// still restate the canonical path and hash, but they cannot substitute a
+// release tag for the embedded source commit.
+func populateRuntimeParameters(options *commandOptions) error {
+ parameters, err := parseParameters(options.parameters)
if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- return 0
-}
-
-func captureEvidenceCommand(arguments []string) int {
- flags := flag.NewFlagSet("capture-evidence", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence")
- capability := flags.String("capability", "visual", "evidence capability to capture")
- feature := flags.String("feature", "", "managed Boatstack feature slug")
- base := flags.String("base", "", "base branch for the product diff (defaults to the project default branch)")
- if err := flags.Parse(arguments); err != nil {
- return 2
+ return err
}
- if *feature == "" {
- return fail(fmt.Errorf("capture-evidence requires --feature"))
+ 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)
}
- captured, err := boatstack.CaptureEvidence(boatstack.CaptureEvidenceOptions{
- Repo: *repo, Capability: *capability, Feature: *feature, Base: *base,
- })
- if err != nil {
- return fail(err)
+ if _, ok := parameters.Get("runtime_sha256"); !ok {
+ runtimeRaw, readErr := os.ReadFile(runtimePath)
+ if readErr != nil {
+ return readErr
+ }
+ options.parameters = append(options.parameters, "runtime_sha256="+hash(runtimeRaw))
}
- value, err := boatstack.MarshalJSON(captured)
- if err != nil {
- return fail(err)
+ if _, ok := parameters.Get("source_revision"); !ok {
+ options.parameters = append(options.parameters, "source_revision="+buildRevision())
}
- fmt.Print(string(value))
- return 0
+ return nil
}
-func provisionCapabilityCommand(arguments []string) int {
- flags := flag.NewFlagSet("provision-capability", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to inspect for evidence-capability provisioning")
- capability := flags.String("capability", "visual", "evidence capability to provision")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- guide, err := boatstack.CapabilityProvisionGuide(*repo, *capability)
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(guide)
+func populateFileFingerprint(options *commandOptions, pathName, fingerprintName string) error {
+ parameters, err := parseParameters(options.parameters)
if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- return 0
-}
-
-func capabilityRegisterCommand(arguments []string) int {
- flags := flag.NewFlagSet("capability-register", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Boatstack configuration owns the command")
- capability := flags.String("capability", "visual", "evidence capability to register a command for")
- surface := flags.String("surface", "", "optional product surface (e.g. web, ops) to scope the command to")
- command := flags.String("command", "", "repository command that produces the evidence")
- if err := flags.Parse(arguments); err != nil {
- return 2
+ return err
}
- if *command == "" {
- return fail(fmt.Errorf("capability-register requires --command"))
+ if _, exists := parameters.Get(fingerprintName); exists {
+ return nil
}
- registered, err := boatstack.RegisterCapabilityCommand(*repo, *capability, *surface, *command)
- if err != nil {
- return fail(err)
+ path, exists := parameters.Get(pathName)
+ if !exists {
+ return nil
}
- value, err := boatstack.MarshalJSON(registered)
+ raw, err := os.ReadFile(path)
if err != nil {
- return fail(err)
+ return err
}
- fmt.Print(string(value))
- return 0
+ options.parameters = append(options.parameters, fingerprintName+"="+hash(raw))
+ return nil
}
-func recordPRVisualPublicationCommand(arguments []string) int {
- flags := flag.NewFlagSet("record-pr-visual-publication", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence")
- key := flags.String("key", "", "managed feature or ad-hoc branch evidence key")
- prURL := flags.String("pr-url", "", "published pull request URL")
- commentURL := flags.String("comment-url", "", "observable Boatstack evidence comment URL")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *key == "" || *prURL == "" || *commentURL == "" {
- return fail(fmt.Errorf("record-pr-visual-publication requires --key, --pr-url, and --comment-url"))
- }
- recorded, err := boatstack.RecordPRVisualPublication(*repo, *key, *prURL, *commentURL)
- if err != nil {
- return fail(err)
+func buildRevision() string {
+ if boatstack.SourceCommit != "" && boatstack.SourceCommit != "unknown" {
+ return boatstack.SourceCommit
}
- value, err := boatstack.MarshalJSON(recorded)
- if err != nil {
- return fail(err)
+ if info, ok := debug.ReadBuildInfo(); ok {
+ for _, setting := range info.Settings {
+ if setting.Key == "vcs.revision" && setting.Value != "" {
+ return setting.Value
+ }
+ }
}
- fmt.Print(string(value))
- return 0
+ return boatstack.Version
}
-func attachEvidenceCommand(arguments []string) int {
- flags := flag.NewFlagSet("attach-evidence", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence")
- feature := flags.String("feature", "", "managed Boatstack feature slug")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *feature == "" {
- return fail(fmt.Errorf("attach-evidence requires --feature"))
+func buildRequest(operation surfaces.Operation, options commandOptions) (surfaces.Request, error) {
+ now := time.Now().UTC()
+ correlation := fmt.Sprintf("cli-%d-%d", os.Getpid(), now.UnixNano())
+ goal := model.Goal{}
+ if options.goalKind != "" || options.goalID != "" || options.deliveryID != "" {
+ goal = model.Goal{ID: options.goalID, Kind: model.GoalKind(options.goalKind), DeliveryID: options.deliveryID}
+ if err := goal.Validate(); err != nil {
+ return surfaces.Request{}, err
+ }
}
- manifest, err := boatstack.RetryVisualAttachment(*repo, *feature, boatstack.SelectVisualPublisher(*repo))
+ parameters, err := parseParameters(options.parameters)
if err != nil {
- return fail(err)
+ return surfaces.Request{}, err
}
- value, err := boatstack.MarshalJSON(manifest)
+ authority, err := loadAuthority(options, correlation, goal, now)
if err != nil {
- return fail(err)
+ return surfaces.Request{}, err
}
- fmt.Print(string(value))
- return 0
-}
-
-func deliveryStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("delivery-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the managed delivery")
- feature := flags.String("feature", "", "managed Boatstack feature slug")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *feature == "" {
- return fail(fmt.Errorf("delivery-status requires --feature"))
- }
- state, err := boatstack.CurrentDeliveryState(*repo, *feature)
- if err != nil {
- return fail(err)
+ flowID := options.flowID
+ if flowID == "" && goal.ID != "" {
+ flowID = "flow-" + goal.ID
}
- value, err := boatstack.MarshalJSON(state)
- if err != nil {
- return fail(err)
+ if flowID == "" && (operation == surfaces.OperationApply || operation == surfaces.OperationRecover) {
+ flowID = "flow-" + correlation
}
- fmt.Print(string(value))
- return 0
+ return surfaces.Request{
+ SchemaVersion: surfaces.SchemaVersion, Operation: operation, Repository: options.repository, Host: options.host, CorrelationID: correlation,
+ FlowID: flowID, Goal: goal, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters,
+ RepositoryAuthority: options.repositoryPolicy, IdempotencyKey: options.idempotencyKey, Command: options.command,
+ }, nil
}
-func nextStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("next-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Boatstack stage should be inspected")
- feature := flags.String("feature", "", "optional specific managed feature to inspect")
- jsonOutput := flags.Bool("json", false, "print the versioned structured status")
- render := flags.Bool("render", false, "print the branded, human-facing status banner")
- format := flags.String("format", "", `optional output format: "response" renders the canonical response contract (banner, outcome line, one ### Next step)`)
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *format != "" && *format != "response" {
- return fail(fmt.Errorf(`unsupported format %q; use --format response`, *format))
- }
- status, err := boatstack.ResolveNext(*repo, *feature)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(status)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else if *format == "response" {
- output, renderErr := boatstack.RenderNextStatusResponse(*repo, status)
- if renderErr != nil {
- return fail(renderErr)
+func parseParameters(values []string) (protocol.Parameters, error) {
+ parameters := make(protocol.Parameters, 0, len(values))
+ for _, value := range values {
+ name, parameterValue, ok := strings.Cut(value, "=")
+ if !ok || strings.TrimSpace(name) == "" || parameterValue == "" {
+ return nil, fmt.Errorf("--param requires name=value")
}
- fmt.Print(output)
- } else if *render {
- fmt.Print(boatstack.RenderNextStatusBanner(status))
- } else {
- fmt.Print(boatstack.FormatNextStatus(status))
- }
- return 0
-}
-
-func recoveryStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("recovery-status", flag.ContinueOnError)
- options := boatstack.RecoveryStatusOptions{}
- flags.StringVar(&options.Repo, "repo", ".", "repository whose managed delivery should be resolved")
- flags.StringVar(&options.Feature, "feature", "", "optional specific active or published feature")
- flags.StringVar(&options.Message, "message", "", "exact reported correction")
- flags.StringVar(&options.SourceStage, "source-stage", "", "ci, review, publication, or user")
- flags.StringVar(&options.Evidence, "evidence", "", "bounded failure or review reference")
- flags.StringVar(&options.ObservedHeadSHA, "observed-head-sha", "", "optional PR head tied to the reported evidence")
- jsonOutput := flags.Bool("json", false, "print the versioned structured recovery decision")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- status, err := boatstack.ResolveRecovery(options)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(status)
- if marshalErr != nil {
- return fail(marshalErr)
+ name = strings.TrimSpace(name)
+ if isPathParameter(name) {
+ absolute, err := filepath.Abs(parameterValue)
+ if err != nil {
+ return nil, fmt.Errorf("resolve %s: %w", name, err)
+ }
+ parameterValue = filepath.Clean(absolute)
+ if name != "destination" {
+ resolved, resolveErr := filepath.EvalSymlinks(absolute)
+ if resolveErr != nil {
+ return nil, fmt.Errorf("resolve %s: %w", name, resolveErr)
+ }
+ parameterValue = resolved
+ }
}
- fmt.Print(string(value))
- } else {
- fmt.Printf("Recovery: %s\nFeature: %s\nLifecycle: %s\nNext operation: %s\nReason: %s\n", status.VerificationStatus, status.Feature, status.Lifecycle, status.NextOperation, status.Reason)
+ parameters = append(parameters, protocol.Parameter{Name: name, Value: parameterValue})
}
- if status.VerificationStatus == "BLOCKED" {
- return 1
- }
- return 0
+ return parameters.Canonical(), nil
}
-func repairStateCommand(arguments []string) int {
- flags := flag.NewFlagSet("repair-state", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose stuck feature draft should be repaired")
- feature := flags.String("feature", "", "optional specific feature draft to repair")
- jsonOutput := flags.Bool("json", false, "print the versioned structured repair decision")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result, err := boatstack.RepairState(*repo, *feature)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(result)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Printf("Repair: %s\nFeature: %s\nAction: %s\nQuarantine: %s\nNext operation: %s\nReason: %s\n",
- result.VerificationStatus, result.Feature, result.Action, result.QuarantinePath, result.NextOperation, result.Reason)
- }
- if result.VerificationStatus == "BLOCKED" {
- return 1
+func isPathParameter(name string) bool {
+ switch name {
+ case "source_path", "runtime_path", "config_path", "destination", "evidence_path", "manifest_path", "body_path":
+ return true
+ default:
+ return false
}
- return 0
}
-func mutationStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("mutation-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose managed-artifact mutation receipts should be listed")
- mutation := flags.String("mutation", "", "optional specific mutation id to inspect")
- jsonOutput := flags.Bool("json", false, "print the structured mutation receipt(s)")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if strings.TrimSpace(*mutation) != "" {
- receipt, ok, err := boatstack.GetMutationReceipt(*repo, *mutation)
+func loadAuthority(options commandOptions, correlation string, goal model.Goal, now time.Time) (protocol.AuthorityBundle, error) {
+ bundle := protocol.AuthorityBundle{}
+ for _, path := range options.authorityReceipts {
+ raw, err := os.ReadFile(path)
if err != nil {
- return fail(err)
- }
- if !ok {
- fmt.Fprintf(os.Stderr, "no mutation receipt for %s\n", *mutation)
- return 1
+ return protocol.AuthorityBundle{}, err
}
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(receipt)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Printf("Mutation: %s\nKind: %s\nStatus: %s\nRecorded: %s\nScope: %s\n", receipt.MutationID, receipt.Kind, receipt.Status, receipt.RecordedAt, strings.Join(receipt.Scope, ", "))
- }
- return 0
- }
- receipts, err := boatstack.ListMutationReceipts(*repo)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(receipts)
- if marshalErr != nil {
- return fail(marshalErr)
+ var receipt protocol.AuthorityReceipt
+ decoder := json.NewDecoder(strings.NewReader(string(raw)))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&receipt); err != nil {
+ return protocol.AuthorityBundle{}, err
}
- fmt.Print(string(value))
- } else {
- if len(receipts) == 0 {
- fmt.Println("No mutation receipts.")
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return protocol.AuthorityBundle{}, fmt.Errorf("authority receipt contains trailing JSON")
}
- for _, receipt := range receipts {
- fmt.Printf("%s\t%s\t%s\t%s\n", receipt.MutationID, receipt.Kind, receipt.Status, receipt.RecordedAt)
+ bundle.Receipts = append(bundle.Receipts, receipt)
+ }
+ if options.humanActor != "" {
+ fingerprint := hash([]byte(strings.Join([]string{correlation, goal.ID, options.transitionID, options.humanActor}, "\x00")))
+ bundle.Receipts = append(bundle.Receipts, protocol.AuthorityReceipt{
+ ID: "human-" + fingerprint[:16], Class: catalog.AuthorityHuman, Subject: options.humanActor, Fingerprint: fingerprint,
+ IssuedAt: now, ExpiresAt: now.Add(5 * time.Minute),
+ })
+ }
+ return bundle, nil
+}
+
+func renderResponse(response surfaces.Response, format string) error {
+ if response.Operation == surfaces.OperationCatalog {
+ switch format {
+ case "markdown":
+ fmt.Print(surfaces.RenderCatalogMarkdown(response.Catalog))
+ return nil
+ case "mermaid":
+ fmt.Print(surfaces.RenderCatalogMermaid(response.Catalog))
+ return nil
+ case "locus-safety":
+ value, err := surfaces.RenderCatalogLocusSafety(response.Catalog)
+ if err != nil {
+ return err
+ }
+ fmt.Print(value)
+ return nil
+ case "locus-liveness":
+ value, err := surfaces.RenderCatalogLocusLiveness(response.Catalog)
+ if err != nil {
+ return err
+ }
+ fmt.Print(value)
+ return nil
}
}
- return 0
-}
-
-func undoCommand(arguments []string) int {
- flags := flag.NewFlagSet("undo", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the managed-artifact mutation to reverse")
- mutation := flags.String("mutation", "", "mutation id to undo (its receipt is the inverse command)")
- jsonOutput := flags.Bool("json", false, "print the structured undo receipt")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if strings.TrimSpace(*mutation) == "" {
- fmt.Fprintln(os.Stderr, "undo requires --mutation ")
- return 2
- }
- receipt, err := boatstack.UndoManagedMutation(*repo, *mutation)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(receipt)
- if marshalErr != nil {
- return fail(marshalErr)
+ switch format {
+ case "json":
+ encoder := json.NewEncoder(os.Stdout)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(response)
+ case "jsonl":
+ encoder := json.NewEncoder(os.Stdout)
+ if response.Operation == surfaces.OperationEvents {
+ for _, event := range response.Events {
+ if err := encoder.Encode(event); err != nil {
+ return err
+ }
+ }
+ return nil
}
- fmt.Print(string(value))
- } else {
- fmt.Printf("Undo: %s\nKind: %s\nStatus: %s\nScope: %s\n", receipt.MutationID, receipt.Kind, receipt.Status, strings.Join(receipt.Scope, ", "))
- }
- return 0
-}
-
-func runPreflightCommand(arguments []string) int {
- flags := flag.NewFlagSet("run-preflight", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Git state should be verified before boatstack run")
- feature := flags.String("feature", "", "optional specific managed feature to verify")
- healthOnly := flags.Bool("health-only", false, "verify installation and generated state without Git network or branch checks")
- jsonOutput := flags.Bool("json", false, "print the versioned structured preflight")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- var status boatstack.RunPreflight
- if *healthOnly {
- status = boatstack.CheckInstallationPreflight(*repo)
- } else {
- status = boatstack.CheckRunPreflight(*repo, *feature)
- }
- if *jsonOutput {
- value, err := boatstack.MarshalJSON(status)
- if err != nil {
- return fail(err)
+ return encoder.Encode(response)
+ case "text":
+ if response.Error != "" {
+ fmt.Println("UNRESOLVED:", response.Error)
+ return nil
}
- fmt.Print(string(value))
- } else {
- fmt.Printf("Boatstack run preflight: %s\nAuthority: %s\nAuthority reason: %s\nReason: %s\n", status.VerificationStatus, status.AuthorityStatus, status.AuthorityReason, status.Reason)
- }
- if status.VerificationStatus != "VERIFIED" {
- return 1
- }
- return 0
-}
-
-func authorityContextCommand(arguments []string) int {
- flags := flag.NewFlagSet("authority-context", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose authority binding should be projected")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- context, err := boatstack.ResolveAuthorityContext(*repo)
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(context)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- return 0
-}
-
-func recordChangeCommand(arguments []string) int {
- flags := flag.NewFlagSet("record-change", flag.ContinueOnError)
- options := boatstack.ChangeObservationOptions{}
- flags.StringVar(&options.Repo, "repo", ".", "repository containing the managed delivery")
- flags.StringVar(&options.Feature, "feature", "", "managed Boatstack feature slug")
- flags.StringVar(&options.Message, "message", "", "exact user change request")
- flags.StringVar(&options.SourceStage, "source-stage", "", "stage where the change was observed")
- flags.StringVar(&options.Expected, "expected", "", "approved or requested expected behavior")
- flags.StringVar(&options.Actual, "actual", "", "observed behavior")
- flags.StringVar(&options.Evidence, "evidence", "", "bounded evidence or reproduction reference")
- flags.StringVar(&options.Mechanism, "mechanism", "", "repair mechanism used to address the observed failure")
- flags.StringVar(&options.Classification, "classification", "", "implementation_repair, verification_repair, review_repair, requirement_amendment, needs_clarification, or plan_invalid")
- flags.StringVar(&options.SliceID, "slice", "", "delivery slice id the correction targets; redirects to the named active or published-open slice (default: the correction's branch, then the active slice)")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if options.Feature == "" || options.Message == "" || options.SourceStage == "" || options.Classification == "" {
- return fail(fmt.Errorf("record-change requires --feature, --message, --source-stage, and --classification"))
- }
- if strings.HasSuffix(strings.ToLower(options.Classification), "_repair") && strings.TrimSpace(options.Mechanism) == "" {
- return fail(fmt.Errorf("record-change requires --mechanism for repair classifications"))
- }
- observation, state, err := boatstack.RecordChangeObservation(options)
- if err != nil {
- if strings.HasSuffix(strings.ToLower(options.Classification), "_repair") &&
- (strings.Contains(err.Error(), "friction:") || strings.Contains(err.Error(), "budget exhausted")) {
- boatstack.RecordFlowAttribution(options.Repo, "repair."+strings.ToLower(options.Classification), deliverycontrol.CostFriction, true, err.Error())
+ if response.Doctor != nil {
+ fmt.Printf("healthy=%t transitions=%d snapshot=%s\n%s\n", response.Doctor.Healthy, response.Doctor.TransitionCount, response.Doctor.Snapshot, response.Doctor.Detail)
+ return nil
}
- return fail(err)
- }
- if strings.HasSuffix(strings.ToLower(options.Classification), "_repair") {
- boatstack.RecordFlowAttribution(options.Repo, "repair."+strings.ToLower(options.Classification), deliverycontrol.CostRecovery, false, options.Mechanism)
- }
- // A recorded correction is the honest moment coding rework is initiated;
- // record one unit of coding effort as telemetry (never a gate, never J_flow).
- boatstack.RecordCodingEffort(options.Repo, 1, string(observation.Classification))
- fmt.Printf("PASS: change observation recorded\nOBSERVATION_ID=%s\nCLASSIFICATION=%s\nOUTCOME=%s\nMODE=%s\nRESUME_STAGE=%s\n", observation.ID, observation.Classification, observation.Outcome, state.Mode, state.ResumeStage)
- if observation.Outcome == "CORRECTIVE_CHILD_REQUIRED" {
- fmt.Printf("PARENT_DELIVERY=%s\nSUGGESTED_FEATURE_ID=%s\n", observation.ParentDelivery, observation.SuggestedFeatureID)
- }
- return 0
-}
-
-func recordJourneyResultsCommand(arguments []string) int {
- flags := flag.NewFlagSet("record-journey-results", flag.ContinueOnError)
- options := boatstack.JourneyResultsOptions{}
- flags.StringVar(&options.Repo, "repo", ".", "repository containing the managed delivery")
- flags.StringVar(&options.Feature, "feature", "", "managed Boatstack feature slug")
- flags.StringVar(&options.BaseBranch, "base", "", "delivery base branch")
- flags.StringVar(&options.InputPath, "results", "", "JSON file containing typed oracle results")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if options.Feature == "" || options.InputPath == "" {
- return fail(fmt.Errorf("record-journey-results requires --feature and --results"))
- }
- result, err := boatstack.RecordJourneyResults(options)
- if err != nil {
- boatstack.RecordFlowAttribution(options.Repo, "journey_discovery", deliverycontrol.CostQuery, false, err.Error())
- return fail(err)
- }
- boatstack.RecordFlowAttribution(options.Repo, "journey_discovery", deliverycontrol.CostQuery, false, "results bound to manifest and diff")
- fmt.Printf("PASS: journey results recorded\nMANIFEST_SHA256=%s\nHEAD_COMMIT=%s\nDIFF_SHA256=%s\n", result.ManifestSHA256, result.HeadCommit, result.DiffSHA256)
- return 0
-}
-
-func ignoreDeliveryCommand(arguments []string) int {
- flags := flag.NewFlagSet("ignore-delivery", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the Boatstack installation")
- feature := flags.String("feature", "", "feature slug of the past delivery to ignore")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *feature == "" {
- return fail(fmt.Errorf("ignore-delivery requires --feature"))
- }
- added, err := boatstack.IgnoreDelivery(*repo, *feature)
- if err != nil {
- return fail(err)
- }
- if added {
- fmt.Printf("PASS: delivery %s added to workflow.ignored_deliveries\n", *feature)
- } else {
- fmt.Printf("PASS: delivery %s already ignored\n", *feature)
- }
- return 0
-}
-
-func discardDeliveryCommand(arguments []string) int {
- flags := flag.NewFlagSet("discard-delivery", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the Boatstack installation")
- feature := flags.String("feature", "", "feature slug of the delivery whose state should be discarded")
- force := flags.Bool("force", false, "discard even a delivery that has published slices (git history and merged PRs are unaffected)")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *feature == "" {
- return fail(fmt.Errorf("discard-delivery requires --feature"))
- }
- result, err := boatstack.DiscardDelivery(*repo, *feature, *force)
- if err != nil {
- return fail(err)
- }
- switch result.Action {
- case "discarded":
- fmt.Printf("PASS: delivery %s discarded; state archived to %s\n", result.Feature, result.ArchivePath)
- return 0
- case "none":
- fmt.Printf("PASS: %s\n", result.Reason)
- return 0
- default: // refused
- if len(result.Published) > 0 {
- fmt.Printf("BLOCKED: delivery %s has published slices (%s); %s\n", result.Feature, strings.Join(result.Published, ", "), result.Reason)
- } else {
- fmt.Printf("BLOCKED: delivery %s: %s\n", result.Feature, result.Reason)
+ if response.Decision != nil {
+ fmt.Printf("%s: %s\n", response.Decision.Kind, response.Decision.Reason)
+ if response.Decision.Transition != nil {
+ fmt.Println("transition:", response.Decision.Transition.ID)
+ }
}
- return 1
- }
-}
-
-func doctorCommand(arguments []string) int {
- flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose Boatstack installation should be checked")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if err := boatstack.DoctorRepairHint(boatstack.Doctor(*repo)); err != nil {
- return fail(err)
- }
- root, err := boatstack.ResolveRepository(*repo)
- if err != nil {
- return fail(err)
- }
- ctx, err := boatstack.ResolveWorkspaceContext(root)
- if err != nil {
- return fail(err)
- }
- fmt.Printf("PASS: Boatstack %s installation and generated adapters are healthy\n", boatstack.Version)
- fmt.Printf("SUPERVISION_MODE=%s\nCONTROLLER_ROOT=%s\nHEALTH=VERIFIED\n", ctx.Mode, ctx.ExportRoot())
- hosts, err := boatstack.DoctorHookHosts(*repo)
- if err != nil {
- return fail(err)
- }
- for _, host := range hosts {
- name := strings.ToUpper(host)
- fmt.Printf("HOST_CONTRACT_%s=PASS\nHOST_ACTIVATION_%s=OPERATOR_VERIFY\n", name, name)
- switch host {
- case "cursor":
- fmt.Println("HOST_ACTIVATION_GUIDANCE_CURSOR=Reload Cursor and confirm both Boatstack hooks are enabled; Cursor hooks remain defense in depth.")
- case "claude":
- fmt.Println("HOST_ACTIVATION_GUIDANCE_CLAUDE=Reload Claude Code and use /hooks to confirm the Boatstack PreToolUse hook is active.")
- case "codex":
- fmt.Println("HOST_ACTIVATION_GUIDANCE_CODEX=Trust this linked worktree, use /hooks to review and trust the exact Boatstack hook, then start a new task.")
+ if response.Receipt != nil {
+ fmt.Println("receipt:", response.Receipt.ID)
}
- }
- if update, ok := boatstack.CachedUpdate(*repo); ok {
- fmt.Printf("UPDATE_AVAILABLE=%s\nRELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL)
- }
- return 0
-}
-
-func renderDenialCommand(arguments []string) int {
- flags := flag.NewFlagSet("render-denial", flag.ContinueOnError)
- mode := flags.String("mode", "ansi", "render mode: ansi | plain | markdown")
- host := flags.String("host", "claude", "coding host: claude | codex | cursor | gemini")
- demo := flags.Bool("demo", false, "render a representative set of denials")
- if err := flags.Parse(arguments); err != nil {
- return fail(err)
- }
- if !*demo {
- return fail(fmt.Errorf("render-denial requires --demo (optional: --mode, --host)"))
- }
- fmt.Println(boatstack.DenialDemo(*host, boatstack.ParseRenderMode(*mode)))
- return 0
-}
-
-func diagnoseHookCommand(arguments []string) int {
- flags := flag.NewFlagSet("diagnose-hook", flag.ContinueOnError)
- host := flags.String("host", "", "cursor, claude, or codex")
- repo := flags.String("repo", ".", "repository whose installed hook should be probed")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- diagnostic, err := boatstack.DiagnoseHook(*repo, *host)
- if err != nil {
- return fail(err)
- }
- fmt.Printf("HOOK_CONTRACT_%s=%s\nLIVE_HOST_EVENT=NOT_OBSERVED\n", strings.ToUpper(diagnostic.Host), diagnostic.ContractStatus)
- if diagnostic.Host == "cursor" {
- fmt.Println("NEXT=If Cursor still reports HOST_PAYLOAD_MALFORMED, preserve edits and start a new Cursor task; this probe cannot inspect Cursor's live event.")
- } else {
- name := strings.ToUpper(diagnostic.Host[:1]) + diagnostic.Host[1:]
- fmt.Printf("NEXT=If %s still reports HOST_PAYLOAD_MALFORMED, preserve edits and start a new host session; this probe cannot inspect the live event.\n", name)
- }
- return 0
-}
-
-func safetyHookCommand(arguments []string) int {
- flags := flag.NewFlagSet("safety-hook", flag.ContinueOnError)
- host := flags.String("host", "", "cursor, claude, or codex")
- repo := flags.String("repo", ".", "repository protected by the hook")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- input, err := io.ReadAll(os.Stdin)
- if err != nil {
- input = nil
- }
- value, _ := boatstack.HookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input})
- if err := emitHookOutput(os.Stdout, *host, value); err != nil {
- return failSafetyHook(fmt.Errorf("cannot emit hook decision: %w", err))
- }
- return 0
-}
-
-// engagementProbeCLI is the inert developer-level entry point. It emits no
-// policy effect unless a verified active-delivery lease exists in this worktree.
-func engagementProbeCLI(arguments []string) int {
- flags := flag.NewFlagSet("engagement-probe", flag.ContinueOnError)
- host := flags.String("host", "", "cursor, claude, codex, or gemini")
- repo := flags.String("repo", ".", "repository the coding agent is operating in")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- input, err := io.ReadAll(os.Stdin)
- if err != nil {
- input = nil
- }
- value, _ := boatstack.EngagementProbeDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input})
- if err := emitHookOutput(os.Stdout, *host, value); err != nil {
- return failSafetyHook(fmt.Errorf("cannot emit hook decision: %w", err))
- }
- return 0
-}
-
-func bootstrapSafetyHookCommand(arguments []string) int {
- flags := flag.NewFlagSet("bootstrap-safety-hook", flag.ContinueOnError)
- host := flags.String("host", "", "cursor, claude, or codex")
- repo := flags.String("repo", ".", "worktree protected by the hook")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- input, err := io.ReadAll(os.Stdin)
- if err != nil {
- input = nil
- }
- if err := boatstack.HydrateWorktree(*repo); err != nil {
- return failSafetyHook(fmt.Errorf("worktree runtime activation failed: %w", err))
- }
- value, _ := boatstack.HookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input})
- if err := emitHookOutput(os.Stdout, *host, value); err != nil {
- return failSafetyHook(fmt.Errorf("cannot emit hook decision: %w", err))
- }
- return 0
-}
-
-// hydrateRuntimeCommand populates the version-keyed shared runtime slot (and
-// this worktree's ignored bin/) from the RUNNING binary, without switching
-// branches or touching any committed generated file. The safety guard invokes
-// it — via the verified installer's hydrate mode — to self-heal a clone whose
-// slot is empty after a version bump or a fresh checkout, so a teammate never
-// sees a hard "shared runtime is missing" deny.
-func hydrateRuntimeCommand(arguments []string) int {
- flags := flag.NewFlagSet("hydrate-runtime", flag.ContinueOnError)
- repo := flags.String("repo", ".", "worktree whose shared runtime slot should be populated")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if err := boatstack.RunHydrateRuntime(*repo); err != nil {
- return fail(err)
- }
- return 0
-}
-
-// activateWorktreeRuntimeCommand installs or repairs only this worktree's
-// ignored helper from the already verified exact shared runtime. Unlike the
-// safety-hook bootstrap it reads no hook payload and makes no policy decision.
-func activateWorktreeRuntimeCommand(arguments []string) int {
- flags := flag.NewFlagSet("activate-worktree-runtime", flag.ContinueOnError)
- repo := flags.String("repo", ".", "worktree whose local runtime should be activated")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if err := boatstack.HydrateWorktree(*repo); err != nil {
- return fail(fmt.Errorf("worktree runtime activation failed: %w", err))
- }
- return 0
-}
-
-func checkSafetyCommand(arguments []string) int {
- flags := flag.NewFlagSet("check-safety", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose operational diff should be checked")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- report, err := boatstack.CheckRepositorySafety(*repo)
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(report)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- if report.Status != "PASS" {
- return 1
- }
- return 0
-}
-
-func migrateConfigCommand(arguments []string) int {
- flags := flag.NewFlagSet("migrate-config", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose configuration should be migrated")
- target := flags.String("target", "", "configuration projection: repository or controller; required for hybrid installations")
- check := flags.Bool("check", false, "dry-run check mode")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- report, err := boatstack.MigrateManagedConfiguration(*repo, *target, *check)
- if err != nil {
- return fail(err)
- }
- value, err := json.Marshal(report)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- if report.Status != "PASS" {
- return 1
- }
- return 0
-}
-
-func prContextCommand(arguments []string) int {
- flags := flag.NewFlagSet("pr-context", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose branch should be projected")
- feature := flags.String("feature", "", "managed Boatstack feature slug; omit for evidence-limited ad-hoc mode")
- slice := flags.String("slice", "", "managed delivery slice; redirects to the named active or published-open slice (default: active slice)")
- base := flags.String("base", "", "base branch; defaults to the Boatstack project configuration")
- format := flags.String("format", "json", "json or template")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- context, err := boatstack.PreparePRContext(boatstack.PRContextOptions{Repo: *repo, Feature: *feature, SliceID: *slice, Base: *base})
- if err != nil {
- return fail(err)
- }
- switch *format {
- case "json":
- value, err := boatstack.PRContextJSON(context)
- if err != nil {
- return fail(err)
+ if response.Guard != nil {
+ fmt.Printf("allowed=%t operation=%s reason=%s\n", response.Guard.Allowed, response.Guard.Intent.Operation, response.Guard.Reason)
+ if response.Guard.RequiredTransition != "" {
+ fmt.Println("transition:", response.Guard.RequiredTransition)
+ }
}
- fmt.Print(string(value))
- case "template":
- fmt.Print(boatstack.PRPreviewTemplate(context))
+ return nil
default:
- return fail(fmt.Errorf("pr-context format must be json or template"))
+ return fmt.Errorf("unsupported format %q", format)
}
- return 0
}
-func checkPRCommand(arguments []string) int {
- flags := flag.NewFlagSet("check-pr", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the PR preview")
- previewPath := flags.String("preview", "", "reviewed pr.md preview")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *previewPath == "" {
- return fail(fmt.Errorf("check-pr requires --preview"))
- }
- preview, context, err := boatstack.CheckPRPreview(*repo, *previewPath)
- if err != nil {
- return fail(err)
- }
- action, url, actionErr := boatstack.RecommendedPRAction(*repo)
- fmt.Printf("PASS: exact PR preview matches the current branch and evidence\nPR_ACTION=%s\nPR_TITLE=%s\nPREVIEW_FINGERPRINT=%s\nCONTEXT_FINGERPRINT=%s\n", action, preview.Title, preview.Fingerprint, context.ContextFingerprint)
- if url != "" {
- fmt.Printf("PR_URL=%s\n", url)
- }
- if actionErr != nil {
- fmt.Printf("PUBLICATION_NOTE=%s\n", actionErr)
- }
- fmt.Printf("--- PR BODY ---\n%s\n--- END PR BODY ---\n", string(boatstack.PRBody(preview)))
- return 0
+func hash(value []byte) string {
+ digest := sha256.Sum256(value)
+ return hex.EncodeToString(digest[:])
}
-
-func publishPRCommand(arguments []string) int {
- flags := flag.NewFlagSet("publish-pr", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the PR preview")
- previewPath := flags.String("preview", "", "reviewed pr.md preview")
- fingerprint := flags.String("preview-fingerprint", "", "exact preview fingerprint confirmed by the human")
- action := flags.String("action", "", "open or update")
- autonomy := flags.String("autonomy", "", "fingerprinted autonomy.md receipt authorizing the PR target")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if *previewPath == "" || *fingerprint == "" || *action == "" {
- return fail(fmt.Errorf("publish-pr requires --preview, --preview-fingerprint, and --action"))
- }
- feature := ""
- if preview, previewErr := boatstack.ParsePRPreview(*previewPath); previewErr == nil {
- feature = preview.Feature
- }
- guard := boatstack.GuardFlowMove(*repo, feature, boatstack.PublishTransition)
- if !guard.Allow {
- boatstack.RecordFlowTransition(*repo, guard.Transition, guard.From, false)
- return fail(fmt.Errorf("%s", guard.Message))
- }
- url, err := boatstack.PublishPR(boatstack.PRPublishOptions{
- Repo: *repo, PreviewPath: *previewPath, ExpectedFingerprint: *fingerprint, Action: *action,
- AutonomyPath: *autonomy,
- VisualPublisher: boatstack.SelectVisualPublisher(*repo),
- })
- boatstack.RecordFlowTransition(*repo, boatstack.PublishTransition, guard.From, err == nil)
- if err != nil {
- return fail(err)
- }
- verb := "opened"
- if *action == "update" {
- verb = "updated"
- }
- fmt.Printf("PASS: PR %s without merge authorization\nPR_URL=%s\n", verb, url)
-
- if update, ok := boatstack.PostShipUpdateNotice(*repo, feature); ok {
- fmt.Printf("UPDATE_AVAILABLE=%s\nUPDATE_RELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL)
- }
- return 0
-}
-
-func workspaceCutCommand(arguments []string) int {
- flags := flag.NewFlagSet("workspace-cut", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to cut the feature workspace in")
- feature := flags.String("feature", "", "feature slug used to derive the branch name")
- branch := flags.String("branch", "", "explicit branch name; overrides --feature derivation")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result, err := boatstack.CutFeatureWorkspace(boatstack.WorkspaceCutOptions{Repo: *repo, Feature: *feature, Branch: *branch})
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(result)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- if result.VerificationStatus != "VERIFIED" {
- return 1
- }
- return 0
-}
-
-func workspaceCleanupCommand(arguments []string) int {
- flags := flag.NewFlagSet("workspace-cleanup", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose finished workspace should be removed")
- branch := flags.String("branch", "", "branch whose workspace should be cleaned up")
- confirm := flags.Bool("confirm", false, "human confirmation to remove the workspace")
- force := flags.Bool("force", false, "override the merge gate and discard uncommitted or unmerged work")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result, err := boatstack.CleanupFeatureWorkspace(boatstack.WorkspaceCleanupOptions{Repo: *repo, Branch: *branch, Confirm: *confirm, Force: *force})
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(result)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- if result.VerificationStatus == "BLOCKED" {
- return 1
- }
- return 0
-}
-
-func workspaceReapCommand(arguments []string) int {
- flags := flag.NewFlagSet("workspace-reap", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository whose terminal workspaces should be reclaimed")
- confirm := flags.Bool("confirm", false, "operator confirmation to reclaim the merged or abandoned workspaces")
- force := flags.Bool("force", false, "override the merge gate and discard uncommitted or unmerged work")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result, err := boatstack.ReapWorkspaces(boatstack.WorkspaceReapOptions{Repo: *repo, Confirm: *confirm, Force: *force})
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(result)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- if result.VerificationStatus == "BLOCKED" {
- return 1
- }
- return 0
-}
-
-func workspaceStatusCommand(arguments []string) int {
- flags := flag.NewFlagSet("workspace-status", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository to inspect")
- branch := flags.String("branch", "", "branch whose workspace should be reported")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- result, err := boatstack.FeatureWorkspaceStatus(*repo, *branch)
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(result)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- return 0
-}
-
-func workspaceSyncCommand(arguments []string) int {
- flags := flag.NewFlagSet("workspace-sync", flag.ContinueOnError)
- repo := flags.String("repo", ".", "repository containing the branch to synchronize")
- branch := flags.String("branch", "", "local branch to align; defaults to the current branch")
- source := flags.String("source", "", "remote branch to fetch and align to, for example origin/main")
- if err := flags.Parse(arguments); err != nil {
- return 2
- }
- if strings.TrimSpace(*source) == "" {
- return fail(fmt.Errorf("workspace-sync requires --source"))
- }
- result, err := boatstack.SyncWorkspace(boatstack.WorkspaceSyncOptions{Repo: *repo, Branch: *branch, Source: *source})
- if err != nil {
- return fail(err)
- }
- value, err := boatstack.MarshalJSON(result)
- if err != nil {
- return fail(err)
- }
- fmt.Print(string(value))
- if result.Status == "BLOCKED" {
- return 1
- }
- return 0
-}
-
-// resolveCommandScope projects every named Boatstack request through the same
-// engagement resolver used by hooks. Hook processes resolve ambient ACTIVE
-// authority from their payload path; ordinary named commands receive ephemeral
-// COMMAND authority and never persist it.
-func resolveCommandScope(verb string, arguments []string) boatstack.EngagementStatus {
- if verb == "safety-hook" || verb == "engagement-probe" || verb == "bootstrap-safety-hook" {
- return boatstack.ResolveEngagement(traceFlag(arguments, "--repo"), boatstack.EngagementRequest{})
- }
- repo := traceFlag(arguments, "--repo")
- if repo == "" {
- repo = "."
- }
- return boatstack.ResolveEngagement(repo, boatstack.EngagementRequest{ExplicitCommand: true})
-}
-
-func run() (result int) {
- if len(os.Args) < 2 {
- fmt.Fprintln(os.Stderr, "usage: boatstack-helper ")
- return 2
- }
- if complete := commandTraceCompletion(os.Args[1], os.Args[2:]); complete != nil {
- defer func() { complete(result) }()
- }
- _ = resolveCommandScope(os.Args[1], os.Args[2:])
- switch os.Args[1] {
- case "attach":
- return attachCommand(os.Args[2:])
- case "detach":
- return detachCommand(os.Args[2:])
- case "detached-status":
- return detachedStatusCommand(os.Args[2:])
- case "engagement-status":
- return engagementStatusCommand(os.Args[2:])
- case "config-rebind":
- return configRebindCommand(os.Args[2:])
- case "context":
- return contextCommand(os.Args[2:])
- case "activate":
- return activateCommand(os.Args[2:])
- case "deactivate":
- return deactivateCommand(os.Args[2:])
- case "init":
- return initCommand(os.Args[2:])
- case "update":
- return updateCommand(os.Args[2:])
- case "check-update":
- return checkUpdateCommand(os.Args[2:])
- case "repair-status":
- return repairStatusCommand(os.Args[2:])
- case "operation-status":
- return operationStatusCommand(os.Args[2:])
- case "prepare-update-pr":
- return prepareUpdatePRCommand(os.Args[2:])
- case "publish-update-pr":
- return publishUpdatePRCommand(os.Args[2:])
- case "release-classify":
- return releaseClassifyCommand(os.Args[2:])
- case "next-patch":
- return nextPatchCommand(os.Args[2:])
- case "export":
- return exportCommand(os.Args[2:])
- case "check-source-plan":
- return checkSourcePlanCommand(os.Args[2:])
- case "check-plan":
- return checkPlanCommand(os.Args[2:])
- case "planning-write":
- return planningWriteCommand(os.Args[2:])
- case "record-approval":
- return recordApprovalCommand(os.Args[2:])
- case "record-autonomy":
- return recordAutonomyCommand(os.Args[2:])
- case "activate-plan":
- return activatePlanCommand(os.Args[2:])
- case "delivery-status":
- return deliveryStatusCommand(os.Args[2:])
- case "next-status":
- return nextStatusCommand(os.Args[2:])
- case "recovery-status":
- return recoveryStatusCommand(os.Args[2:])
- case "repair-state":
- return repairStateCommand(os.Args[2:])
- case "mutation-status":
- return mutationStatusCommand(os.Args[2:])
- case "undo":
- return undoCommand(os.Args[2:])
- case "run-preflight":
- return runPreflightCommand(os.Args[2:])
- case "authority-context":
- return authorityContextCommand(os.Args[2:])
- case "record-change":
- return recordChangeCommand(os.Args[2:])
- case "record-journey-results":
- return recordJourneyResultsCommand(os.Args[2:])
- case "ignore-delivery":
- return ignoreDeliveryCommand(os.Args[2:])
- case "discard-delivery":
- return discardDeliveryCommand(os.Args[2:])
- case "record-delivery-gate":
- return recordDeliveryGateCommand(os.Args[2:])
- case "record-pr-visual-evidence":
- return recordPRVisualEvidenceCommand(os.Args[2:])
- case "review-pr-visual-evidence":
- return reviewPRVisualEvidenceCommand(os.Args[2:])
- case "capture-evidence":
- return captureEvidenceCommand(os.Args[2:])
- case "provision-capability":
- return provisionCapabilityCommand(os.Args[2:])
- case "capability-register":
- return capabilityRegisterCommand(os.Args[2:])
- case "record-pr-visual-publication":
- return recordPRVisualPublicationCommand(os.Args[2:])
- case "attach-evidence":
- return attachEvidenceCommand(os.Args[2:])
- case "pr-context":
- return prContextCommand(os.Args[2:])
- case "check-pr":
- return checkPRCommand(os.Args[2:])
- case "publish-pr":
- return publishPRCommand(os.Args[2:])
- case "doctor":
- return doctorCommand(os.Args[2:])
- case "diagnose-hook":
- return diagnoseHookCommand(os.Args[2:])
- case "render-denial":
- return renderDenialCommand(os.Args[2:])
- case "safety-hook":
- return safetyHookCommand(os.Args[2:])
- case "engagement-probe":
- return engagementProbeCLI(os.Args[2:])
- case "bootstrap-safety-hook":
- return bootstrapSafetyHookCommand(os.Args[2:])
- case "hydrate-runtime":
- return hydrateRuntimeCommand(os.Args[2:])
- case "activate-worktree-runtime":
- return activateWorktreeRuntimeCommand(os.Args[2:])
- case "check-safety":
- return checkSafetyCommand(os.Args[2:])
- case "workspace-cut":
- return workspaceCutCommand(os.Args[2:])
- case "workspace-cleanup":
- return workspaceCleanupCommand(os.Args[2:])
- case "workspace-reap":
- return workspaceReapCommand(os.Args[2:])
- case "workspace-status":
- return workspaceStatusCommand(os.Args[2:])
- case "workspace-sync":
- return workspaceSyncCommand(os.Args[2:])
- case "migrate-config":
- return migrateConfigCommand(os.Args[2:])
- case "flow":
- return flowCommand(os.Args[2:])
- case "retro":
- return retroCommand(os.Args[2:])
- case "insight":
- return insightCommand(os.Args[2:])
- case "version":
- fmt.Printf("Boatstack %s (%s)\n", boatstack.Version, boatstack.SourceCommit)
- return 0
- default:
- fmt.Fprintln(os.Stderr, "unknown command:", os.Args[1])
- return 2
- }
-}
-
-func main() { os.Exit(run()) }
diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go
index a16f0dc..6f24aae 100644
--- a/boatstack/cmd/boatstack-helper/main_test.go
+++ b/boatstack/cmd/boatstack-helper/main_test.go
@@ -1,234 +1,75 @@
package main
import (
- "bytes"
- "context"
- "io"
"os"
- "os/exec"
"path/filepath"
- "strings"
"testing"
- "time"
boatstack "github.com/operatorstack/boatstack/boatstack"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/surfaces"
)
-func TestEmitHookOutputSettlesOnlyCursor(t *testing.T) {
- previous := hookOutputSleep
- defer func() { hookOutputSleep = previous }()
- delays := []time.Duration{}
- hookOutputSleep = func(value time.Duration) { delays = append(delays, value) }
-
- var output bytes.Buffer
- if err := emitHookOutput(&output, "cursor", []byte("{\"permission\":\"deny\"}\n")); err != nil {
- t.Fatal(err)
- }
- if output.String() != "{\"permission\":\"deny\"}\n" {
- t.Fatalf("unexpected output: %q", output.String())
- }
- if len(delays) != 1 || delays[0] != cursorHookSettleDelay {
- t.Fatalf("Cursor settle delays = %v", delays)
- }
-
- delays = nil
- if err := emitHookOutput(&output, "claude", []byte("{}\n")); err != nil {
- t.Fatal(err)
- }
- if err := emitHookOutput(&output, "codex", nil); err != nil {
- t.Fatal(err)
- }
- if len(delays) != 0 {
- t.Fatalf("non-Cursor hosts unexpectedly settled: %v", delays)
+func TestEveryFriendlyMutationAliasMapsToOneRegistryTransition(t *testing.T) {
+ // control-law: cli-verbs-are-adapters-not-transition-authority
+ registry := catalog.Default()
+ commands := []string{"init", "update", "attach", "detach", "hydrate-runtime", "configure", "goal-configure", "plan-create", "plan-validate", "plan-approve", "plan-activate", "plan-amend", "workspace-cut", "workspace-sync", "workspace-cleanup", "workspace-reap", "record-build", "record-test", "record-review", "record-change", "record-journey", "publication-preview", "publish-pr", "observe-pr", "correct-pr", "abandon"}
+ for _, command := range commands {
+ operation, transitionID, _, err := classifyCommand(command)
+ if err != nil {
+ t.Errorf("%s: %v", command, err)
+ continue
+ }
+ if operation != surfaces.OperationApply {
+ t.Errorf("%s operation = %s, want apply", command, operation)
+ }
+ if _, ok := registry.Lookup(transitionID); !ok {
+ t.Errorf("%s maps to unregistered transition %s", command, transitionID)
+ }
}
}
-func TestBootstrapFailureUsesBlockingExitCode(t *testing.T) {
- if code := bootstrapSafetyHookCommand([]string{"--host", "claude", "--repo", t.TempDir()}); code != 2 {
- t.Fatalf("bootstrap failure exit = %d, want 2", code)
+func TestParametersRejectMissingEquals(t *testing.T) {
+ if _, err := parseParameters([]string{"unsafe"}); err == nil {
+ t.Fatal("malformed transition parameter was accepted")
}
}
-// control-law: detached-config-input-stays-outside-plant
-func TestAttachCommandAcceptsExternalConfigFlag(t *testing.T) {
- repo := t.TempDir()
- commands := [][]string{
- {"git", "-C", repo, "init", "-b", "main"},
- {"git", "-C", repo, "config", "user.name", "Boatstack Test"},
- {"git", "-C", repo, "config", "user.email", "boatstack@example.invalid"},
- }
- for _, arguments := range commands {
- if output, err := exec.Command(arguments[0], arguments[1:]...).CombinedOutput(); err != nil {
- t.Fatalf("%v: %v: %s", arguments, err, output)
- }
- }
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# app\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- for _, arguments := range [][]string{{"git", "-C", repo, "add", "README.md"}, {"git", "-C", repo, "commit", "-m", "initial"}} {
- if output, err := exec.Command(arguments[0], arguments[1:]...).CombinedOutput(); err != nil {
- t.Fatalf("%v: %v: %s", arguments, err, output)
- }
- }
- configPath := filepath.Join(t.TempDir(), "project.json")
- config := []byte(`{"schema_version":1,"project":{"name":"works-yield","commands":{"test":"pnpm test"}}}` + "\n")
- if err := os.WriteFile(configPath, config, 0o644); err != nil {
+func TestApplyWithoutRestatedGoalGetsCommandScopedFlowIdentity(t *testing.T) {
+ request, err := buildRequest(surfaces.OperationApply, commandOptions{transitionID: "installation.update", host: "cli", repository: "."})
+ if err != nil {
t.Fatal(err)
}
- stateRoot := t.TempDir()
- var code int
- output := captureStdout(t, func() {
- code = attachCommand([]string{"--repo", repo, "--mode", "detached", "--config", configPath, "--state-root", stateRoot})
- })
- if code != 0 || !strings.Contains(output, `"verification_status": "VERIFIED"`) || !strings.Contains(output, `"config_sha256":`) {
- t.Fatalf("attach --config failed: code=%d output=%s", code, output)
+ if request.FlowID == "" || request.Goal.ID != "" {
+ t.Fatalf("request did not preserve configured-goal lookup with a generated flow: %#v", request)
}
}
-// captureStdout runs fn with os.Stdout redirected and returns what it printed,
-// so read-only CLI verbs can be asserted without polluting test output.
-func captureStdout(t *testing.T, fn func()) string {
- t.Helper()
- previous := os.Stdout
- reader, writer, err := os.Pipe()
- if err != nil {
+func TestCorrectPRBindsExactBodyBytes(t *testing.T) {
+ bodyPath := filepath.Join(t.TempDir(), "body.md")
+ if err := os.WriteFile(bodyPath, []byte("reviewed body"), 0o600); err != nil {
t.Fatal(err)
}
- os.Stdout = writer
- fn()
- writer.Close()
- os.Stdout = previous
- out, err := io.ReadAll(reader)
+ options, err := parseOptions("correct-pr", []string{
+ "--param", "publication_id=7", "--param", "body_path=" + bodyPath,
+ }, "publication.correct", nil)
if err != nil {
t.Fatal(err)
}
- return string(out)
-}
-
-func TestFlowCheckCommandPassesOnShippedModel(t *testing.T) {
- var code int
- out := captureStdout(t, func() { code = flowCheckCommand(nil) })
- if code != 0 {
- t.Fatalf("flow check exit = %d, want 0; output:\n%s", code, out)
- }
- if !strings.HasPrefix(out, "PASS:") {
- t.Errorf("flow check output should lead with PASS:\n%s", out)
- }
-}
-
-func TestFlowCommandRejectsUnknownSubcommand(t *testing.T) {
- if code := flowCommand([]string{"nonsense"}); code != 2 {
- t.Errorf("unknown flow subcommand exit = %d, want 2", code)
- }
- if code := flowCommand(nil); code != 2 {
- t.Errorf("flow with no subcommand exit = %d, want 2", code)
- }
-}
-
-func liveHostArguments(host, prompt string) []string {
- switch host {
- case "cursor":
- return []string{"-p", "--force", prompt}
- case "claude":
- return []string{"-p", "--dangerously-skip-permissions", prompt}
- case "codex":
- return []string{"exec", "--dangerously-bypass-hook-trust", "--sandbox", "danger-full-access", prompt}
- default:
- return nil
- }
-}
-
-func liveHostRepo(t *testing.T, host, helper string) string {
- t.Helper()
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init", "-b", "main").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- config := boatstack.ProjectConfig{
- SchemaVersion: 1,
- Project: boatstack.Project{Name: "live-hook-" + host, DefaultBranch: "main", Commands: map[string]string{"test": "true"}},
- Workflow: boatstack.Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true},
- Adapters: []string{host},
- }
- value, err := boatstack.MarshalJSON(config)
+ parameters, err := parseParameters(options.parameters)
if err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(filepath.Join(repo, ".boatstack-project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := boatstack.RunInit(boatstack.InitOptions{Repo: repo, BinaryPath: helper, IntegrationChoice: "core", Yes: true, Output: io.Discard}); err != nil {
- t.Fatal(err)
- }
- scriptDirectory := filepath.Join(repo, "scripts")
- if err := os.MkdirAll(scriptDirectory, 0o755); err != nil {
- t.Fatal(err)
+ if fingerprint, ok := parameters.Get("body_sha256"); !ok || fingerprint != hash([]byte("reviewed body")) {
+ t.Fatalf("correction body fingerprint = %q, present=%t", fingerprint, ok)
}
- // The script itself has a harmless side effect. The policy marker in the
- // comment makes Boatstack deny its invocation, so a sentinel proves whether
- // the real host honored the hook without executing a destructive operation.
- script := "#!/usr/bin/env bash\n# git reset --hard is operator-only\ntouch \"$1\"\n"
- if err := os.WriteFile(filepath.Join(scriptDirectory, "blocked-probe.sh"), []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- return repo
}
-func TestLiveHostHarnessesAreExplicitlyOptIn(t *testing.T) {
- if os.Getenv("BOATSTACK_LIVE_HOST_TESTS") != "1" {
- t.Skip("set BOATSTACK_LIVE_HOST_TESTS=1 to exercise installed host harnesses")
- }
- commands := map[string]string{"cursor": "cursor-agent", "claude": "claude", "codex": "codex"}
- available := map[string]string{}
- for host, command := range commands {
- if path, err := exec.LookPath(command); err == nil {
- available[host] = path
- }
- }
- if len(available) == 0 {
- t.Skip("no supported live host is installed")
- }
- helper := filepath.Join(t.TempDir(), "boatstack-helper")
- if output, err := exec.Command("go", "build", "-o", helper, ".").CombinedOutput(); err != nil {
- t.Fatalf("build live helper: %v: %s", err, output)
- }
- for _, host := range []string{"cursor", "claude", "codex"} {
- path := available[host]
- if path == "" {
- continue
- }
- t.Run(host, func(t *testing.T) {
- repo := liveHostRepo(t, host, helper)
- safe := filepath.Join(repo, "safe-sentinel")
- blocked := filepath.Join(repo, "blocked-sentinel")
- prompts := []struct {
- text string
- sentinel string
- wantExists bool
- }{
- {"Use the shell exactly once to run: touch " + safe, safe, true},
- {"Use the shell exactly once to run: bash scripts/blocked-probe.sh " + blocked, blocked, false},
- }
- for _, probe := range prompts {
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
- command := exec.CommandContext(ctx, path, liveHostArguments(host, probe.text)...)
- command.Dir = repo
- output, runErr := command.CombinedOutput()
- cancel()
- if ctx.Err() == context.DeadlineExceeded {
- t.Fatalf("%s live probe timed out", host)
- }
- exists := false
- if _, err := os.Stat(probe.sentinel); err == nil {
- exists = true
- }
- if exists != probe.wantExists {
- t.Fatalf("%s sentinel exists=%t want=%t; err=%v output=%s", host, exists, probe.wantExists, runErr, output)
- }
- if !probe.wantExists && !strings.Contains(string(output), "Boatstack denied") {
- t.Fatalf("%s blocked probe lacked Boatstack evidence: err=%v output=%s", host, runErr, output)
- }
- }
- })
+func TestBuildRevisionPrefersReleaseEmbeddedSourceCommit(t *testing.T) {
+ prior := boatstack.SourceCommit
+ boatstack.SourceCommit = "exact-release-source"
+ t.Cleanup(func() { boatstack.SourceCommit = prior })
+ if got := buildRevision(); got != "exact-release-source" {
+ t.Fatalf("build revision = %q", got)
}
}
diff --git a/boatstack/cmd/boatstack-helper/retro.go b/boatstack/cmd/boatstack-helper/retro.go
deleted file mode 100644
index 8363674..0000000
--- a/boatstack/cmd/boatstack-helper/retro.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "os"
-
- boatstack "github.com/operatorstack/boatstack/boatstack"
-)
-
-// retroCommand is the derive-only entry point for transcript mining. The CLI
-// boundary owns the ONLY I/O in the pipeline: it reads the operator-supplied
-// paths and prints the report to stdout. Below this boundary the derivation
-// is capability-free (no filesystem, network, subprocess, or clock), and
-// nothing anywhere in the pipeline writes, mutates state, or runs a command.
-// control-law: retro-proposes-never-enforces
-func retroCommand(arguments []string) int {
- if len(arguments) == 0 || arguments[0] != "derive" {
- fmt.Fprintln(os.Stderr, "usage: boatstack-helper retro derive --input [--input ...] [--format events|claudecode|plaintext] [--json]")
- return 2
- }
- flags := flag.NewFlagSet("retro derive", flag.ContinueOnError)
- var inputs stringList
- flags.Var(&inputs, "input", "transcript file to mine (repeatable)")
- format := flags.String("format", "", "transcript format: events, claudecode, or plaintext (default: sniff per file)")
- jsonOutput := flags.Bool("json", false, "print the structured derivation report")
- if err := flags.Parse(arguments[1:]); err != nil {
- return 2
- }
- inputs = append(inputs, flags.Args()...)
- if len(inputs) == 0 {
- fmt.Fprintln(os.Stderr, "retro derive requires at least one --input transcript; Boatstack never scans for transcripts on its own")
- return 2
- }
- loaded := make([]boatstack.RetroInput, 0, len(inputs))
- for _, path := range inputs {
- content, err := os.ReadFile(path)
- if err != nil {
- return fail(err)
- }
- loaded = append(loaded, boatstack.RetroInput{Name: path, Content: content})
- }
- report, err := boatstack.RetroDerive(*format, loaded)
- if err != nil {
- return fail(err)
- }
- if *jsonOutput {
- value, marshalErr := boatstack.MarshalJSON(report)
- if marshalErr != nil {
- return fail(marshalErr)
- }
- fmt.Print(string(value))
- } else {
- fmt.Print(boatstack.FormatRetroReport(report))
- }
- return 0
-}
-
-// stringList is a repeatable string flag.
-type stringList []string
-
-func (s *stringList) String() string { return fmt.Sprint([]string(*s)) }
-func (s *stringList) Set(value string) error {
- *s = append(*s, value)
- return nil
-}
diff --git a/boatstack/command.go b/boatstack/command.go
deleted file mode 100644
index b14128c..0000000
--- a/boatstack/command.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "fmt"
- "os"
- "os/exec"
- "strings"
-)
-
-// commandChannels preserves the subprocess transport boundary. Stdout is the
-// only authority-bearing channel; stderr is diagnostic even when the command
-// exits successfully.
-type commandChannels struct {
- Stdout []byte
- Stderr []byte
-}
-
-func runCommandChannels(command *exec.Cmd) (commandChannels, error) {
- var stdout bytes.Buffer
- var stderr bytes.Buffer
- command.Stdout = &stdout
- command.Stderr = &stderr
- err := command.Run()
- return commandChannels{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}, err
-}
-
-func commandFailure(channels commandChannels, runErr error) error {
- message := strings.TrimSpace(string(channels.Stderr))
- if message == "" {
- message = strings.TrimSpace(string(channels.Stdout))
- }
- if message == "" && runErr != nil {
- message = runErr.Error()
- }
- return fmt.Errorf("%s", boundedObservation(message))
-}
-
-// commandOutput returns only successful stdout for machine parsing. Successful
-// stderr can contain warnings, progress, locale text, or host diagnostics and
-// must never become a path, ref, URL, fingerprint, or workflow status.
-func commandOutput(repo string, name string, arguments ...string) (string, error) {
- command := exec.Command(name, arguments...)
- command.Dir = repo
- channels, err := runCommandChannels(command)
- if err != nil {
- return "", commandFailure(channels, err)
- }
- return strings.TrimSpace(string(channels.Stdout)), nil
-}
-
-// commandOutputEnv is commandOutput with additional environment variables appended
-// to the inherited environment. It keeps the same stdout-is-the-only-authority
-// contract; extra entries are ordinary NAME=VALUE strings.
-func commandOutputEnv(repo string, extraEnv []string, name string, arguments ...string) (string, error) {
- command := exec.Command(name, arguments...)
- command.Dir = repo
- command.Env = append(os.Environ(), extraEnv...)
- channels, err := runCommandChannels(command)
- if err != nil {
- return "", commandFailure(channels, err)
- }
- return strings.TrimSpace(string(channels.Stdout)), nil
-}
diff --git a/boatstack/command_test.go b/boatstack/command_test.go
deleted file mode 100644
index 83a4e67..0000000
--- a/boatstack/command_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestCommandOutputSeparatesAuthorityFromDiagnostics(t *testing.T) {
- t.Setenv("BOATSTACK_COMMAND_HELPER", "success")
- output, err := commandOutput(t.TempDir(), os.Args[0], "-test.run=^TestCommandOutputHelperProcess$")
- if err != nil {
- t.Fatal(err)
- }
- if output != ".cursor/commands/boatstack-update.md" {
- t.Fatalf("stderr contaminated machine output: %q", output)
- }
-
- t.Setenv("BOATSTACK_COMMAND_HELPER", "failure")
- output, err = commandOutput(t.TempDir(), os.Args[0], "-test.run=^TestCommandOutputHelperProcess$")
- if err == nil || output != "" || !strings.Contains(err.Error(), "CRLF diagnostic") || strings.Contains(err.Error(), "not-authoritative") {
- t.Fatalf("failed command did not prefer bounded stderr: output=%q err=%v", output, err)
- }
-}
-
-func TestCommandOutputHelperProcess(t *testing.T) {
- mode := os.Getenv("BOATSTACK_COMMAND_HELPER")
- if mode == "" {
- return
- }
- fmt.Fprintln(os.Stdout, ".cursor/commands/boatstack-update.md")
- if mode == "failure" {
- fmt.Fprintln(os.Stdout, "not-authoritative")
- fmt.Fprintln(os.Stderr, "CRLF diagnostic")
- os.Exit(7)
- }
- fmt.Fprintln(os.Stderr, "warning: fake/path will be replaced by CRLF")
- os.Exit(0)
-}
-
-func TestProductionControllersDoNotCollapseSubprocessChannels(t *testing.T) {
- err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
- return nil
- }
- value, err := os.ReadFile(filepath.Clean(path))
- if err != nil {
- return err
- }
- if strings.Contains(string(value), ".CombinedOutput()") {
- return fmt.Errorf("%s collapses authority-bearing stdout and diagnostic stderr", path)
- }
- return nil
- })
- if err != nil {
- t.Fatal(err)
- }
-}
diff --git a/boatstack/command_trace.go b/boatstack/command_trace.go
deleted file mode 100644
index f828da6..0000000
--- a/boatstack/command_trace.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// CommandTraceInput is the bounded, secret-free observation supplied by the
-// helper dispatcher. Raw arguments and process I/O are intentionally absent.
-type CommandTraceInput struct {
- Repo string
- Verb string
- Category string
- Feature string
- Slice string
- Transition deliverycontrol.TransitionID
- StartedAt time.Time
- FinishedAt time.Time
- ExitCode int
-}
-
-func commandAuthorityFingerprint(repo, feature string) string {
- if strings.TrimSpace(feature) == "" {
- return ""
- }
- path := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "autonomy.md")
- value, err := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true)
- if err != nil {
- return ""
- }
- return stringValue(value["fingerprint"])
-}
-
-func commandOperationFingerprint(repo, feature, slice string) string {
- receipts, err := operationReceipts(repo)
- if err != nil {
- return ""
- }
- selected := OperationReceipt{}
- for _, receipt := range receipts {
- if receipt.State == OperationSucceeded || receipt.State == OperationFailedFinal {
- continue
- }
- if feature != "" && receipt.Scope.Feature != "" && receipt.Scope.Feature != feature {
- continue
- }
- if slice != "" && receipt.Scope.Slice != "" && receipt.Scope.Slice != slice {
- continue
- }
- if selected.UpdatedAt == "" || receipt.UpdatedAt > selected.UpdatedAt {
- selected = receipt
- }
- }
- return selected.PackageFingerprint
-}
-
-func resolveCommandFeature(repo, feature, slice string) (string, string) {
- if feature != "" {
- return feature, slice
- }
- active, err := ActiveManagedDeliveries(repo)
- if err != nil || len(active) != 1 {
- return "", slice
- }
- feature = active[0]
- if slice == "" {
- if state, stateErr := LoadDeliveryState(repo, feature); stateErr == nil {
- if activeSlice, sliceErr := activeDeliverySlice(state); sliceErr == nil {
- slice = activeSlice.ID
- }
- }
- }
- return feature, slice
-}
-
-// RecordCommandEvent appends one best-effort shadow event. Telemetry is never a
-// control point: all failures and panics are swallowed after the command result
-// has already been decided.
-func RecordCommandEvent(input CommandTraceInput) {
- defer func() { _ = recover() }()
- if strings.TrimSpace(input.Verb) == "" || strings.TrimSpace(input.Category) == "" || input.StartedAt.IsZero() || input.FinishedAt.IsZero() {
- return
- }
- if strings.TrimSpace(os.Getenv(flowTraceKillSwitch)) == "0" {
- return
- }
- repo, err := ResolveRepository(input.Repo)
- if err != nil {
- return
- }
- feature, slice := resolveCommandFeature(repo, strings.TrimSpace(input.Feature), strings.TrimSpace(input.Slice))
- outcome := "succeeded"
- if input.ExitCode == 2 {
- outcome = "usage_error"
- } else if input.ExitCode != 0 {
- outcome = "failed"
- }
- duration := input.FinishedAt.Sub(input.StartedAt)
- if duration < 0 {
- return
- }
- directory, err := flowLogDirectory(repo)
- if err != nil {
- return
- }
- _ = deliverycontrol.AppendCommandEvent(directory, deliverycontrol.CommandEvent{
- Verb: input.Verb, Category: input.Category, Feature: feature, Slice: slice,
- Transition: input.Transition, StartedAt: input.StartedAt.UTC().Format(time.RFC3339Nano),
- FinishedAt: input.FinishedAt.UTC().Format(time.RFC3339Nano), DurationMS: duration.Milliseconds(),
- ExitCode: input.ExitCode, Outcome: outcome,
- AuthorityFingerprint: commandAuthorityFingerprint(repo, feature),
- OperationFingerprint: commandOperationFingerprint(repo, feature, slice),
- })
-}
diff --git a/boatstack/command_trace_test.go b/boatstack/command_trace_test.go
deleted file mode 100644
index 0a5dc29..0000000
--- a/boatstack/command_trace_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "strings"
- "testing"
- "time"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: helper-dispatch-produces-secret-free-shadow-event
-func TestRecordCommandEventIsSecretFreeAndBestEffort(t *testing.T) {
- repo := prTestRepo(t)
- started := time.Date(2026, 8, 9, 10, 0, 0, 0, time.UTC)
- RecordCommandEvent(CommandTraceInput{
- Repo: repo, Verb: "run-preflight", Category: "readiness", Feature: "demo",
- StartedAt: started, FinishedAt: started.Add(1500 * time.Millisecond), ExitCode: 1,
- })
- dir, err := flowLogDirectory(repo)
- if err != nil {
- t.Fatal(err)
- }
- events, err := deliverycontrol.ReadCommandEvents(dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(events) != 1 || events[0].Outcome != "failed" || events[0].DurationMS != 1500 {
- t.Fatalf("events = %+v", events)
- }
- raw, _ := json.Marshal(events[0])
- for _, forbidden := range []string{"arguments", "stdin", "stdout", "stderr", "environment"} {
- if strings.Contains(string(raw), forbidden) {
- t.Fatalf("secret-bearing field %q entered event: %s", forbidden, raw)
- }
- }
-
- t.Setenv(flowTraceKillSwitch, "0")
- RecordCommandEvent(CommandTraceInput{
- Repo: repo, Verb: "doctor", Category: "readiness",
- StartedAt: started, FinishedAt: started, ExitCode: 0,
- })
- events, err = deliverycontrol.ReadCommandEvents(dir)
- if err != nil || len(events) != 1 {
- t.Fatalf("kill switch changed log: events=%v err=%v", events, err)
- }
-
- // Invalid repositories and malformed timestamps are silent no-ops.
- RecordCommandEvent(CommandTraceInput{Repo: t.TempDir(), Verb: "doctor", Category: "readiness"})
-}
-
-func TestFlowReportScopesCommandEvidenceByFeature(t *testing.T) {
- repo := prTestRepo(t)
- dir, _ := flowLogDirectory(repo)
- for _, event := range []deliverycontrol.CommandEvent{
- {Verb: "check-plan", Category: "planning", Feature: "one", StartedAt: "2026-08-09T10:00:00Z", FinishedAt: "2026-08-09T10:00:01Z", DurationMS: 1000, Outcome: "succeeded"},
- {Verb: "publish-pr", Category: "publication", Feature: "two", StartedAt: "2026-08-09T10:00:02Z", FinishedAt: "2026-08-09T10:00:04Z", DurationMS: 2000, ExitCode: 1, Outcome: "failed"},
- } {
- if err := deliverycontrol.AppendCommandEvent(dir, event); err != nil {
- t.Fatal(err)
- }
- }
- report, err := FlowReportFor(repo, "two")
- if err != nil {
- t.Fatal(err)
- }
- if report.CommandCoverageStatus != "SCOPED_COMPLETE" || report.CommandEvents != 1 || report.CommandFailures != 1 || report.ObservedCommandMS != 2000 {
- t.Fatalf("report = %+v", report)
- }
- if report.CommandFailureByCategory["publication"] != 1 {
- t.Fatalf("failure categories = %+v", report.CommandFailureByCategory)
- }
-}
diff --git a/boatstack/compiled_artifact_resolution_test.go b/boatstack/compiled_artifact_resolution_test.go
deleted file mode 100644
index ee8f41b..0000000
--- a/boatstack/compiled_artifact_resolution_test.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// Regression for the evidence-path split-brain: activate-plan writes the evidence
-// ledger under compiled/, and pr-context resolves it through the shared dual-layout
-// rule (feature-root canonical, compiled/ fallback). But the delivery-gate recorder
-// used to hand-join the feature-root path with no fallback, so once a real project
-// kept its ledger only at compiled/evidence.md the recorder could not find it and
-// the gate failed with "delivery gate requires current evidence". These tests pin
-// the recorder to the same resolver every other layer uses.
-
-// TestFeatureEvidencePathResolvesBothLayouts locks the shared resolver's ordering:
-// feature-root (legacy canonical) first, compiled/ as the current-layout fallback,
-// and the canonical path returned even when neither exists so the caller reports a
-// clear error location.
-func TestFeatureEvidencePathResolvesBothLayouts(t *testing.T) {
- dir := t.TempDir()
- root := filepath.Join(dir, "evidence.md")
- compiled := filepath.Join(dir, "compiled", "evidence.md")
-
- // With neither present the resolver returns its last candidate (the compiled
- // copy) so a missing-evidence error names the canonical write location.
- if got := featureEvidencePath(dir); got != compiled {
- t.Fatalf("with neither present, want the compiled error path %q, got %q", compiled, got)
- }
-
- if err := os.MkdirAll(filepath.Dir(compiled), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(compiled, []byte("x"), 0o644); err != nil {
- t.Fatal(err)
- }
- if got := featureEvidencePath(dir); got != compiled {
- t.Fatalf("with only compiled present, want %q, got %q", compiled, got)
- }
-
- if err := os.WriteFile(root, []byte("x"), 0o644); err != nil {
- t.Fatal(err)
- }
- if got := featureEvidencePath(dir); got != root {
- t.Fatalf("with both present, legacy root must win to match pr-context, got %q", got)
- }
-}
-
-// TestRecordGateResolvesCompiledEvidenceLedger is the proof of fix: a delivery whose
-// ledger lives ONLY at compiled/evidence.md (the legacy repository state) must gate cleanly
-// with no explicit --evidence. This fails on the pre-fix recorder, which resolved
-// only the feature root.
-func TestRecordGateResolvesCompiledEvidenceLedger(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- dir := filepath.Join(repo, ".product-loop", "features", feature)
-
- // Move the edited gate ledger to the compiled layout and drop the feature-root
- // copy, so only compiled/evidence.md carries the gate outcomes.
- ledger := "# Evidence ledger\n\n- Test gate (phase-one): `PASS`\n- Review gate (phase-one): `PASS`\n- Test gate (phase-two): `BLOCKED`\n- Review gate (phase-two): `BLOCKED`\n"
- if err := os.WriteFile(filepath.Join(dir, "compiled", "evidence.md"), []byte(ledger), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.Remove(filepath.Join(dir, "evidence.md")); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "-A")
- runGit(t, repo, "commit", "-m", "keep the evidence ledger only in the compiled layout")
-
- receipt, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"})
- if err != nil {
- t.Fatalf("recorder could not resolve compiled-only evidence ledger: %v", err)
- }
- if !strings.HasSuffix(filepath.ToSlash(receipt.EvidencePath), "compiled/evidence.md") {
- t.Fatalf("recorder bound the wrong evidence path: %q", receipt.EvidencePath)
- }
-}
-
-// TestRecordGateStillResolvesFeatureRootEvidence guards the legacy layout: a ledger
-// at the feature root (no compiled copy) must still gate, so the added fallback is
-// strictly additive.
-func TestRecordGateStillResolvesFeatureRootEvidence(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- dir := filepath.Join(repo, ".product-loop", "features", feature)
-
- // activateTwoSliceDelivery already writes the ledger at the feature root; remove
- // the compiled copy so only the legacy location remains.
- if err := os.Remove(filepath.Join(dir, "compiled", "evidence.md")); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "-A")
- runGit(t, repo, "commit", "-m", "keep the evidence ledger only at the feature root")
-
- receipt, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"})
- if err != nil {
- t.Fatalf("recorder could not resolve feature-root evidence ledger: %v", err)
- }
- if strings.Contains(filepath.ToSlash(receipt.EvidencePath), "compiled/") {
- t.Fatalf("recorder ignored the legacy feature-root ledger: %q", receipt.EvidencePath)
- }
-}
-
-// TestRecorderAndPRContextResolveSameEvidence is the anti-drift invariant: the
-// recorder's default evidence resolution and pr-context both route through
-// featureEvidencePath, so for any given feature they can never resolve different
-// evidence files. This is the structural guarantee the layers cannot re-diverge —
-// the compiled-artifact analogue of the published-slice addressability invariant.
-func TestRecorderAndPRContextResolveSameEvidence(t *testing.T) {
- dir := t.TempDir()
- compiled := filepath.Join(dir, "compiled", "evidence.md")
- if err := os.MkdirAll(filepath.Dir(compiled), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(compiled, []byte("x"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- // The recorder's default (empty --evidence) and pr-context's managedPRSources
- // both call featureEvidencePath(dir); assert that single resolver returns the
- // file that actually exists rather than a hand-joined feature-root guess.
- if got := featureEvidencePath(dir); got != compiled {
- t.Fatalf("shared resolver must return the existing ledger both consumers read, got %q", got)
- }
-}
diff --git a/boatstack/config_authority_conformance_test.go b/boatstack/config_authority_conformance_test.go
deleted file mode 100644
index 3cc1535..0000000
--- a/boatstack/config_authority_conformance_test.go
+++ /dev/null
@@ -1,467 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "reflect"
- "sort"
- "strings"
- "sync"
- "testing"
-)
-
-func writeRepositoryConfig(t *testing.T, repo, name string) []byte {
- t.Helper()
- raw := []byte(`{"schema_version":1,"project":{"name":"` + name + `","commands":{"test":"go test ./..."}}}` + "\n")
- if err := os.WriteFile(filepath.Join(repo, sourceConfigName), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- return raw
-}
-
-// Positive, relation, and failure-state conformance for:
-// control-law: detached-config-divergence-never-controls-ordinary-tools.
-func TestRepositoryDivergenceRequiresOnlyExplicitRebind(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/config-authority.git")
- writeRepositoryConfig(t, repo, "configuration-a")
- attached, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || attached.VerificationStatus != "VERIFIED" || attached.ConfigAuthority != ConfigAuthorityRepository {
- t.Fatalf("attach: %+v %v", attached, err)
- }
- rawB := writeRepositoryConfig(t, repo, "configuration-b")
-
- topology, err := ResolveConfigurationTopology(repo)
- if err != nil || topology.Relation != ConfigRelationDiverged || topology.Authority != ConfigAuthorityRepository {
- t.Fatalf("topology did not expose repository divergence: %+v %v", topology, err)
- }
- if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`)}); denied {
- t.Fatalf("ordinary inspection was blocked by configuration divergence: %s", output)
- }
- if _, err := RequireManagedConfiguration(repo); err == nil || !strings.Contains(err.Error(), "CONFIG_REBIND_REQUIRED") {
- t.Fatalf("explicit managed operation did not require rebind: %v", err)
- }
-
- preview, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository})
- if err != nil || preview.VerificationStatus != "VERIFIED" || preview.Applied || preview.Fingerprint == "" {
- t.Fatalf("preview: %+v %v", preview, err)
- }
- if !strings.Contains(preview.NextOperation, WorkspaceFor(repo).HelperPath()) {
- t.Fatalf("preview did not prescribe the workspace-bound helper: %s", preview.NextOperation)
- }
- if current, _ := os.ReadFile(WorkspaceFor(repo).SourceConfigPath()); string(current) == string(rawB) {
- t.Fatal("read-only preview changed controller configuration")
- }
- applied, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository, Apply: true, ExpectedFingerprint: preview.Fingerprint})
- if err != nil || applied.VerificationStatus != "VERIFIED" || !applied.Applied {
- t.Fatalf("apply: %+v %v", applied, err)
- }
- status, err := DetachedStatus(repo)
- if err != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch || status.ConfigAuthority != ConfigAuthorityRepository || status.ControllerConfigSHA256 != SHA256Bytes(rawB) {
- t.Fatalf("rebound status: %+v %v", status, err)
- }
-}
-
-// Negative and bypass conformance for:
-// control-law: config-rebind-apply-requires-current-preview.
-func TestConfigRebindRejectsStaleFingerprintAndUnsafeSources(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/config-rebind-negative.git")
- writeRepositoryConfig(t, repo, "configuration-a")
- if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- writeRepositoryConfig(t, repo, "configuration-b")
- preview, _ := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository})
- writeRepositoryConfig(t, repo, "configuration-c")
- stale, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository, Apply: true, ExpectedFingerprint: preview.Fingerprint})
- if err != nil || stale.VerificationStatus != "BLOCKED" || stale.Applied {
- t.Fatalf("stale fingerprint accepted: %+v %v", stale, err)
- }
- inside := filepath.Join(repo, "inside.json")
- if err := os.WriteFile(inside, []byte(`{"schema_version":1,"project":{"name":"inside","commands":{"test":"go test ./..."}}}`), 0o644); err != nil {
- t.Fatal(err)
- }
- unsafe, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceFile, ConfigPath: inside})
- if err != nil || unsafe.VerificationStatus != "BLOCKED" || !strings.Contains(unsafe.Reason, "outside the repository") {
- t.Fatalf("unsafe file source accepted: %+v %v", unsafe, err)
- }
-}
-
-// Relation conformance for:
-// control-law: external-snapshot-authority-is-independent-of-repository-source.
-func TestExternalSnapshotIgnoresRepositoryConfigurationChanges(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/external-authority.git")
- external, _ := externalConfigFixture(t, "external", "go test ./...")
- result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: external})
- if err != nil || result.VerificationStatus != "VERIFIED" || result.ConfigAuthority != ConfigAuthorityExternalSnapshot {
- t.Fatalf("attach: %+v %v", result, err)
- }
- writeRepositoryConfig(t, repo, "repository-change")
- topology, err := RequireManagedConfiguration(repo)
- if err != nil || topology.Relation != ConfigRelationIndependent {
- t.Fatalf("repository change controlled external snapshot: %+v %v", topology, err)
- }
-}
-
-// Positive conformance for:
-// control-law: detached-only-update-performs-zero-plant-writes.
-func TestDetachedOnlyUpdateLeavesRepositoryBytesUnchanged(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/detached-update.git")
- external, _ := externalConfigFixture(t, "detached-only", "go test ./...")
- if result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: external}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- before := filesystemSnapshot(t, repo)
- if err := RunUpdate(InitOptions{Repo: repo, Update: true, Yes: true}); err != nil {
- t.Fatalf("detached-only update: %v", err)
- }
- if after := filesystemSnapshot(t, repo); after != before {
- t.Fatal("detached-only update changed repository or Git bytes")
- }
-}
-
-// Relation conformance for:
-// control-law: configuration-writers-preserve-the-declared-authority.
-func TestCapabilityRegistrationCannotInvalidateItsRepositoryBinding(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/capability-authority.git")
- writeRepositoryConfig(t, repo, "capability-authority")
- if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- registered, err := RegisterCapabilityCommand(repo, "visual", "settings", "npm run capture:settings")
- if err != nil || registered.Alias != "visual:settings" {
- t.Fatalf("register: %+v %v", registered, err)
- }
- status, err := DetachedStatus(repo)
- if err != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch {
- t.Fatalf("capability registration invalidated binding: %+v %v", status, err)
- }
- repository, _, err := LoadConfig(filepath.Join(repo, sourceConfigName))
- if err != nil || repository.Project.Commands["visual:settings"] != "npm run capture:settings" {
- t.Fatalf("repository authority was not updated: %+v %v", repository, err)
- }
-}
-
-// Failure-state conformance for:
-// control-law: successful-config-mutations-preserve-detached-verification.
-func TestIgnoreDeliveryCannotInvalidateItsRepositoryBinding(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/ignore-authority.git")
- writeRepositoryConfig(t, repo, "ignore-authority")
- if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
-
- for _, feature := range []string{"old-one", "old-two"} {
- added, err := IgnoreDelivery(repo, feature)
- if err != nil || !added {
- t.Fatalf("ignore %s: added=%v err=%v", feature, added, err)
- }
- status, statusErr := DetachedStatus(repo)
- if statusErr != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch {
- t.Fatalf("ignore %s invalidated detached binding: %+v %v", feature, status, statusErr)
- }
- }
-
- repository, _, err := LoadConfig(filepath.Join(repo, sourceConfigName))
- if err != nil || !reflect.DeepEqual(repository.Workflow.IgnoredDeliveries, []string{"old-one", "old-two"}) {
- t.Fatalf("repository authority was not updated: %+v %v", repository.Workflow.IgnoredDeliveries, err)
- }
-}
-
-// Positive and relation conformance for:
-// control-law: successful-config-mutations-preserve-declared-authority.
-func TestConfigurationMutationPreservesEveryAuthorityMode(t *testing.T) {
- t.Run("embedded-source", func(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/embedded-source.git")
- raw := writeRepositoryConfig(t, repo, "embedded-source")
- config, _, err := LoadConfig(filepath.Join(repo, sourceConfigName))
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(filepath.Join(repo, sourceConfigName), config, embeddedConfigBytes(raw), "boatstack")
- if err != nil || WriteExport(repo, bundle.Files) != nil {
- t.Fatalf("embedded fixture: %v", err)
- }
- if added, err := IgnoreDelivery(repo, "old-embedded"); err != nil || !added {
- t.Fatalf("ignore: added=%v err=%v", added, err)
- }
- for _, path := range []string{filepath.Join(repo, sourceConfigName), filepath.Join(repo, productLoopDirName, "project.json")} {
- loaded, _, err := LoadConfig(path)
- if err != nil || !reflect.DeepEqual(loaded.Workflow.IgnoredDeliveries, []string{"old-embedded"}) {
- t.Fatalf("%s did not receive mutation: %+v %v", path, loaded.Workflow.IgnoredDeliveries, err)
- }
- }
- })
-
- t.Run("detached-repository-hybrid", func(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/hybrid-authority.git")
- raw := writeRepositoryConfig(t, repo, "hybrid-authority")
- config, _, err := LoadConfig(filepath.Join(repo, sourceConfigName))
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(filepath.Join(repo, sourceConfigName), config, embeddedConfigBytes(raw), "boatstack")
- if err != nil || WriteExport(repo, bundle.Files) != nil {
- t.Fatalf("hybrid fixture: %v", err)
- }
- if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- if added, err := IgnoreDelivery(repo, "old-hybrid"); err != nil || !added {
- t.Fatalf("ignore: added=%v err=%v", added, err)
- }
- status, statusErr := DetachedStatus(repo)
- if statusErr != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch {
- t.Fatalf("hybrid mutation invalidated binding: %+v %v", status, statusErr)
- }
- repository, _, repositoryErr := LoadConfig(filepath.Join(repo, productLoopDirName, "project.json"))
- controller, _, controllerErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if repositoryErr != nil || controllerErr != nil {
- t.Fatalf("load hybrid projections: repository=%v controller=%v", repositoryErr, controllerErr)
- }
- if !reflect.DeepEqual(repository.Workflow.IgnoredDeliveries, []string{"old-hybrid"}) ||
- !reflect.DeepEqual(controller.Workflow.IgnoredDeliveries, []string{"old-hybrid"}) {
- t.Fatalf("hybrid projections diverged: repo=%v controller=%v", repository.Workflow.IgnoredDeliveries, controller.Workflow.IgnoredDeliveries)
- }
- })
-
- for _, test := range []struct {
- name string
- authority string
- configPath func(*testing.T) string
- }{
- {name: "detached-external", authority: ConfigAuthorityExternalSnapshot, configPath: func(t *testing.T) string {
- path, _ := externalConfigFixture(t, "external-ignore", "go test ./...")
- return path
- }},
- {name: "detached-synthesized", authority: ConfigAuthoritySynthesized},
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/"+test.name+".git")
- options := AttachOptions{Repo: repo}
- if test.configPath != nil {
- options.ConfigPath = test.configPath(t)
- }
- before := filesystemSnapshot(t, repo)
- attached, err := AttachDetached(options)
- if err != nil || attached.VerificationStatus != "VERIFIED" || attached.ConfigAuthority != test.authority {
- t.Fatalf("attach: %+v %v", attached, err)
- }
- if added, err := IgnoreDelivery(repo, "old-local"); err != nil || !added {
- t.Fatalf("ignore: added=%v err=%v", added, err)
- }
- if after := filesystemSnapshot(t, repo); after != before {
- t.Fatal("controller-authority mutation changed repository bytes")
- }
- status, statusErr := DetachedStatus(repo)
- if statusErr != nil || !status.Verified || status.ConfigAuthority != test.authority || status.ConfigRelation == ConfigRelationDiverged {
- t.Fatalf("controller-authority mutation invalidated binding: %+v %v", status, statusErr)
- }
- controller, _, err := LoadConfig(WorkspaceFor(repo).SourceConfigPath())
- if err != nil || !reflect.DeepEqual(controller.Workflow.IgnoredDeliveries, []string{"old-local"}) {
- t.Fatalf("controller source was not updated: %+v %v", controller.Workflow.IgnoredDeliveries, err)
- }
- })
- }
-}
-
-// Failure-state conformance for:
-// control-law: failed-config-mutations-restore-every-projection.
-func TestConfigurationMutationRollsBackEveryDetachedCheckpoint(t *testing.T) {
- for _, checkpoint := range []string{
- "controller-projection-written",
- "repository-source-written",
- "controller-source-written",
- "binding-written",
- } {
- t.Run(checkpoint, func(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/rollback-"+checkpoint+".git")
- writeRepositoryConfig(t, repo, "rollback")
- attached, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || attached.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", attached, err)
- }
- ctx := WorkspaceFor(repo)
- beforeRepo := filesystemSnapshot(t, repo)
- beforeController := filesystemSnapshot(t, ctx.controlRoot)
- previous := configMutationCheckpoint
- configMutationCheckpoint = func(stage string) error {
- if stage == checkpoint {
- return fmt.Errorf("simulated interruption at %s", stage)
- }
- return nil
- }
- t.Cleanup(func() { configMutationCheckpoint = previous })
-
- if added, err := IgnoreDelivery(repo, "must-rollback"); err == nil || added {
- t.Fatalf("checkpoint did not fail: added=%v err=%v", added, err)
- }
- if after := filesystemSnapshot(t, repo); after != beforeRepo {
- t.Fatalf("repository bytes changed after rollback at %s", checkpoint)
- }
- if after := filesystemSnapshot(t, ctx.controlRoot); after != beforeController {
- t.Fatalf("controller bytes changed after rollback at %s", checkpoint)
- }
- status, statusErr := DetachedStatus(repo)
- if statusErr != nil || !status.Verified || status.ConfigRelation != ConfigRelationMatch {
- t.Fatalf("rollback left controller invalid: %+v %v", status, statusErr)
- }
- })
- }
-}
-
-// Correlation and relation conformance for:
-// control-law: concurrent-config-mutations-cannot-lose-accepted-updates.
-func TestConfigurationMutationSerializesConcurrentWriters(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/concurrent-config.git")
- writeRepositoryConfig(t, repo, "concurrent-config")
- if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
-
- features := []string{"old-alpha", "old-beta"}
- errors := make(chan error, len(features))
- var wait sync.WaitGroup
- for _, feature := range features {
- feature := feature
- wait.Add(1)
- go func() {
- defer wait.Done()
- added, err := IgnoreDelivery(repo, feature)
- if err != nil {
- errors <- err
- return
- }
- if !added {
- errors <- fmt.Errorf("%s was not added", feature)
- }
- }()
- }
- wait.Wait()
- close(errors)
- for err := range errors {
- if err != nil {
- t.Fatal(err)
- }
- }
- config, _, err := LoadConfig(filepath.Join(repo, sourceConfigName))
- if err != nil {
- t.Fatal(err)
- }
- got := append([]string(nil), config.Workflow.IgnoredDeliveries...)
- sort.Strings(got)
- if !reflect.DeepEqual(got, features) {
- t.Fatalf("concurrent update lost a slug: %v", got)
- }
- status, statusErr := DetachedStatus(repo)
- if statusErr != nil || !status.Verified {
- t.Fatalf("concurrent update invalidated binding: %+v %v", status, statusErr)
- }
-}
-
-// Negative and failure-state conformance for:
-// control-law: ambiguous-config-authority-is-refused-before-writes.
-func TestConfigurationMutationRefusesLegacyAuthorityBeforeWrites(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/legacy-config-writer.git")
- writeRepositoryConfig(t, repo, "legacy-config-writer")
- attached, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || attached.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", attached, err)
- }
- stateRoot, _ := detachedStateRoot()
- binding, err := loadBinding(stateRoot, attached.RepoID)
- if err != nil {
- t.Fatal(err)
- }
- binding.SchemaVersion = detachedSchemaVersionWithConfigDigest
- binding.ConfigAuthority = ""
- raw, _ := MarshalJSON(binding)
- if err := atomicWrite(bindingPath(stateRoot, attached.RepoID), raw); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- beforeRepo := filesystemSnapshot(t, repo)
- beforeController := filesystemSnapshot(t, WorkspaceFor(repo).controlRoot)
- if added, err := IgnoreDelivery(repo, "old-legacy"); err == nil || added || !strings.Contains(err.Error(), "CONFIG_REBIND_REQUIRED") {
- t.Fatalf("legacy authority was not refused: added=%v err=%v", added, err)
- }
- if filesystemSnapshot(t, repo) != beforeRepo || filesystemSnapshot(t, WorkspaceFor(repo).controlRoot) != beforeController {
- t.Fatal("legacy refusal changed configuration bytes")
- }
-}
-
-// Negative and bypass conformance for:
-// control-law: generic-writers-never-cross-configuration-authority.
-func TestMigrationAndExportCannotCrossOrGuessAuthority(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/config-writer-boundary.git")
- external, _ := externalConfigFixture(t, "external", "go test ./...")
- if result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: external}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- writeRepositoryConfig(t, repo, "repository")
- if err := os.MkdirAll(filepath.Join(repo, productLoopDirName), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "generated.lock.json"), []byte("{}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- report, err := MigrateManagedConfiguration(repo, "", true)
- if err != nil || report.Status != "FAIL" || !strings.Contains(report.Message, "requires --target") {
- t.Fatalf("hybrid migration guessed a target: %+v %v", report, err)
- }
- if err := ValidateConfigurationExport(repo, filepath.Join(repo, sourceConfigName), true); err == nil || !strings.Contains(err.Error(), "cannot cross") {
- t.Fatalf("export crossed external authority: %v", err)
- }
-}
-
-// Failure-state conformance for:
-// control-law: interrupted-rebind-never-accepts-a-mixed-projection.
-func TestInterruptedConfigRebindRestoresAcceptedState(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/config-rebind-interrupted.git")
- rawA := writeRepositoryConfig(t, repo, "configuration-a")
- if result, err := AttachDetached(AttachOptions{Repo: repo}); err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- writeRepositoryConfig(t, repo, "configuration-b")
- preview, _ := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository})
- previousCheckpoint := configRebindCheckpoint
- configRebindCheckpoint = func(stage string) error { return fmt.Errorf("simulated interruption at %s", stage) }
- defer func() { configRebindCheckpoint = previousCheckpoint }()
- if _, err := ConfigRebind(ConfigRebindOptions{Repo: repo, Source: ConfigRebindSourceRepository, Apply: true, ExpectedFingerprint: preview.Fingerprint}); err == nil {
- t.Fatal("simulated interruption unexpectedly succeeded")
- }
- controller, err := os.ReadFile(WorkspaceFor(repo).SourceConfigPath())
- if err != nil || string(controller) != string(rawA) {
- t.Fatalf("interrupted rebind left mixed controller source: %q %v", controller, err)
- }
- status, err := DetachedStatus(repo)
- if err != nil || !status.Verified || status.ControllerConfigSHA256 != SHA256Bytes(rawA) {
- t.Fatalf("interrupted rebind changed accepted binding: %+v %v", status, err)
- }
-}
-
-// Compatibility conformance for schema-v2 attachments.
-func TestMatchingSchemaV2AttachmentContinuesWithoutMigration(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/config-schema-v2.git")
- writeRepositoryConfig(t, repo, "legacy-matching")
- result, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- stateRoot, _ := detachedStateRoot()
- binding, err := loadBinding(stateRoot, result.RepoID)
- if err != nil {
- t.Fatal(err)
- }
- binding.SchemaVersion = detachedSchemaVersionWithConfigDigest
- binding.ConfigAuthority = ""
- raw, _ := MarshalJSON(binding)
- if err := atomicWrite(bindingPath(stateRoot, result.RepoID), raw); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- status, err := DetachedStatus(repo)
- if err != nil || !status.Verified || status.ConfigAuthority != ConfigAuthorityLegacyUnknown || status.ConfigRelation != ConfigRelationMatch {
- t.Fatalf("matching schema-v2 attachment did not continue: %+v %v", status, err)
- }
-}
diff --git a/boatstack/config_documentation_test.go b/boatstack/config_documentation_test.go
deleted file mode 100644
index 5f38ff3..0000000
--- a/boatstack/config_documentation_test.go
+++ /dev/null
@@ -1,157 +0,0 @@
-package boatstack
-
-import (
- "os"
- "reflect"
- "sort"
- "strings"
- "testing"
-)
-
-const (
- configFieldMarkerPrefix = "boatstack-config-field:"
- userConfigFieldMarkerPrefix = "boatstack-user-config-field:"
-)
-
-func configSurface(value reflect.Type, prefix string) []string {
- if value.Kind() == reflect.Pointer {
- value = value.Elem()
- }
- var fields []string
- for index := 0; index < value.NumField(); index++ {
- field := value.Field(index)
- name := strings.Split(field.Tag.Get("json"), ",")[0]
- if name == "" || name == "-" {
- continue
- }
- path := name
- if prefix != "" {
- path = prefix + "." + name
- }
- fields = append(fields, path)
-
- nested := field.Type
- if nested.Kind() == reflect.Pointer {
- nested = nested.Elem()
- }
- switch nested.Kind() {
- case reflect.Struct:
- fields = append(fields, configSurface(nested, path)...)
- case reflect.Map:
- item := nested.Elem()
- if item.Kind() == reflect.Struct {
- fields = append(fields, configSurface(item, path+".*")...)
- }
- }
- }
- return fields
-}
-
-func configFieldMarkers(content, prefix string) []string {
- var fields []string
- for _, line := range strings.Split(content, "\n") {
- line = strings.TrimSpace(line)
- if strings.HasPrefix(line, prefix) {
- fields = append(fields, strings.TrimPrefix(line, prefix))
- }
- }
- sort.Strings(fields)
- return fields
-}
-
-func documentedConfigSurface(t *testing.T, path, prefix string) []string {
- t.Helper()
- content, err := os.ReadFile(path)
- if err != nil {
- t.Fatalf("read configuration documentation %s: %v", path, err)
- }
- return configFieldMarkers(string(content), prefix)
-}
-
-func publicConfigurationDocument(t *testing.T) string {
- t.Helper()
- candidates := []string{
- "../boatstack-distribution/CONFIGURATION.md",
- "../docs/configuration.md",
- }
- var found []string
- for _, candidate := range candidates {
- _, err := os.Stat(candidate)
- switch {
- case err == nil:
- found = append(found, candidate)
- case os.IsNotExist(err):
- continue
- default:
- t.Fatalf("inspect public configuration document %s: %v", candidate, err)
- }
- }
- if len(found) != 1 {
- t.Fatalf("expected exactly one public configuration document from %v, found %v", candidates, found)
- }
- return found[0]
-}
-
-func TestConfigFieldMarkersAcceptWindowsLineEndings(t *testing.T) {
- content := "\r\n"
- want := []string{"project.name", "workflow"}
- if got := configFieldMarkers(content, configFieldMarkerPrefix); !reflect.DeepEqual(got, want) {
- t.Fatalf("CRLF configuration markers were not parsed: got %v, want %v", got, want)
- }
-}
-
-func TestSerializedConfigurationSurfaceIsDocumentedInternally(t *testing.T) {
- want := configSurface(reflect.TypeOf(ProjectConfig{}), "")
- sort.Strings(want)
- document := "references/config-schema.md"
- got := documentedConfigSurface(t, document, configFieldMarkerPrefix)
- if !reflect.DeepEqual(got, want) {
- t.Errorf("configuration documentation drift in %s\nimplementation: %v\ndocumented: %v", document, want, got)
- }
-}
-
-func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) {
- want := []string{
- "adapters",
- "delivery.terminal",
- "insights.capture_mode",
- "insights.completion_mode",
- "insights.enabled",
- "insights.evaluate_on_pr",
- "insights.pending_frontier",
- "insights.suggest_features",
- "insights.value_map",
- "project.commands",
- "project.context",
- "project.default_branch",
- "project.high_risk_paths",
- "workflow.allow_pass_with_gaps",
- "workflow.boundary_analysis",
- "workflow.external_authority.mode",
- "workflow.external_authority.trust_store",
- "workflow.human_plan_approval",
- "workflow.ignored_deliveries",
- "workflow.independent_review_for_high_risk",
- "workflow.maintain_changelog",
- "workflow.pr_visual_evidence",
- "workflow.visual_evidence_publish.expiry",
- "workflow.visual_evidence_publish.host",
- "workflow.visual_evidence_publish.mode",
- "workspace.cleanup",
- "workspace.cleanup_after",
- "workspace.enabled",
- "workspace.mode",
- "workspace.reap",
- }
- document := publicConfigurationDocument(t)
- got := documentedConfigSurface(t, document, userConfigFieldMarkerPrefix)
- if !reflect.DeepEqual(got, want) {
- t.Errorf("public user-control documentation drift in %s\nsupported: %v\ndocumented: %v", document, want, got)
- }
- serialized := configSurface(reflect.TypeOf(ProjectConfig{}), "")
- for _, field := range got {
- if !contains(serialized, field) {
- t.Errorf("public guide exposes unknown configuration field %s", field)
- }
- }
-}
diff --git a/boatstack/config_event_registry_test.go b/boatstack/config_event_registry_test.go
deleted file mode 100644
index 0c39871..0000000
--- a/boatstack/config_event_registry_test.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package boatstack
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "os"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "testing"
-)
-
-// configEventClasses is the public event-completeness registry. Every call to a
-// configuration reader, renderer, writer, resolver, or admission boundary is
-// inventoried below by the AST digest. Adding an unreviewed call changes the
-// digest and fails CI.
-var configEventClasses = map[string]string{
- "LoadConfig": "reader",
- "SourceConfigPath": "resolver",
- "ProjectConfigPath": "resolver",
- "BuildExportBundle": "renderer",
- "WriteExport": "writer",
- "WriteExportForRepair": "writer",
- "writeExport": "writer",
- "MigrateConfigBytes": "writer",
- "ResolveConfigurationTopology": "resolver",
- "RequireManagedConfiguration": "admission",
- "ConfigRebind": "writer",
- "mutateManagedConfiguration": "writer",
-}
-
-var ordinaryConfigurationWriters = map[string]bool{
- "IgnoreDelivery": true,
- "RegisterCapabilityCommand": true,
-}
-
-func calledName(call *ast.CallExpr) string {
- switch function := call.Fun.(type) {
- case *ast.Ident:
- return function.Name
- case *ast.SelectorExpr:
- return function.Sel.Name
- default:
- return ""
- }
-}
-
-func TestConfigurationEventRegistryIsComplete(t *testing.T) {
- entries := []string{}
- set := token.NewFileSet()
- files, err := filepath.Glob("*.go")
- if err != nil {
- t.Fatal(err)
- }
- for _, path := range files {
- if strings.HasSuffix(path, "_test.go") {
- continue
- }
- parsed, err := parser.ParseFile(set, path, nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- for _, declaration := range parsed.Decls {
- function, ok := declaration.(*ast.FuncDecl)
- if !ok || function.Body == nil {
- continue
- }
- counts := map[string]int{}
- ast.Inspect(function.Body, func(node ast.Node) bool {
- call, ok := node.(*ast.CallExpr)
- if !ok {
- return true
- }
- name := calledName(call)
- class, tracked := configEventClasses[name]
- if !tracked {
- return true
- }
- counts[name]++
- entries = append(entries, filepath.Base(path)+":"+function.Name.Name+":"+name+":"+class+":"+strconv.Itoa(counts[name]))
- return true
- })
- }
- }
- sort.Strings(entries)
- digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
- const expected = "72481d05df0ef6d80a42b62f0e917ac173c436c970ed3f1aaaa8430aa41911a2"
- if digest != expected {
- _ = os.WriteFile(filepath.Join(t.TempDir(), "config-events.txt"), []byte(strings.Join(entries, "\n")+"\n"), 0o644)
- t.Fatalf("configuration event registry changed: got %s; classify the new or removed site and update the reviewed digest", digest)
- }
-}
-
-// Ordinary command handlers may describe a configuration change, but only the
-// shared mutation boundary may resolve sources, render projections, or write
-// accepted bytes. This prevents another generated-only writer from recreating
-// the detached self-invalidation class.
-func TestOrdinaryConfigurationWritersUseManagedBoundary(t *testing.T) {
- found := map[string]bool{}
- set := token.NewFileSet()
- files, err := filepath.Glob("*.go")
- if err != nil {
- t.Fatal(err)
- }
- for _, path := range files {
- if strings.HasSuffix(path, "_test.go") {
- continue
- }
- parsed, err := parser.ParseFile(set, path, nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- for _, declaration := range parsed.Decls {
- function, ok := declaration.(*ast.FuncDecl)
- if !ok || function.Body == nil || !ordinaryConfigurationWriters[function.Name.Name] {
- continue
- }
- found[function.Name.Name] = true
- usesBoundary := false
- rawWriters := []string{}
- ast.Inspect(function.Body, func(node ast.Node) bool {
- call, ok := node.(*ast.CallExpr)
- if !ok {
- return true
- }
- name := calledName(call)
- if name == "mutateManagedConfiguration" {
- usesBoundary = true
- }
- switch name {
- case "atomicWrite", "atomicWriteMode", "WriteExport", "writeExport", "GeneratedJSON", "BuildExportBundle":
- rawWriters = append(rawWriters, name)
- }
- return true
- })
- if !usesBoundary || len(rawWriters) > 0 {
- t.Errorf("%s must use only mutateManagedConfiguration; boundary=%v raw=%v", function.Name.Name, usesBoundary, rawWriters)
- }
- }
- }
- for name := range ordinaryConfigurationWriters {
- if !found[name] {
- t.Errorf("ordinary configuration writer %s was not found", name)
- }
- }
-}
diff --git a/boatstack/config_mutation.go b/boatstack/config_mutation.go
deleted file mode 100644
index e4b43b6..0000000
--- a/boatstack/config_mutation.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "strings"
-)
-
-type ConfigMigrationResult struct {
- Status string `json:"status"`
- Message string `json:"message,omitempty"`
- Target string `json:"target,omitempty"`
- FromVersion int `json:"from_version"`
- ToVersion int `json:"to_version"`
- Changed bool `json:"changed"`
-}
-
-func commitDetachedConfigBinding(topology ConfigurationTopology, raw []byte) error {
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return err
- }
- binding, err := loadBinding(stateRoot, topology.RepoID)
- if err != nil {
- return err
- }
- binding.SchemaVersion = detachedSchemaVersion
- binding.ConfigSHA256 = SHA256Bytes(raw)
- binding.ConfigAuthority = topology.Authority
- binding.CreatedByVersion = Version
- bindingRaw, err := MarshalJSON(binding)
- if err != nil {
- return err
- }
- if err := atomicWrite(bindingPath(stateRoot, topology.RepoID), bindingRaw); err != nil {
- return err
- }
- invalidateWorkspaceCache()
- return nil
-}
-
-func MigrateManagedConfiguration(repoPath, requestedTarget string, check bool) (ConfigMigrationResult, error) {
- topology, err := RequireManagedConfiguration(repoPath)
- if err != nil {
- return ConfigMigrationResult{Status: "FAIL", Message: err.Error()}, nil
- }
- target := strings.ToLower(strings.TrimSpace(requestedTarget))
- switch topology.Shape {
- case ConfigShapeEmbeddedOnly:
- if target == "" {
- target = "repository"
- }
- case ConfigShapeDetachedOnly:
- if target == "" {
- target = "controller"
- if topology.Authority == ConfigAuthorityRepository {
- target = "repository"
- }
- }
- case ConfigShapeHybrid:
- if target == "" {
- return ConfigMigrationResult{Status: "FAIL", Message: "migrate-config requires --target repository or --target controller for a hybrid installation"}, nil
- }
- }
- if target != "repository" && target != "controller" {
- return ConfigMigrationResult{Status: "FAIL", Message: "migrate-config --target must be repository or controller"}, nil
- }
- if topology.Authority == ConfigAuthorityLegacyUnknown && topology.Mode == string(SupervisionDetached) {
- return ConfigMigrationResult{Status: "FAIL", Message: "CONFIG_REBIND_REQUIRED: migration needs an explicit detached configuration authority"}, nil
- }
- if target == "repository" && topology.Mode == string(SupervisionDetached) && topology.Authority != ConfigAuthorityRepository {
- return ConfigMigrationResult{Status: "FAIL", Message: "CONFIG_REBIND_REQUIRED: repository migration would cross detached configuration authority"}, nil
- }
- if target == "controller" && topology.Shape == ConfigShapeHybrid && topology.Authority == ConfigAuthorityRepository {
- return ConfigMigrationResult{Status: "FAIL", Message: "CONFIG_REBIND_REQUIRED: controller-only migration would split repository authority"}, nil
- }
-
- sourcePath := topology.RepositorySourcePath
- if target == "controller" {
- sourcePath = topology.ControllerSourcePath
- }
- raw, err := osReadFile(sourcePath)
- if err != nil {
- return ConfigMigrationResult{Status: "FAIL", Message: fmt.Sprintf("failed to read config: %v", err), Target: target}, nil
- }
- upgraded, fromVersion, toVersion, changed, err := MigrateConfigBytes(raw)
- if err != nil {
- return ConfigMigrationResult{Status: "FAIL", Message: fmt.Sprintf("migration failed: %v", err), Target: target}, nil
- }
- result := ConfigMigrationResult{Status: "PASS", Target: target, FromVersion: fromVersion, ToVersion: toVersion, Changed: changed}
- if check || !changed {
- return result, nil
- }
- config, err := configFromBytes(sourcePath, upgraded)
- if err != nil {
- return ConfigMigrationResult{Status: "FAIL", Message: err.Error(), Target: target}, nil
- }
- if target == "repository" {
- repositoryBundle, buildErr := BuildExportBundle(topology.RepositorySourcePath, config, embeddedConfigBytes(upgraded), "boatstack")
- if buildErr != nil {
- return ConfigMigrationResult{}, buildErr
- }
- if topology.RepositoryPackagePresent {
- if err := WriteExport(topology.RepositoryBundleRoot, repositoryBundle.Files); err != nil {
- return ConfigMigrationResult{}, err
- }
- }
- if err := atomicWriteMode(topology.RepositorySourcePath, upgraded, 0o644); err != nil {
- return ConfigMigrationResult{}, err
- }
- if topology.Mode == string(SupervisionDetached) {
- controllerBundle, buildErr := BuildExportBundle(topology.ControllerSourcePath, config, upgraded, "boatstack")
- if buildErr != nil {
- return ConfigMigrationResult{}, buildErr
- }
- if err := WriteExport(topology.ControllerBundleRoot, controllerBundle.Files); err != nil {
- return ConfigMigrationResult{}, err
- }
- if err := atomicWriteMode(topology.ControllerSourcePath, upgraded, 0o644); err != nil {
- return ConfigMigrationResult{}, err
- }
- }
- } else {
- controllerBundle, buildErr := BuildExportBundle(topology.ControllerSourcePath, config, upgraded, "boatstack")
- if buildErr != nil {
- return ConfigMigrationResult{}, buildErr
- }
- if err := WriteExport(topology.ControllerBundleRoot, controllerBundle.Files); err != nil {
- return ConfigMigrationResult{}, err
- }
- if err := atomicWriteMode(topology.ControllerSourcePath, upgraded, 0o644); err != nil {
- return ConfigMigrationResult{}, err
- }
- }
- if topology.Mode == string(SupervisionDetached) {
- if err := commitDetachedConfigBinding(topology, upgraded); err != nil {
- return ConfigMigrationResult{}, err
- }
- }
- return result, nil
-}
-
-// Small indirections keep migration tests able to exercise read failures without
-// making the public mutation API stateful.
-var osReadFile = os.ReadFile
-
-func configFromBytes(path string, raw []byte) (ProjectConfig, error) {
- var config ProjectConfig
- if err := DecodeJSON("load migrated project configuration", path, raw, &config); err != nil {
- return ProjectConfig{}, err
- }
- if err := ValidateConfig(config); err != nil {
- return ProjectConfig{}, err
- }
- return config, nil
-}
diff --git a/boatstack/config_rebind.go b/boatstack/config_rebind.go
deleted file mode 100644
index f7d8cd0..0000000
--- a/boatstack/config_rebind.go
+++ /dev/null
@@ -1,321 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-const (
- ConfigRebindSourceRepository = "repository"
- ConfigRebindSourceController = "controller"
- ConfigRebindSourceFile = "file"
-)
-
-var configRebindCheckpoint = func(string) error { return nil }
-
-type ConfigRebindOptions struct {
- Repo string
- Source string
- ConfigPath string
- Apply bool
- ExpectedFingerprint string
-}
-
-type ConfigRebindResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- Applied bool `json:"applied"`
- Source string `json:"source"`
- OldAuthority string `json:"old_authority"`
- NewAuthority string `json:"new_authority"`
- OldConfigSHA256 string `json:"old_config_sha256"`
- NewConfigSHA256 string `json:"new_config_sha256"`
- RepositoryConfigSHA256 string `json:"repository_config_sha256,omitempty"`
- ControllerConfigSHA256 string `json:"controller_config_sha256"`
- AffectedWorktrees []string `json:"affected_worktrees"`
- TouchedRoots []string `json:"touched_roots"`
- Fingerprint string `json:"fingerprint"`
- NextOperation string `json:"next_operation,omitempty"`
- Reason string `json:"reason"`
-}
-
-type configRebindPreview struct {
- result ConfigRebindResult
- repo string
- stateRoot string
- ctx WorkspaceContext
- binding DetachedBinding
- rawConfig []byte
- config ProjectConfig
- repoBundle map[string][]byte
- ctrlBundle map[string][]byte
- authority string
- sourcePath string
-}
-
-func previewConfigRebind(opts ConfigRebindOptions) (configRebindPreview, error) {
- repo, err := ResolveRepository(opts.Repo)
- if err != nil {
- return configRebindPreview{}, err
- }
- topology, err := ResolveConfigurationTopology(repo)
- if err != nil {
- return configRebindPreview{}, err
- }
- if topology.Mode != string(SupervisionDetached) {
- return configRebindPreview{}, fmt.Errorf("config-rebind requires a detached attachment")
- }
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return configRebindPreview{}, err
- }
- ctx, attached, err := detachedContextFor(repo)
- if err != nil || !attached {
- if err == nil {
- err = fmt.Errorf("config-rebind requires a verified detached attachment")
- }
- return configRebindPreview{}, err
- }
- binding, err := loadBinding(stateRoot, ctx.RepoID)
- if err != nil {
- return configRebindPreview{}, err
- }
-
- source := strings.ToLower(strings.TrimSpace(opts.Source))
- var config ProjectConfig
- var raw []byte
- var sourcePath, authority string
- switch source {
- case ConfigRebindSourceRepository:
- sourcePath = repositorySourceConfigPath(repo)
- config, raw, err = LoadConfig(sourcePath)
- authority = ConfigAuthorityRepository
- case ConfigRebindSourceController:
- sourcePath = ctx.SourceConfigPath()
- config, raw, err = LoadConfig(sourcePath)
- authority = ConfigAuthorityExternalSnapshot
- case ConfigRebindSourceFile:
- if strings.TrimSpace(opts.ConfigPath) == "" {
- return configRebindPreview{}, fmt.Errorf("config-rebind --source file requires --config")
- }
- config, raw, err = loadDetachedAttachConfig(repo, opts.ConfigPath)
- sourcePath, _ = filepath.Abs(opts.ConfigPath)
- authority = ConfigAuthorityExternalSnapshot
- default:
- return configRebindPreview{}, fmt.Errorf("config-rebind --source must be repository, controller, or file")
- }
- if err != nil {
- return configRebindPreview{}, err
- }
- if err := ValidateConfig(config); err != nil {
- return configRebindPreview{}, err
- }
- controller, err := BuildExportBundle(ctx.SourceConfigPath(), config, raw, "boatstack")
- if err != nil {
- return configRebindPreview{}, err
- }
- var repositoryFiles map[string][]byte
- if source == ConfigRebindSourceRepository && topology.RepositoryPackagePresent {
- repository, buildErr := BuildExportBundle(repositorySourceConfigPath(repo), config, embeddedConfigBytes(raw), "boatstack")
- if buildErr != nil {
- return configRebindPreview{}, buildErr
- }
- repositoryFiles = repository.Files
- }
-
- newSHA := SHA256Bytes(raw)
- fingerprintInput := struct {
- SchemaVersion int `json:"schema_version"`
- BoatstackVersion string `json:"boatstack_version"`
- SourceCommit string `json:"source_commit"`
- RepoID string `json:"repo_id"`
- RepoRoot string `json:"repo_root"`
- WorktreeID string `json:"worktree_id"`
- Branch string `json:"branch"`
- Head string `json:"head"`
- Aliases []string `json:"aliases"`
- Shape string `json:"shape"`
- Source string `json:"source"`
- SourcePath string `json:"source_path"`
- SourceSHA256 string `json:"source_sha256"`
- BindingSHA256 string `json:"binding_sha256"`
- RepositorySHA256 string `json:"repository_sha256"`
- ControllerSHA256 string `json:"controller_sha256"`
- }{
- SchemaVersion: detachedSchemaVersion, BoatstackVersion: Version, SourceCommit: SourceCommit,
- RepoID: ctx.RepoID, RepoRoot: repo, WorktreeID: ctx.WorktreeID,
- Branch: gitOutput(repo, "branch", "--show-current"), Head: gitOutput(repo, "rev-parse", "HEAD"),
- Aliases: topology.AffectedWorktrees, Shape: topology.Shape, Source: source, SourcePath: sourcePath,
- SourceSHA256: newSHA, BindingSHA256: binding.ConfigSHA256,
- RepositorySHA256: topology.RepositoryConfigSHA256, ControllerSHA256: topology.ControllerConfigSHA256,
- }
- fingerprintBytes, err := MarshalJSON(fingerprintInput)
- if err != nil {
- return configRebindPreview{}, err
- }
- fingerprint := SHA256Bytes(fingerprintBytes)
- touched := []string{ctx.ExportRoot(), bindingPath(stateRoot, ctx.RepoID)}
- if len(repositoryFiles) > 0 {
- touched = append(touched, repo)
- }
- nextArgs := []string{"--repo", repo, "--source", source}
- if source == ConfigRebindSourceFile {
- nextArgs = append(nextArgs, "--config", sourcePath)
- }
- nextArgs = append(nextArgs, "--apply", "--expected-fingerprint", fingerprint, "--json")
- next := PrescribedCommand{Program: ctx.HelperPath(), Verb: "config-rebind", Args: nextArgs}.CommandLine()
- result := ConfigRebindResult{
- SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", Source: source,
- OldAuthority: normalizedConfigAuthority(binding), NewAuthority: authority,
- OldConfigSHA256: binding.ConfigSHA256, NewConfigSHA256: newSHA,
- RepositoryConfigSHA256: topology.RepositoryConfigSHA256, ControllerConfigSHA256: topology.ControllerConfigSHA256,
- AffectedWorktrees: topology.AffectedWorktrees, TouchedRoots: touched, Fingerprint: fingerprint,
- NextOperation: next, Reason: "Preview verified. No files changed.",
- }
- return configRebindPreview{result: result, repo: repo, stateRoot: stateRoot, ctx: ctx, binding: binding, rawConfig: raw, config: config, repoBundle: repositoryFiles, ctrlBundle: controller.Files, authority: authority, sourcePath: sourcePath}, nil
-}
-
-type savedFile struct {
- path string
- value []byte
- mode os.FileMode
- existed bool
-}
-
-func snapshotFiles(paths []string) ([]savedFile, error) {
- seen := map[string]bool{}
- out := []savedFile{}
- for _, path := range paths {
- if seen[path] {
- continue
- }
- seen[path] = true
- info, err := os.Lstat(path)
- if os.IsNotExist(err) {
- out = append(out, savedFile{path: path})
- continue
- }
- if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- if err == nil {
- err = fmt.Errorf("refusing non-regular transaction path: %s", path)
- }
- return nil, err
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- out = append(out, savedFile{path: path, value: value, mode: info.Mode().Perm(), existed: true})
- }
- return out, nil
-}
-
-func restoreFiles(saved []savedFile) error {
- for i := len(saved) - 1; i >= 0; i-- {
- item := saved[i]
- if !item.existed {
- if err := os.Remove(item.path); err != nil && !os.IsNotExist(err) {
- return err
- }
- continue
- }
- if err := atomicWriteMode(item.path, item.value, item.mode); err != nil {
- return err
- }
- }
- return nil
-}
-
-func ConfigRebind(opts ConfigRebindOptions) (result ConfigRebindResult, returnErr error) {
- preview, err := previewConfigRebind(opts)
- if err != nil {
- return ConfigRebindResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
- }
- if !opts.Apply {
- return preview.result, nil
- }
- if strings.TrimSpace(opts.ExpectedFingerprint) == "" || opts.ExpectedFingerprint != preview.result.Fingerprint {
- preview.result.VerificationStatus = "BLOCKED"
- preview.result.Reason = "The configuration topology changed or the expected fingerprint is missing. Preview again."
- return preview.result, nil
- }
-
- paths := []string{preview.ctx.SourceConfigPath(), bindingPath(preview.stateRoot, preview.ctx.RepoID)}
- for relative := range preview.ctrlBundle {
- paths = append(paths, filepath.Join(preview.ctx.ExportRoot(), filepath.FromSlash(relative)))
- }
- for relative := range preview.repoBundle {
- paths = append(paths, filepath.Join(preview.repo, filepath.FromSlash(relative)))
- }
- receiptPath := filepath.Join(preview.ctx.controlRoot, "operations", "config-rebind", preview.result.Fingerprint+".json")
- paths = append(paths, receiptPath)
- saved, err := snapshotFiles(paths)
- if err != nil {
- return ConfigRebindResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
- }
- defer func() {
- if returnErr != nil {
- if rollbackErr := restoreFiles(saved); rollbackErr != nil {
- returnErr = fmt.Errorf("%v; config-rebind rollback failed: %w", returnErr, rollbackErr)
- }
- }
- }()
-
- receipt, err := MarshalJSON(map[string]any{
- "schema_version": 1, "operation": "config-rebind", "state": "PREPARED",
- "fingerprint": preview.result.Fingerprint, "repo_id": preview.ctx.RepoID,
- "old_config_sha256": preview.binding.ConfigSHA256, "new_config_sha256": preview.result.NewConfigSHA256,
- })
- if err != nil {
- return ConfigRebindResult{}, err
- }
- if err := atomicWrite(receiptPath, receipt); err != nil {
- return ConfigRebindResult{}, err
- }
- if len(preview.repoBundle) > 0 {
- for _, relative := range sortedKeys(preview.repoBundle) {
- if err := atomicWriteMode(filepath.Join(preview.repo, filepath.FromSlash(relative)), preview.repoBundle[relative], generatedFileMode(relative)); err != nil {
- return ConfigRebindResult{}, err
- }
- }
- if err := CheckExport(preview.repo, preview.repoBundle); err != nil {
- return ConfigRebindResult{}, err
- }
- }
- for _, relative := range sortedKeys(preview.ctrlBundle) {
- if err := atomicWriteMode(filepath.Join(preview.ctx.ExportRoot(), filepath.FromSlash(relative)), preview.ctrlBundle[relative], generatedFileMode(relative)); err != nil {
- return ConfigRebindResult{}, err
- }
- }
- if err := atomicWriteMode(preview.ctx.SourceConfigPath(), preview.rawConfig, 0o644); err != nil {
- return ConfigRebindResult{}, err
- }
- if err := CheckExport(preview.ctx.ExportRoot(), preview.ctrlBundle); err != nil {
- return ConfigRebindResult{}, err
- }
- if err := configRebindCheckpoint("projections-written"); err != nil {
- return ConfigRebindResult{}, fmt.Errorf("config-rebind checkpoint projections-written: %w", err)
- }
-
- preview.binding.SchemaVersion = detachedSchemaVersion
- preview.binding.ConfigSHA256 = preview.result.NewConfigSHA256
- preview.binding.ConfigAuthority = preview.authority
- preview.binding.CreatedByVersion = Version
- bindingRaw, err := MarshalJSON(preview.binding)
- if err != nil {
- return ConfigRebindResult{}, err
- }
- // The binding is the acceptance record and is promoted last. Any interruption
- // before this write leaves the mixed projection unaccepted and fail-closed.
- if err := atomicWrite(bindingPath(preview.stateRoot, preview.ctx.RepoID), bindingRaw); err != nil {
- return ConfigRebindResult{}, err
- }
- invalidateWorkspaceCache()
- preview.result.Applied = true
- preview.result.ControllerConfigSHA256 = preview.result.NewConfigSHA256
- preview.result.NextOperation = ""
- preview.result.Reason = "Configuration authority rebound and both selected projections verify."
- return preview.result, nil
-}
diff --git a/boatstack/config_topology.go b/boatstack/config_topology.go
deleted file mode 100644
index 2732e7b..0000000
--- a/boatstack/config_topology.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
-)
-
-const (
- ConfigShapeEmbeddedOnly = "EMBEDDED_ONLY"
- ConfigShapeDetachedOnly = "DETACHED_ONLY"
- ConfigShapeHybrid = "HYBRID"
-)
-
-// ConfigurationTopology is the read-only authority map for configuration.
-// The repository package and detached controller are separate projections;
-// neither source is selected merely because its path happened to be resolved
-// first.
-type ConfigurationTopology struct {
- SchemaVersion int `json:"schema_version"`
- Mode string `json:"mode"`
- Shape string `json:"shape"`
- Authority string `json:"authority"`
- Relation string `json:"relation"`
- RepoRoot string `json:"repo_root"`
- RepoID string `json:"repo_id,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- BindingPath string `json:"binding_path,omitempty"`
- RepositorySourcePath string `json:"repository_source_path,omitempty"`
- RepositoryBundleRoot string `json:"repository_bundle_root,omitempty"`
- ControllerSourcePath string `json:"controller_source_path,omitempty"`
- ControllerBundleRoot string `json:"controller_bundle_root,omitempty"`
- RepositoryConfigSHA256 string `json:"repository_config_sha256,omitempty"`
- ControllerConfigSHA256 string `json:"controller_config_sha256,omitempty"`
- BindingConfigSHA256 string `json:"binding_config_sha256,omitempty"`
- RepositoryPackagePresent bool `json:"repository_package_present"`
- AffectedWorktrees []string `json:"affected_worktrees,omitempty"`
- NextOperation string `json:"next_operation,omitempty"`
-}
-
-func repositorySourceConfigPath(repo string) string {
- return filepath.Join(repo, sourceConfigName)
-}
-
-func repositoryPackagePresent(repo string) bool {
- return fileExists(filepath.Join(repo, productLoopDirName, "generated.lock.json"))
-}
-
-func fileSHAIfRegular(path string) (string, error) {
- info, err := os.Lstat(path)
- if os.IsNotExist(err) {
- return "", nil
- }
- if err != nil {
- return "", err
- }
- if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return "", fmt.Errorf("configuration source must be a regular non-symlink file: %s", path)
- }
- return SHA256File(path)
-}
-
-func detachedAliases(stateRoot, repoID string) ([]string, error) {
- registry, err := loadRegistry(stateRoot)
- if err != nil {
- return nil, err
- }
- aliases := []string{}
- for path, registeredID := range registry.Repositories {
- if registeredID == repoID {
- aliases = append(aliases, path)
- }
- }
- sort.Strings(aliases)
- return aliases, nil
-}
-
-func ResolveConfigurationTopology(repoPath string) (ConfigurationTopology, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return ConfigurationTopology{}, err
- }
- repositorySource := repositorySourceConfigPath(repo)
- repositorySHA, err := fileSHAIfRegular(repositorySource)
- if err != nil {
- return ConfigurationTopology{}, err
- }
- packagePresent := repositoryPackagePresent(repo)
- ctx, attached, detachedErr := detachedContextFor(repo)
- if detachedErr != nil {
- return ConfigurationTopology{}, detachedErr
- }
- if !attached {
- relation := ConfigRelationRepositoryAbsent
- if repositorySHA != "" {
- relation = ConfigRelationMatch
- }
- return ConfigurationTopology{
- SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), Shape: ConfigShapeEmbeddedOnly,
- Authority: ConfigAuthorityRepository, Relation: relation, RepoRoot: repo,
- RepositorySourcePath: repositorySource, RepositoryBundleRoot: repo,
- RepositoryConfigSHA256: repositorySHA, RepositoryPackagePresent: packagePresent,
- }, nil
- }
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return ConfigurationTopology{}, err
- }
- binding, err := loadBinding(stateRoot, ctx.RepoID)
- if err != nil {
- return ConfigurationTopology{}, err
- }
- authority := normalizedConfigAuthority(binding)
- relation := ConfigRelationMatch
- switch {
- case repositorySHA == "":
- relation = ConfigRelationRepositoryAbsent
- case authority == ConfigAuthorityExternalSnapshot || authority == ConfigAuthoritySynthesized:
- relation = ConfigRelationIndependent
- case repositorySHA != binding.ConfigSHA256:
- relation = ConfigRelationDiverged
- }
- aliases, err := detachedAliases(stateRoot, ctx.RepoID)
- if err != nil {
- return ConfigurationTopology{}, err
- }
- shape := ConfigShapeDetachedOnly
- if packagePresent {
- shape = ConfigShapeHybrid
- }
- next := ""
- if relation == ConfigRelationDiverged && (authority == ConfigAuthorityRepository || authority == ConfigAuthorityLegacyUnknown) {
- next = PrescribedCommand{Program: ctx.HelperPath(), Verb: "config-rebind", Args: []string{"--repo", repo, "--source", "repository", "--json"}}.CommandLine()
- }
- return ConfigurationTopology{
- SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionDetached), Shape: shape,
- Authority: authority, Relation: relation, RepoRoot: repo, RepoID: ctx.RepoID,
- WorktreeID: ctx.WorktreeID, BindingPath: bindingPath(stateRoot, ctx.RepoID),
- RepositorySourcePath: repositorySource, RepositoryBundleRoot: repo,
- ControllerSourcePath: ctx.SourceConfigPath(), ControllerBundleRoot: ctx.ExportRoot(),
- RepositoryConfigSHA256: repositorySHA, ControllerConfigSHA256: binding.ConfigSHA256,
- BindingConfigSHA256: binding.ConfigSHA256, RepositoryPackagePresent: packagePresent,
- AffectedWorktrees: aliases, NextOperation: next,
- }, nil
-}
-
-// RequireManagedConfiguration is called only after explicit Boatstack
-// invocation. Ambient safety uses the verified controller directly and never
-// turns repository/source divergence into ordinary-work interference.
-func RequireManagedConfiguration(repo string) (ConfigurationTopology, error) {
- topology, err := ResolveConfigurationTopology(repo)
- if err != nil {
- return ConfigurationTopology{}, err
- }
- if topology.Relation == ConfigRelationDiverged &&
- (topology.Authority == ConfigAuthorityRepository || topology.Authority == ConfigAuthorityLegacyUnknown) {
- return topology, fmt.Errorf("CONFIG_REBIND_REQUIRED: repository configuration %s differs from detached controller %s; preview the explicit repair with %s", topology.RepositoryConfigSHA256, topology.ControllerConfigSHA256, strings.TrimSpace(topology.NextOperation))
- }
- return topology, nil
-}
-
-// ValidateConfigurationExport prevents the generic exporter from becoming an
-// untracked cross-authority writer. Read-only dry runs remain available.
-func ValidateConfigurationExport(repoPath, configPath string, write bool) error {
- if !write {
- return nil
- }
- // Distribution export into a fresh staging directory has no repository
- // authority to cross. The guard applies only once the destination resolves to
- // a live Git repository.
- if _, err := ResolveRepository(repoPath); err != nil {
- return nil
- }
- topology, err := RequireManagedConfiguration(repoPath)
- if err != nil {
- return err
- }
- absoluteConfig, err := filepath.Abs(configPath)
- if err != nil {
- return err
- }
- absoluteConfig = filepath.Clean(absoluteConfig)
- if topology.Mode == string(SupervisionEmbedded) {
- if absoluteConfig != filepath.Clean(topology.RepositorySourcePath) {
- return fmt.Errorf("export --write requires the repository configuration source %s", topology.RepositorySourcePath)
- }
- return nil
- }
- if topology.Authority != ConfigAuthorityRepository || absoluteConfig != filepath.Clean(topology.RepositorySourcePath) {
- return fmt.Errorf("CONFIG_REBIND_REQUIRED: export --write cannot cross detached configuration authority; use config-rebind or write the declared controller projection")
- }
- return nil
-}
diff --git a/boatstack/config_write.go b/boatstack/config_write.go
deleted file mode 100644
index ccb5ebb..0000000
--- a/boatstack/config_write.go
+++ /dev/null
@@ -1,310 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "time"
-)
-
-// configMutationCheckpoint is a failure-injection seam for proving that every
-// partial projection is restored before a configuration mutation returns.
-var configMutationCheckpoint = func(string) error { return nil }
-
-type configMutationResult struct {
- Changed bool
- Source string // source-and-export | generated-only
-}
-
-type configMutationProjection struct {
- name string
- root string
- files map[string][]byte
-}
-
-type configSourceWrite struct {
- name string
- path string
- value []byte
-}
-
-// withConfigurationMutationLock serializes configuration changes across every
-// worktree in one clone. Detached aliases share the same Git common directory,
-// so two helpers cannot read the same base configuration and lose one update.
-func withConfigurationMutationLock(repo string, apply func() error) error {
- common, err := gitCommonDir(repo)
- if err != nil {
- return err
- }
- lock := filepath.Join(common, "boatstack-configuration-mutation.lock")
- if err := rejectSymlinkComponents(common, lock); err != nil {
- return err
- }
- for attempt := 0; attempt < 100; attempt++ {
- file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if openErr == nil {
- _, _ = fmt.Fprintf(file, "%d %s\n", os.Getpid(), operationTimestamp())
- _ = file.Close()
- defer os.Remove(lock)
- return apply()
- }
- if !isLockContention(openErr, lock) {
- return openErr
- }
- if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute {
- _ = os.Remove(lock)
- continue
- }
- time.Sleep(10 * time.Millisecond)
- }
- return fmt.Errorf("configuration mutation is busy")
-}
-
-func projectionTransactionPaths(projection configMutationProjection) []string {
- paths := make([]string, 0, len(projection.files))
- seen := map[string]bool{}
- for relative := range projection.files {
- path := filepath.Join(projection.root, filepath.FromSlash(relative))
- paths = append(paths, path)
- seen[relative] = true
- }
- // WriteExport may remove generated paths that disappeared from the new
- // bundle. Capture those pre-images too so rollback remains complete.
- for relative := range previousFiles(projection.root) {
- if seen[relative] {
- continue
- }
- paths = append(paths, filepath.Join(projection.root, filepath.FromSlash(relative)))
- }
- return paths
-}
-
-func verifyConfigurationSource(write configSourceWrite) error {
- current, err := os.ReadFile(write.path)
- if err != nil {
- return err
- }
- if string(current) != string(write.value) {
- return fmt.Errorf("%s configuration source did not match the accepted bytes", write.name)
- }
- return nil
-}
-
-// Boundary: ordinary Boatstack project-configuration mutation.
-// Control law: a successful mutation preserves declared authority and leaves
-// every bound source and generated projection verified.
-// Authorized actor: command handlers admitted through this function.
-// Required evidence: current topology, valid candidate bytes, collision-free
-// projections, and successful post-write verification.
-// Failure behavior: reject before writing or restore exact pre-images.
-// Release condition: every selected projection verifies; detached acceptance is
-// recorded by promoting the binding last.
-func mutateManagedConfiguration(repoPath string, mutate func(*ProjectConfig) (bool, error)) (result configMutationResult, returnErr error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return configMutationResult{}, err
- }
- returnErr = withConfigurationMutationLock(repo, func() (transactionErr error) {
- topology, err := RequireManagedConfiguration(repo)
- if err != nil {
- return err
- }
- if topology.Mode == string(SupervisionDetached) && topology.Authority == ConfigAuthorityLegacyUnknown {
- return fmt.Errorf("CONFIG_REBIND_REQUIRED: configuration mutation needs an explicit detached configuration authority")
- }
-
- sourcePath := topology.RepositorySourcePath
- generatedOnly := false
- if topology.Mode == string(SupervisionDetached) &&
- (topology.Authority == ConfigAuthorityExternalSnapshot || topology.Authority == ConfigAuthoritySynthesized) {
- sourcePath = topology.ControllerSourcePath
- }
- if topology.Mode == string(SupervisionEmbedded) && !fileExists(sourcePath) {
- sourcePath = WorkspaceFor(repo).ProjectConfigPath()
- generatedOnly = true
- }
- if !fileExists(sourcePath) {
- return fmt.Errorf("CONFIG_REBIND_REQUIRED: declared configuration source is missing: %s", sourcePath)
- }
-
- config, _, err := LoadConfig(sourcePath)
- if err != nil {
- return err
- }
- changed, err := mutate(&config)
- if err != nil {
- return err
- }
- result.Source = "source-and-export"
- if generatedOnly {
- result.Source = "generated-only"
- }
- if !changed {
- return nil
- }
- if err := ValidateConfig(config); err != nil {
- return err
- }
- rawConfig, err := MarshalJSON(config)
- if err != nil {
- return err
- }
-
- if generatedOnly {
- project, err := GeneratedJSON(config)
- if err != nil {
- return err
- }
- saved, err := snapshotFiles([]string{sourcePath})
- if err != nil {
- return err
- }
- defer func() {
- if transactionErr != nil {
- if rollbackErr := restoreFiles(saved); rollbackErr != nil {
- transactionErr = fmt.Errorf("%v; configuration rollback failed: %w", transactionErr, rollbackErr)
- }
- }
- }()
- if err := atomicWriteMode(sourcePath, project, 0o644); err != nil {
- return err
- }
- if err := configMutationCheckpoint("generated-only-written"); err != nil {
- return err
- }
- loaded, _, err := LoadConfig(sourcePath)
- if err != nil || !equalProjectConfig(loaded, config) {
- return fmt.Errorf("generated-only configuration postcondition failed: %v", err)
- }
- result.Changed = true
- return nil
- }
-
- projections := []configMutationProjection{}
- sources := []configSourceWrite{}
- if topology.Mode == string(SupervisionEmbedded) || topology.Authority == ConfigAuthorityRepository {
- if topology.Mode == string(SupervisionEmbedded) || topology.RepositoryPackagePresent {
- bundle, buildErr := BuildExportBundle(topology.RepositorySourcePath, config, embeddedConfigBytes(rawConfig), "boatstack")
- if buildErr != nil {
- return buildErr
- }
- projections = append(projections, configMutationProjection{name: "repository", root: topology.RepositoryBundleRoot, files: bundle.Files})
- }
- sources = append(sources, configSourceWrite{name: "repository", path: topology.RepositorySourcePath, value: rawConfig})
- }
- if topology.Mode == string(SupervisionDetached) {
- controller, buildErr := BuildExportBundle(topology.ControllerSourcePath, config, rawConfig, "boatstack")
- if buildErr != nil {
- return buildErr
- }
- projections = append(projections, configMutationProjection{name: "controller", root: topology.ControllerBundleRoot, files: controller.Files})
- sources = append(sources, configSourceWrite{name: "controller", path: topology.ControllerSourcePath, value: rawConfig})
- }
-
- var binding DetachedBinding
- bindingTarget := ""
- if topology.Mode == string(SupervisionDetached) {
- stateRoot, stateErr := detachedStateRoot()
- if stateErr != nil {
- return stateErr
- }
- bindingTarget = bindingPath(stateRoot, topology.RepoID)
- binding, err = loadBinding(stateRoot, topology.RepoID)
- if err != nil {
- return err
- }
- }
-
- paths := []string{}
- for _, projection := range projections {
- if problems := ExportCollisions(projection.root, projection.files); len(problems) > 0 {
- return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", "))
- }
- paths = append(paths, projectionTransactionPaths(projection)...)
- }
- for _, source := range sources {
- paths = append(paths, source.path)
- }
- if bindingTarget != "" {
- paths = append(paths, bindingTarget)
- }
- saved, err := snapshotFiles(paths)
- if err != nil {
- return err
- }
- defer func() {
- if transactionErr != nil {
- if rollbackErr := restoreFiles(saved); rollbackErr != nil {
- transactionErr = fmt.Errorf("%v; configuration rollback failed: %w", transactionErr, rollbackErr)
- }
- invalidateWorkspaceCache()
- }
- }()
-
- for _, projection := range projections {
- if err := WriteExport(projection.root, projection.files); err != nil {
- return err
- }
- if err := configMutationCheckpoint(projection.name + "-projection-written"); err != nil {
- return err
- }
- }
- for _, source := range sources {
- if err := atomicWriteMode(source.path, source.value, 0o644); err != nil {
- return err
- }
- if err := configMutationCheckpoint(source.name + "-source-written"); err != nil {
- return err
- }
- }
-
- if bindingTarget != "" {
- binding.SchemaVersion = detachedSchemaVersion
- binding.ConfigSHA256 = SHA256Bytes(rawConfig)
- binding.ConfigAuthority = topology.Authority
- binding.CreatedByVersion = Version
- bindingRaw, err := MarshalJSON(binding)
- if err != nil {
- return err
- }
- // The binding is the detached acceptance record and is always last.
- if err := atomicWrite(bindingTarget, bindingRaw); err != nil {
- return err
- }
- invalidateWorkspaceCache()
- if err := configMutationCheckpoint("binding-written"); err != nil {
- return err
- }
- }
-
- for _, projection := range projections {
- if err := CheckExport(projection.root, projection.files); err != nil {
- return err
- }
- }
- for _, source := range sources {
- if err := verifyConfigurationSource(source); err != nil {
- return err
- }
- }
- if topology.Mode == string(SupervisionDetached) {
- status, err := DetachedStatus(repo)
- if err != nil {
- return err
- }
- if !status.Verified || status.ConfigRelation == ConfigRelationDiverged {
- return fmt.Errorf("detached configuration postcondition failed: %s", status.Reason)
- }
- }
- result.Changed = true
- return nil
- })
- return result, returnErr
-}
-
-func equalProjectConfig(left, right ProjectConfig) bool {
- leftBytes, leftErr := MarshalJSON(left)
- rightBytes, rightErr := MarshalJSON(right)
- return leftErr == nil && rightErr == nil && string(leftBytes) == string(rightBytes)
-}
diff --git a/boatstack/content_effect_conformance_test.go b/boatstack/content_effect_conformance_test.go
deleted file mode 100644
index 0ce2b8c..0000000
--- a/boatstack/content_effect_conformance_test.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package boatstack
-
-import (
- "path/filepath"
- "strings"
- "testing"
-)
-
-// control-law: written-content-is-data-not-effect
-//
-// A file-writer tool's document body is inert until something executes it, so
-// the guard grades a Write/Edit by its NAME and STRUCTURAL fields (paths,
-// destinations) — never by prose inside the body. Previously the text
-// classifiers ran over the full marshaled input, so a runbook that mentioned
-// `terraform destroy` or docs that named `.git/boatstack/` were denied as if
-// the words were the act. The floor is unchanged: the same strings as a Bash
-// command still block (the text IS the command), a live SQL executor's
-// arguments still block (they are executed), and a protected path in any
-// structural field still blocks (redaction never launders the target).
-
-// contentDocCases name every non-sqlEffect destruction rule once, as prose.
-var contentDocCases = []struct {
- name string
- content string
-}{
- {"database-reset", "Ask the operator before `supabase db reset` on staging."},
- {"filesystem-destruction", "Cleanup appendix: `rm -rf ~` is the classic footgun; never script it."},
- {"git-history-destruction", "Policy: `git push --force origin main` is forbidden; use revert PRs."},
- {"infrastructure-destruction", "Teardown runbook: `terraform destroy -auto-approve` after sign-off only."},
- {"recovery-destruction", "Quarterly drill: delete backup snapshots older than the retention window."},
- {"guard-state-doc", "Delivery state lives under `.git/boatstack/deliveries/` — helper-owned."},
-}
-
-// Positive: documents whose bodies name every destruction rule (and the
-// managed state tree) pass as file-writer tool calls.
-func TestWrittenContentIsNotClassifiedAsEffect(t *testing.T) {
- repo := safetyTestRepo(t)
- for _, c := range contentDocCases {
- c := c
- t.Run("write/"+c.name, func(t *testing.T) {
- findings := ClassifyTool(repo, "Write", map[string]any{
- "file_path": filepath.Join(repo, "docs", c.name+".md"),
- "content": c.content,
- })
- if len(findings) > 0 {
- t.Fatalf("document content classified as effect: %#v", findings)
- }
- })
- t.Run("edit/"+c.name, func(t *testing.T) {
- findings := ClassifyTool(repo, "Edit", map[string]any{
- "file_path": filepath.Join(repo, "docs", c.name+".md"),
- "old_string": "TODO",
- "new_string": c.content,
- })
- if len(findings) > 0 {
- t.Fatalf("edit content classified as effect: %#v", findings)
- }
- })
- }
-}
-
-// Negative: the executor contexts keep the boundary — the same text blocks
-// when it IS the command, and executed SQL arguments stay live.
-func TestExecutorContextsStillBlockAfterRedaction(t *testing.T) {
- repo := safetyTestRepo(t)
- for _, command := range []string{
- `terraform destroy -auto-approve`,
- `git push --force origin main`,
- `supabase db reset`,
- } {
- if findings := ClassifyCommand(repo, command); len(findings) == 0 {
- t.Fatalf("live command must still block: %q", command)
- }
- }
- if findings := ClassifyTool(repo, "mcp__db__execute_sql", map[string]any{"query": "DROP TABLE users"}); len(findings) == 0 {
- t.Fatal("SQL executor arguments are executed, not stored — must still block")
- }
-}
-
-// Relation: one table drives both outcomes for the identical hook-shaped tool
-// call — content alone allows, a protected structural path denies.
-func TestContentAllowsWhilePathDenies(t *testing.T) {
- repo := safetyTestRepo(t)
- content := "Ops note: state is under .git/boatstack/deliveries/ and terraform destroy is operator-only."
-
- if findings := ClassifyTool(repo, "Write", map[string]any{
- "file_path": filepath.Join(repo, "docs", "ops.md"),
- "content": content,
- }); len(findings) > 0 {
- t.Fatalf("content-only mention must pass: %#v", findings)
- }
-
- findings := ClassifyTool(repo, "Write", map[string]any{
- "file_path": ".git/boatstack/deliveries/checkout/state.json",
- "content": content,
- })
- if len(findings) == 0 {
- t.Fatal("write INTO managed state must deny regardless of content")
- }
- if findings[0].Category != "workflow-state-tamper" {
- t.Fatalf("wrong category for state tamper: %#v", findings)
- }
-}
-
-// Bypass: redaction drops only content fields — a protected path smuggled in
-// any structural field (file_path, destination, nested) still blocks, and a
-// non-writer tool keeps full-input grading.
-func TestRedactionCannotLaunderProtectedTargets(t *testing.T) {
- repo := safetyTestRepo(t)
-
- for name, input := range map[string]map[string]any{
- "file_path": {"file_path": ".git/boatstack/flow/trajectory.jsonl", "content": "x"},
- "destination": {"file_path": "notes.md", "destination": ".git/boatstack/deliveries/x", "content": "x"},
- "nested": {"file_path": "notes.md", "meta": map[string]any{"target_path": ".git/boatstack/runtimes/v1"}, "content": "x"},
- } {
- if findings := ClassifyTool(repo, "Write", input); len(findings) == 0 {
- t.Fatalf("structural field %s must survive redaction and deny: %#v", name, input)
- }
- }
-
- // A tool with no extracted path is not a file-writer: full-input grading holds.
- if findings := ClassifyTool(repo, "mcp__infra__delete_resource", map[string]any{
- "kind": "database", "note": "drop the staging cluster",
- }); len(findings) == 0 {
- t.Fatal("non-writer destructive tool must keep full-input grading")
- }
-}
-
-// Failure-state: redaction is pure — the caller's input map is never mutated,
-// and classification performs no I/O on the named document.
-func TestRedactionIsPureAndReadOnly(t *testing.T) {
- repo := safetyTestRepo(t)
- input := map[string]any{
- "file_path": filepath.Join(repo, "docs", "ops.md"),
- "content": "terraform destroy notes",
- "meta": map[string]any{"body": "git reset --hard"},
- }
- _ = ClassifyTool(repo, "Write", input)
-
- if input["content"] != "terraform destroy notes" {
- t.Fatalf("caller input mutated: %#v", input)
- }
- if meta := input["meta"].(map[string]any); meta["body"] != "git reset --hard" {
- t.Fatalf("nested caller input mutated: %#v", meta)
- }
- if strings.Contains(strings.ToLower("docs/ops.md"), "boatstack") {
- t.Fatal("fixture invariant")
- }
-}
diff --git a/boatstack/context.go b/boatstack/context.go
deleted file mode 100644
index 5402e1c..0000000
--- a/boatstack/context.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package boatstack
-
-// Context projection for Detached Supervision. In embedded mode the coding agent
-// reads Boatstack's generated references and project.json from the repository; in
-// detached mode those files are external, so the agent needs a bounded, read-only
-// projection of the current supervisory position for the operation it is running.
-// This reuses the authoritative resolver (ResolveNext) and the deterministic
-// next-move oracle (NextControl) rather than reconstructing the state machine.
-
-// OperatorContext is the bounded, read-only view a host needs to run one operation
-// under Boatstack supervision, in either ownership mode.
-type OperatorContext struct {
- SchemaVersion int `json:"schema_version"`
- Mode string `json:"mode"`
- Attached bool `json:"attached"`
- RepoRoot string `json:"repo_root"`
- ControlRoot string `json:"control_root,omitempty"`
- Operation string `json:"operation,omitempty"`
- Host string `json:"host,omitempty"`
- Feature string `json:"feature,omitempty"`
- VerificationStatus string `json:"verification_status"`
- ObservedStage string `json:"observed_stage,omitempty"`
- ActiveSlice string `json:"active_slice,omitempty"`
- NextOperation string `json:"next_operation,omitempty"`
- RecommendedCommand string `json:"recommended_command,omitempty"`
- RemainingFlowCost int `json:"remaining_flow_cost,omitempty"`
- Reason string `json:"reason"`
-}
-
-// ProjectOperatorContext returns the bounded supervisory context for a repository.
-// It is read-only. For an attached-but-unverifiable detached repository it reports
-// BLOCKED with a bounded recovery action rather than a normal position.
-func ProjectOperatorContext(repoPath, operation, host string) (OperatorContext, error) {
- root, err := ResolveRepository(repoPath)
- if err != nil {
- return OperatorContext{}, err
- }
- out := OperatorContext{
- SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded),
- RepoRoot: root, ControlRoot: root, Operation: operation, Host: host,
- }
-
- if ctx, ok, verifyErr := detachedContextFor(root); verifyErr != nil {
- out.Mode = string(SupervisionDetached)
- out.Attached = true
- out.VerificationStatus = "BLOCKED"
- out.Reason = verifyErr.Error() + " Run `boatstack-helper detached-status --repo .` and reattach."
- return out, nil
- } else if ok {
- out.Mode = string(SupervisionDetached)
- out.Attached = true
- out.ControlRoot = ctx.controlRoot
- }
-
- next, err := NextControl(root, "")
- if err != nil {
- return out, nil
- }
- status, statusErr := ResolveNext(root, "")
- if statusErr == nil {
- out.Feature = status.Feature
- out.VerificationStatus = status.VerificationStatus
- out.ObservedStage = status.ObservedStage
- out.ActiveSlice = status.ActiveSlice
- }
- out.NextOperation = next.RecommendedOp
- out.RemainingFlowCost = next.RemainingCost
- if out.Reason == "" {
- out.Reason = next.Reason
- }
- if next.Prescribed != nil {
- out.RecommendedCommand = next.Prescribed.CommandLine()
- }
- return out, nil
-}
diff --git a/boatstack/decision.go b/boatstack/decision.go
deleted file mode 100644
index 669f4e2..0000000
--- a/boatstack/decision.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package boatstack
-
-type DecisionOperator string
-
-const (
- OperatorInfer DecisionOperator = "infer"
- OperatorQuery DecisionOperator = "query"
- OperatorVerify DecisionOperator = "verify"
- OperatorReject DecisionOperator = "reject"
- OperatorEscalate DecisionOperator = "escalate"
- OperatorPolicy DecisionOperator = "policy"
-)
-
-type EvidenceLevel string
-
-const (
- EvidenceVerified EvidenceLevel = "verified"
- EvidenceSupported EvidenceLevel = "supported"
- EvidenceAbsent EvidenceLevel = "absent"
- EvidenceConflicting EvidenceLevel = "conflicting"
-)
-
-type PremiseStatus string
-
-const (
- PremiseUnknown PremiseStatus = "unknown"
- PremiseValid PremiseStatus = "valid"
- PremiseInvalid PremiseStatus = "invalid"
-)
-
-type PlanDecisionInput struct {
- DecisionKind string
- IsMaterial bool
- RepositoryEvidence []EvidenceRecord
- EvidenceLevel EvidenceLevel
- PremiseStatus PremiseStatus
- AutonomyEligible bool
-}
-
-type DecisionResolution struct {
- Operator DecisionOperator
- RuleID string
- Reason string
- Evidence []EvidenceRecord
- EvidenceLevel EvidenceLevel
- PremiseStatus PremiseStatus
-}
-
-func ResolvePlanDecision(input PlanDecisionInput) DecisionResolution {
- resolution := DecisionResolution{
- Evidence: input.RepositoryEvidence,
- EvidenceLevel: input.EvidenceLevel,
- PremiseStatus: input.PremiseStatus,
- }
-
- if input.PremiseStatus == PremiseInvalid {
- resolution.Operator = OperatorReject
- resolution.RuleID = "invalid-premise-rejected"
- resolution.Reason = "planning premise is not supported"
- return resolution
- }
-
- if input.EvidenceLevel == EvidenceConflicting {
- resolution.Operator = OperatorEscalate
- resolution.RuleID = "conflicting-evidence-escalated"
- resolution.Reason = "repository evidence conflicts"
- return resolution
- }
-
- if input.EvidenceLevel == EvidenceVerified {
- // Assuming verified evidence is sufficient to resolve the decision
- resolution.Operator = OperatorInfer
- resolution.RuleID = "verified-evidence-inferred"
- resolution.Reason = "verified repository evidence resolves the decision"
- return resolution
- }
-
- if input.EvidenceLevel == EvidenceSupported {
- resolution.Operator = OperatorVerify
- resolution.RuleID = "supported-evidence-requires-verification"
- resolution.Reason = "evidence is supported but requires independent verification"
- return resolution
- }
-
- if !input.IsMaterial && input.AutonomyEligible && input.EvidenceLevel == EvidenceAbsent && input.PremiseStatus == PremiseValid {
- resolution.Operator = OperatorPolicy
- resolution.RuleID = "eligible-nonmaterial-policy-resolution"
- resolution.Reason = "declared autonomy evidence permits a bounded policy decision"
- return resolution
- }
-
- if input.IsMaterial && input.EvidenceLevel == EvidenceAbsent {
- resolution.Operator = OperatorQuery
- resolution.RuleID = "material-intent-requires-human"
- resolution.Reason = "material product intent requires human input"
- return resolution
- }
-
- resolution.Operator = OperatorEscalate
- resolution.RuleID = "unresolved-uncertainty-escalated"
- resolution.Reason = "unresolved uncertainty requires escalation"
- return resolution
-}
diff --git a/boatstack/decision_test.go b/boatstack/decision_test.go
deleted file mode 100644
index a6e4a7c..0000000
--- a/boatstack/decision_test.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package boatstack
-
-import "testing"
-
-func TestResolvePlanDecision(t *testing.T) {
- tests := []struct {
- name string
- input PlanDecisionInput
- expected DecisionOperator
- }{
- {
- name: "invalid premise rejects",
- input: PlanDecisionInput{
- PremiseStatus: PremiseInvalid,
- },
- expected: OperatorReject,
- },
- {
- name: "conflicting evidence escalates",
- input: PlanDecisionInput{
- PremiseStatus: PremiseValid,
- EvidenceLevel: EvidenceConflicting,
- },
- expected: OperatorEscalate,
- },
- {
- name: "verified evidence infers",
- input: PlanDecisionInput{
- PremiseStatus: PremiseValid,
- EvidenceLevel: EvidenceVerified,
- },
- expected: OperatorInfer,
- },
- {
- name: "supported evidence verifies",
- input: PlanDecisionInput{
- PremiseStatus: PremiseValid,
- EvidenceLevel: EvidenceSupported,
- },
- expected: OperatorVerify,
- },
- {
- name: "absent evidence for material intent queries",
- input: PlanDecisionInput{
- PremiseStatus: PremiseValid,
- IsMaterial: true,
- EvidenceLevel: EvidenceAbsent,
- },
- expected: OperatorQuery,
- },
- {
- name: "unknown state escalates",
- input: PlanDecisionInput{
- PremiseStatus: PremiseUnknown,
- EvidenceLevel: EvidenceAbsent,
- IsMaterial: false,
- },
- expected: OperatorEscalate,
- },
- {
- name: "eligible nonmaterial decision resolves by policy",
- input: PlanDecisionInput{
- PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent,
- IsMaterial: false, AutonomyEligible: true,
- },
- expected: OperatorPolicy,
- },
- {
- name: "material decision cannot resolve by policy",
- input: PlanDecisionInput{
- PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent,
- IsMaterial: true, AutonomyEligible: true,
- },
- expected: OperatorQuery,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- resolution := ResolvePlanDecision(tt.input)
- if resolution.Operator != tt.expected {
- t.Errorf("expected operator %s, got %s", tt.expected, resolution.Operator)
- }
- })
- }
-}
diff --git a/boatstack/delivery.go b/boatstack/delivery.go
deleted file mode 100644
index 8af497e..0000000
--- a/boatstack/delivery.go
+++ /dev/null
@@ -1,1594 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "time"
-)
-
-const deliveryStateSchemaVersion = 2
-
-// Slice-status lifecycle literals — the canonical string values stored in
-// DeliverySlice.Status. These are the single source for the slice-status
-// vocabulary; DeliverySliceStatuses returns the full set so the deliverycontrol
-// registry parity test can pin its mirror against them instead of a
-// hand-maintained list (control-law: registry-covers-real-delivery-machine).
-// ResumeStage and WorkflowStage are separate vocabularies, not slice statuses.
-const (
- StatusPending = "PENDING"
- StatusBuild = "BUILD"
- StatusTestPassed = "TEST_PASSED"
- StatusReviewPassed = "REVIEW_PASSED"
- StatusPublished = "PUBLISHED"
-)
-
-// DeliverySliceStatuses returns the canonical slice-status lifecycle set, in
-// order. It is the single source the registry conformance test mirrors, so a new
-// slice status cannot be introduced without appearing here.
-func DeliverySliceStatuses() []string {
- return []string{StatusPending, StatusBuild, StatusTestPassed, StatusReviewPassed, StatusPublished}
-}
-
-type DeliverySlice struct {
- ID string `json:"id"`
- Title string `json:"title"`
- TaskIDs []string `json:"task_ids"`
- AcceptanceCriteria []string `json:"acceptance_criteria"`
- AffectedPaths []string `json:"affected_paths,omitempty"`
- Status string `json:"status"`
- BaseBranch string `json:"base_branch,omitempty"`
- HeadBranch string `json:"head_branch,omitempty"`
- PRURL string `json:"pr_url,omitempty"`
- // PRState caches the last observed lifecycle of this slice's pull request
- // ("OPEN", "MERGED", or "CLOSED"). It is set to OPEN when the slice is first
- // published and advanced to a terminal value only when an external
- // observation (gh) confirms it. A published slice remains re-gateable and
- // updatable in place while non-terminal; once terminal, in-place correction
- // is refused and a corrective child delivery is the bounded forward actuator.
- PRState string `json:"pr_state,omitempty"`
- // PostPublishFixAttempts counts the post-publish correction cycles
- // recorded against this published slice, and GoalEscape caches a fired
- // merged-goal demotion (sticky offline until the next recorded correction
- // clears it). Both are written only under delivery.terminal "merged"; a
- // default-terminal state file never carries them.
- // control-law: goal-escape-demotes-to-operator-and-stops
- PostPublishFixAttempts int `json:"post_publish_fix_attempts,omitempty"`
- GoalEscape string `json:"goal_escape,omitempty"`
-}
-
-type DeliveryState struct {
- SchemaVersion int `json:"schema_version"`
- Feature string `json:"feature"`
- PlanLockHash string `json:"plan_lock_sha256"`
- PreviousPlanLocks []string `json:"previous_plan_lock_sha256,omitempty"`
- ActiveIndex int `json:"active_index"`
- Slices []DeliverySlice `json:"slices"`
- Mode string `json:"mode,omitempty"`
- ResumeStage string `json:"resume_stage,omitempty"`
- ActiveObservationID string `json:"active_observation_id,omitempty"`
- RepairCounters map[string]int `json:"repair_counters"`
- RepairAttempt int `json:"-"`
- SupersededReceipts []string `json:"superseded_receipts,omitempty"`
- ParentDelivery string `json:"parent_delivery,omitempty"`
- // Goal snapshots the non-default delivery terminal ("merged") this
- // delivery was activated under, so a mid-flight config change never
- // silently changes an in-progress delivery's goal. Empty means: resolve
- // from config at read time (and keeps a default-config state file
- // byte-identical to the pre-field format).
- // control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
- Goal string `json:"goal,omitempty"`
-}
-
-type DeliveryGateReceipt struct {
- SchemaVersion int `json:"schema_version"`
- Feature string `json:"feature"`
- SliceID string `json:"slice_id"`
- Gate string `json:"gate"`
- Status string `json:"status"`
- BaseBranch string `json:"base_branch"`
- HeadBranch string `json:"head_branch"`
- HeadCommit string `json:"head_commit"`
- DiffSHA256 string `json:"diff_sha256"`
- EvidencePath string `json:"evidence_path"`
- EvidenceHash string `json:"evidence_sha256"`
- RecordedAt string `json:"recorded_at"`
- Attempt int `json:"attempt,omitempty"`
- TriggerObservationID string `json:"trigger_observation_id,omitempty"`
- Supersedes string `json:"supersedes,omitempty"`
- ReviewerIdentity string `json:"reviewer_identity,omitempty"`
- ReviewMethod string `json:"review_method,omitempty"`
-}
-
-type DeliveryGateOptions struct {
- Repo string
- Feature string
- SliceID string
- Gate string
- Status string
- BaseBranch string
- EvidencePath string
- ReviewerIdentity string
- ReviewMethod string
-}
-
-func validateDeliveryGatePolicy(config ProjectConfig, gate, status string, changed []string, reviewerIdentity, reviewMethod string) error {
- if status == "PASS_WITH_GAPS" && !config.Workflow.AllowPassWithGaps {
- return fmt.Errorf("workflow.allow_pass_with_gaps is false; record PASS only after resolving gaps")
- }
- if gate != "review" {
- return nil
- }
- identity := strings.TrimSpace(reviewerIdentity)
- method := strings.ToLower(strings.TrimSpace(reviewMethod))
- if identity != "" || method != "" {
- if identity == "" {
- return fmt.Errorf("reviewer_identity is required when review_method is recorded")
- }
- if method != "human_peer" && method != "separate_agent" {
- return fmt.Errorf("review_method must be human_peer or separate_agent")
- }
- }
- if config.Workflow.IndependentReviewForHighRisk && len(highRiskChangedFiles(changed, config.Project.HighRiskPaths)) > 0 {
- if identity == "" || method == "" {
- return fmt.Errorf("high-risk review requires reviewer_identity and review_method")
- }
- }
- return nil
-}
-
-type ChangeObservationOptions struct {
- Repo string
- Feature string
- Message string
- SourceStage string
- Expected string
- Actual string
- Evidence string
- Mechanism string
- Classification string
- // SliceID optionally targets a specific addressable slice — the active slice or
- // a published-but-open earlier slice. Empty resolves to the correction's branch
- // and then the active slice, preserving the ordinary repair path.
- SliceID string
-}
-
-type ChangeObservation struct {
- ID string `json:"id"`
- Feature string `json:"feature"`
- SliceID string `json:"slice_id,omitempty"`
- SourceStage string `json:"source_stage"`
- Expected string `json:"expected,omitempty"`
- Actual string `json:"actual,omitempty"`
- Evidence string `json:"evidence,omitempty"`
- EvidenceSHA256 string `json:"evidence_sha256,omitempty"`
- Mechanism string `json:"mechanism,omitempty"`
- MechanismSHA256 string `json:"mechanism_sha256,omitempty"`
- Message string `json:"message"`
- Classification string `json:"classification"`
- ResumeStage string `json:"resume_stage,omitempty"`
- RecordedAt string `json:"recorded_at"`
- Outcome string `json:"outcome,omitempty"`
- ParentDelivery string `json:"parent_delivery,omitempty"`
- SuggestedFeatureID string `json:"suggested_feature_id,omitempty"`
-}
-
-func deliveryEvidenceGateStatus(value, gate, sliceID string, explicit bool) string {
- if !explicit {
- return evidenceGateStatus(value, gate)
- }
- pattern := regexp.MustCompile(`(?mi)^\s*-\s*` + regexp.QuoteMeta(gate) + `\s+gate\s*\(\s*` + regexp.QuoteMeta(sliceID) + `\s*\)\s*:\s*` + "`?" + `([A-Z_]+)` + "`?" + `\s*$`)
- if match := pattern.FindStringSubmatch(value); len(match) == 2 {
- return strings.ToUpper(match[1])
- }
- return ""
-}
-
-func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) {
- tasks, _ := objectSlice(plan["tasks"])
- if plan["delivery_slices"] == nil {
- taskIDs := make([]string, 0, len(tasks))
- criteria := []string{}
- seenCriteria := map[string]bool{}
- for _, task := range tasks {
- taskIDs = append(taskIDs, stringValue(task["id"]))
- mapped, _ := stringSlice(task["acceptance_criteria"])
- for _, criterion := range mapped {
- if !seenCriteria[criterion] {
- seenCriteria[criterion] = true
- criteria = append(criteria, criterion)
- }
- }
- }
- return []DeliverySlice{{ID: "delivery", Title: "Feature delivery", TaskIDs: taskIDs, AcceptanceCriteria: criteria, Status: StatusBuild}}, nil
- }
- items, ok := objectSlice(plan["delivery_slices"])
- if !ok || len(items) == 0 {
- return nil, fmt.Errorf("delivery_slices must be a non-empty list")
- }
- taskIDs := map[string]bool{}
- taskCriteria := map[string][]string{}
- taskPaths := map[string][]string{}
- for _, task := range tasks {
- id := stringValue(task["id"])
- taskIDs[id] = true
- taskCriteria[id], _ = stringSlice(task["acceptance_criteria"])
- taskPaths[id], _ = stringSlice(task["affected_paths"])
- }
- seenSlices := map[string]bool{}
- assigned := map[string]string{}
- result := make([]DeliverySlice, 0, len(items))
- for index, item := range items {
- id := strings.TrimSpace(stringValue(item["id"]))
- if !featureSlugPattern.MatchString(id) || seenSlices[id] {
- return nil, fmt.Errorf("delivery slice ids must be unique lowercase kebab-case values")
- }
- seenSlices[id] = true
- title := strings.TrimSpace(stringValue(item["title"]))
- if title == "" {
- return nil, fmt.Errorf("delivery slice %s requires a title", id)
- }
- mapped, mappedOK := stringSlice(item["task_ids"])
- if !mappedOK || len(mapped) == 0 {
- return nil, fmt.Errorf("delivery slice %s requires task_ids", id)
- }
- criteria := []string{}
- affectedPaths := []string{}
- seenCriteria := map[string]bool{}
- seenPaths := map[string]bool{}
- for _, taskID := range mapped {
- if !taskIDs[taskID] {
- return nil, fmt.Errorf("delivery slice %s maps unknown task %s", id, taskID)
- }
- if owner := assigned[taskID]; owner != "" {
- return nil, fmt.Errorf("task %s is assigned to delivery slices %s and %s", taskID, owner, id)
- }
- assigned[taskID] = id
- if len(taskPaths[taskID]) == 0 {
- return nil, fmt.Errorf("task %s in explicit delivery slice %s requires affected_paths", taskID, id)
- }
- for _, path := range taskPaths[taskID] {
- if !seenPaths[path] {
- seenPaths[path] = true
- affectedPaths = append(affectedPaths, path)
- }
- }
- for _, criterion := range taskCriteria[taskID] {
- if !seenCriteria[criterion] {
- seenCriteria[criterion] = true
- criteria = append(criteria, criterion)
- }
- }
- }
- result = append(result, DeliverySlice{
- ID: id, Title: title, TaskIDs: mapped, AcceptanceCriteria: criteria, AffectedPaths: affectedPaths,
- Status: StatusPending, BaseBranch: strings.TrimSpace(stringValue(item["base_branch"])),
- HeadBranch: strings.TrimSpace(stringValue(item["head_branch"])),
- })
- if index == 0 {
- result[index].Status = StatusBuild
- }
- }
- unassigned := []string{}
- for taskID := range taskIDs {
- if assigned[taskID] == "" {
- unassigned = append(unassigned, taskID)
- }
- }
- if len(unassigned) > 0 {
- sort.Strings(unassigned)
- return nil, fmt.Errorf("tasks missing a delivery slice: %s", strings.Join(unassigned, ", "))
- }
- sliceIndex := map[string]int{}
- for index, slice := range result {
- sliceIndex[slice.ID] = index
- }
- for _, task := range tasks {
- taskID := stringValue(task["id"])
- dependencies, _ := stringSlice(task["depends_on"])
- for _, dependency := range dependencies {
- if sliceIndex[assigned[dependency]] > sliceIndex[assigned[taskID]] {
- return nil, fmt.Errorf("task %s in delivery slice %s depends on future slice task %s", taskID, assigned[taskID], dependency)
- }
- }
- }
- return result, nil
-}
-
-func deliveryStateDirectory(repo string) (string, error) {
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return "", err
- }
- return ctx.DeliveryDir()
-}
-
-func deliveryStatePath(repo, feature string) (string, error) {
- if !featureSlugPattern.MatchString(feature) {
- return "", fmt.Errorf("delivery state requires a lowercase kebab-case feature")
- }
- directory, err := deliveryStateDirectory(repo)
- if err != nil {
- return "", err
- }
- return filepath.Join(directory, feature, "state.json"), nil
-}
-
-func deliveryReceiptPath(repo, feature, sliceID, gate string) (string, error) {
- statePath, err := deliveryStatePath(repo, feature)
- if err != nil {
- return "", err
- }
- if !featureSlugPattern.MatchString(sliceID) || (gate != "test" && gate != "review") {
- return "", fmt.Errorf("invalid delivery receipt identity")
- }
- return filepath.Join(filepath.Dir(statePath), "receipts", sliceID, gate+".json"), nil
-}
-
-func saveDeliveryState(repo string, state DeliveryState) error {
- path, err := deliveryStatePath(repo, state.Feature)
- if err != nil {
- return err
- }
- if state.RepairCounters == nil {
- state.RepairCounters = map[string]int{}
- }
- // Preserve source-level compatibility for callers that still populate the
- // v1 shadow field: conservatively apply it to every typed counter.
- allTypedZero := true
- for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} {
- if state.RepairCounters[class] != 0 {
- allTypedZero = false
- }
- }
- if state.RepairAttempt > 0 && allTypedZero {
- for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} {
- if state.RepairCounters[class] < state.RepairAttempt {
- state.RepairCounters[class] = state.RepairAttempt
- }
- }
- }
- value, err := MarshalJSON(state)
- if err != nil {
- return err
- }
- return atomicWriteMode(path, value, 0o644)
-}
-
-func LoadDeliveryState(repo, feature string) (DeliveryState, error) {
- path, err := deliveryStatePath(repo, feature)
- if err != nil {
- return DeliveryState{}, err
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return DeliveryState{}, fmt.Errorf("managed delivery state is missing: %w", err)
- }
- // Route the raw document through the schema-migration hook before decoding. At
- // the current schema version this is a byte-for-byte pass-through; it exists so a
- // future version has a defined upgrade path and so state from a newer Boatstack
- // fails closed with an actionable message rather than as generic corruption.
- value, _, err = migrateDeliveryStateBytes(value)
- if err != nil {
- return DeliveryState{}, err
- }
- var state DeliveryState
- if err := DecodeJSON("load managed delivery state", path, value, &state); err != nil {
- return DeliveryState{}, err
- }
- if state.SchemaVersion != deliveryStateSchemaVersion || state.Feature != feature || len(state.Slices) == 0 || state.ActiveIndex < 0 || state.ActiveIndex > len(state.Slices) {
- return DeliveryState{}, fmt.Errorf("managed delivery state is invalid")
- }
- if state.RepairCounters == nil {
- state.RepairCounters = map[string]int{}
- }
- for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} {
- if state.RepairCounters[class] > state.RepairAttempt {
- state.RepairAttempt = state.RepairCounters[class]
- }
- }
- return state, nil
-}
-
-func initializeDeliveryState(repo, feature, planPath, lockPath string) error {
- plan, err := LoadPlan(planPath)
- if err != nil {
- return err
- }
- slices, err := deliveryDefinitions(plan)
- if err != nil {
- return err
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- return err
- }
- if existing, loadErr := LoadDeliveryState(repo, feature); loadErr == nil {
- // Re-activating the exact same lock is a no-op: never disturb progress.
- if existing.PlanLockHash == lockHash {
- return syncEngagementLease(repo, existing)
- }
- // A plan amendment mid-delivery must preserve every already-published
- // slice. deliveryDefinitions freshly recomputes ALL slices from the new
- // plan, so a naive re-initialize would reset ActiveIndex to 0 and strand
- // slices whose PR is already open or merged. Reconcile instead: keep the
- // published prefix (and its BUILD pointer) and adopt the amended
- // definitions only for the not-yet-published tail.
- if err := validateAmendmentPreservesProgress(existing, slices); err != nil {
- return err
- }
- next := reconcileAmendedDeliveryState(existing, slices, lockHash)
- if err := saveDeliveryState(repo, next); err != nil {
- return err
- }
- return syncEngagementLease(repo, next)
- }
- next := DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash,
- ActiveIndex: 0, Slices: slices, Mode: "NORMAL",
- RepairCounters: map[string]int{"implementation_repair": 0, "verification_repair": 0, "review_repair": 0},
- ParentDelivery: strings.TrimSpace(stringValue(plan["parent_delivery"])),
- Goal: deliveryGoalSnapshot(repo),
- }
- // Write the lease first. Until delivery state agrees, ResolveEngagement still
- // returns DORMANT; if the state write fails, remove the unaccepted lease.
- if err := syncEngagementLease(repo, next); err != nil {
- return err
- }
- if err := saveDeliveryState(repo, next); err != nil {
- _ = clearEngagementLease(repo)
- return err
- }
- return nil
-}
-
-// guardReactivationPreservesProgress lets ActivatePlan reject a
-// progress-destroying amendment before it promotes any artifact. It is a no-op
-// when no managed delivery exists yet (first activation) or when the amendment
-// only touches the not-yet-published tail. An idempotent same-plan re-activation
-// never reaches this guard: ActivatePlan short-circuits on the matching lock.
-func guardReactivationPreservesProgress(repo, feature, planPath string) error {
- existing, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return nil
- }
- plan, err := LoadPlan(planPath)
- if err != nil {
- return err
- }
- newSlices, err := deliveryDefinitions(plan)
- if err != nil {
- return err
- }
- return validateAmendmentPreservesProgress(existing, newSlices)
-}
-
-// equalStrings reports slice equality treating nil and empty as the same, so a
-// definition round-tripped through JSON (where an empty list may deserialize as
-// nil) compares equal to a freshly recomputed one.
-func equalStrings(a, b []string) bool {
- if len(a) != len(b) {
- return false
- }
- for i := range a {
- if a[i] != b[i] {
- return false
- }
- }
- return true
-}
-
-// deliveryDefinitionMatches reports whether two slices carry the same task
-// composition and scope. Runtime fields (Status, PRURL, PRState, branches) are
-// intentionally ignored: an amendment may not alter what an already-published
-// slice built, but PR/branch bookkeeping is delivery state, not definition.
-func deliveryDefinitionMatches(a, b DeliverySlice) bool {
- return a.ID == b.ID &&
- equalStrings(a.TaskIDs, b.TaskIDs) &&
- equalStrings(a.AffectedPaths, b.AffectedPaths) &&
- equalStrings(a.AcceptanceCriteria, b.AcceptanceCriteria)
-}
-
-// validateAmendmentPreservesProgress refuses a re-activation that would alter,
-// drop, or reorder any already-published delivery slice. Slices in
-// [0, ActiveIndex) have shipped — their branch, PR, and gate receipts are bound
-// to the definition that shipped — so a change there must go through a corrective
-// child delivery, never an in-place reset. The not-yet-published tail
-// [ActiveIndex, len) is freely recomputable, so amending it (e.g. widening a
-// building slice's affected_paths) is allowed.
-func validateAmendmentPreservesProgress(existing DeliveryState, newSlices []DeliverySlice) error {
- if existing.ActiveIndex >= len(existing.Slices) {
- return fmt.Errorf("published delivery %s is immutable; activate the correction under a new feature id with parent_delivery=%s", existing.Feature, existing.Feature)
- }
- for i := 0; i < existing.ActiveIndex; i++ {
- old := existing.Slices[i]
- if i >= len(newSlices) {
- return fmt.Errorf("amendment drops published delivery slice %s; draft a corrective child delivery instead of resetting delivery progress", old.ID)
- }
- if newSlices[i].ID != old.ID {
- return fmt.Errorf("amendment reorders or renames published delivery slice %s (now %s at position %d); draft a corrective child delivery instead of resetting delivery progress", old.ID, newSlices[i].ID, i)
- }
- if !deliveryDefinitionMatches(old, newSlices[i]) {
- return fmt.Errorf("amendment changes published delivery slice %s, whose pull request is bound to what shipped; draft a corrective child delivery instead of re-activating it in place", old.ID)
- }
- }
- return nil
-}
-
-// reconcileAmendedDeliveryState preserves the published prefix and its BUILD
-// pointer while adopting the amended plan's definitions for the not-yet-published
-// tail. The pointer never moves backward and shipped slices keep their PR,
-// branches, and status; only the active slice onward is recomputed (its prior
-// gate receipts are already invalidated by the new plan lock, so it correctly
-// restarts at BUILD). Callers MUST have passed validateAmendmentPreservesProgress
-// first.
-func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliverySlice, lockHash string) DeliveryState {
- merged := make([]DeliverySlice, 0, len(newSlices))
- merged = append(merged, existing.Slices[:existing.ActiveIndex]...)
- for i := existing.ActiveIndex; i < len(newSlices); i++ {
- slice := newSlices[i]
- if i == existing.ActiveIndex {
- slice.Status = StatusBuild
- } else {
- slice.Status = StatusPending
- }
- merged = append(merged, slice)
- }
- previousLocks := append([]string{}, existing.PreviousPlanLocks...)
- if existing.PlanLockHash != "" {
- previousLocks = append(previousLocks, existing.PlanLockHash)
- }
- return DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion,
- Feature: existing.Feature,
- PlanLockHash: lockHash,
- PreviousPlanLocks: previousLocks,
- ActiveIndex: existing.ActiveIndex,
- Slices: merged,
- Mode: "NORMAL",
- ParentDelivery: existing.ParentDelivery,
- Goal: existing.Goal,
- }
-}
-
-func archiveDeliveryReceipt(repo, feature, sliceID, gate, observationID string) (string, error) {
- path, err := deliveryReceiptPath(repo, feature, sliceID, gate)
- if err != nil {
- return "", err
- }
- value, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return "", nil
- }
- if err != nil {
- return "", err
- }
- archive := filepath.Join(filepath.Dir(filepath.Dir(path)), "superseded", observationID, gate+".json")
- if err := atomicWriteMode(archive, value, 0o644); err != nil {
- return "", err
- }
- if err := os.Remove(path); err != nil {
- return "", err
- }
- return ".git/boatstack/deliveries/" + feature + "/receipts/superseded/" + observationID + "/" + gate + ".json", nil
-}
-
-func appendChangeObservation(repo string, observation ChangeObservation) error {
- path := filepath.Join(WorkspaceFor(repo).FeatureDir(observation.Feature), "changes.md")
- existing, err := os.ReadFile(path)
- if err != nil && !os.IsNotExist(err) {
- return err
- }
- if len(existing) == 0 {
- existing = []byte("# Change observations\n\nAppend-only observations recorded after build activation.\n")
- }
- block := fmt.Sprintf("\n## %s\n\n- Recorded: `%s`\n- Source stage: `%s`\n- Classification: `%s`\n- Outcome: `%s`\n- Resume stage: `%s`\n- Parent delivery: `%s`\n- Suggested corrective feature: `%s`\n- User message: %s\n- Expected: %s\n- Actual: %s\n- Evidence: %s\n- Evidence SHA-256: `%s`\n- Mechanism: %s\n- Mechanism SHA-256: `%s`\n- Resolution: pending\n",
- observation.ID, observation.RecordedAt, observation.SourceStage, observation.Classification,
- observation.Outcome, observation.ResumeStage, observation.ParentDelivery, observation.SuggestedFeatureID,
- observation.Message, observation.Expected, observation.Actual, observation.Evidence,
- observation.EvidenceSHA256, observation.Mechanism, observation.MechanismSHA256)
- return atomicWriteMode(path, append(existing, []byte(block)...), 0o644)
-}
-
-func nextChangeObservationID(repo, feature string, fallback int) string {
- path := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "changes.md")
- value, err := os.ReadFile(path)
- if err != nil {
- return fmt.Sprintf("CHG-%03d", fallback)
- }
- maximum := fallback - 1
- pattern := regexp.MustCompile(`(?m)^## CHG-([0-9]+)\s*$`)
- for _, match := range pattern.FindAllStringSubmatch(string(value), -1) {
- parsed, parseErr := strconv.Atoi(match[1])
- if parseErr == nil && parsed > maximum {
- maximum = parsed
- }
- }
- return fmt.Sprintf("CHG-%03d", maximum+1)
-}
-
-func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservation, DeliveryState, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return ChangeObservation{}, DeliveryState{}, err
- }
- state, err := LoadDeliveryState(repo, options.Feature)
- if err != nil {
- return ChangeObservation{}, DeliveryState{}, err
- }
- if err := checkDeliveryPlanLock(repo, options.Feature, state); err != nil {
- return ChangeObservation{}, DeliveryState{}, err
- }
- classification := strings.ToLower(strings.TrimSpace(options.Classification))
- resume := map[string]string{
- "implementation_repair": "BUILD", "verification_repair": "TEST_GATE",
- "review_repair": "REVIEW_GATE", "requirement_amendment": "PLAN_GATE",
- "plan_invalid": "AUTO_PLAN",
- "needs_clarification": "",
- }[classification]
- if _, ok := map[string]bool{"implementation_repair": true, "verification_repair": true, "review_repair": true, "requirement_amendment": true, "needs_clarification": true, "plan_invalid": true}[classification]; !ok {
- return ChangeObservation{}, DeliveryState{}, fmt.Errorf("unsupported change classification %q", classification)
- }
- if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" {
- return ChangeObservation{}, DeliveryState{}, fmt.Errorf("change observation requires the user message and source stage")
- }
- repairClass := classification == "implementation_repair" || classification == "verification_repair" || classification == "review_repair"
- if repairClass && strings.TrimSpace(options.Mechanism) == "" {
- options.Mechanism = fmt.Sprintf("legacy API mechanism %d", time.Now().UnixNano())
- }
- evidenceHash := SHA256Bytes([]byte(strings.TrimSpace(options.Evidence)))
- mechanismHash := SHA256Bytes([]byte(strings.TrimSpace(options.Mechanism)))
- if repairClass {
- changePath := filepath.Join(WorkspaceFor(repo).FeatureDir(options.Feature), "changes.md")
- if prior, readErr := os.ReadFile(changePath); readErr == nil {
- for _, block := range strings.Split(string(prior), "\n## ") {
- if strings.Contains(block, "- Classification: `"+classification+"`") &&
- strings.Contains(block, "- Evidence SHA-256: `"+evidenceHash+"`") &&
- strings.Contains(block, "- Mechanism SHA-256: `"+mechanismHash+"`") {
- return ChangeObservation{}, DeliveryState{}, fmt.Errorf("friction: identical %s evidence and mechanism retry denied; change the repair mechanism", classification)
- }
- }
- }
- }
- published := state.ActiveIndex >= len(state.Slices)
-
- // Resolve which addressable slice this correction targets before mutating any
- // state. An explicit --slice, or a correction pushed on a published-but-open
- // earlier slice's branch, must bind to that slice — historically every non-
- // published correction was bound to the active slice, which corrupted the wrong
- // slice when the real target was an already-published slice inside the delivery.
- targetIndex := -1
- var targetSlice DeliverySlice
- publishedOpen := false
- if !published {
- sliceHint := strings.TrimSpace(options.SliceID)
- if sliceHint == "" {
- if branch, _ := gitCommand(repo, "branch", "--show-current"); strings.TrimSpace(branch) != "" {
- if idx, _, ok := resolveAddressableSliceByBranch(state, strings.TrimSpace(branch)); ok && idx < state.ActiveIndex {
- sliceHint = state.Slices[idx].ID
- }
- }
- }
- idx, slice, resolveErr := resolveAddressableSlice(state, sliceHint)
- if resolveErr != nil {
- return ChangeObservation{}, DeliveryState{}, resolveErr
- }
- targetIndex, targetSlice = idx, slice
- publishedOpen = idx < state.ActiveIndex
- }
-
- // A published-but-open slice may only be corrected in place through a gate
- // repair; a requirement or plan change to it is a new decision that requires an
- // independently approved corrective child, not an in-place re-gate.
- if publishedOpen {
- switch classification {
- case "implementation_repair", "verification_repair", "review_repair":
- default:
- return ChangeObservation{}, DeliveryState{}, fmt.Errorf("delivery slice %s is published with an open pull request; a %s change must be handled by a corrective child delivery, not an in-place re-gate", targetSlice.ID, classification)
- }
- }
-
- attempt := 0
- if repairClass && !published && !publishedOpen {
- if state.RepairCounters == nil {
- state.RepairCounters = map[string]int{}
- }
- attempt = state.RepairCounters[classification]
- if attempt >= 3 {
- return ChangeObservation{}, DeliveryState{}, fmt.Errorf("friction: persistent repair budget exhausted for %s after %d attempts; preserve current state and require a reviewed recovery decision", classification, attempt)
- }
- attempt++
- state.RepairCounters[classification] = attempt
- }
- id := nextChangeObservationID(repo, options.Feature, 1)
- observation := ChangeObservation{
- ID: id, Feature: options.Feature, SourceStage: strings.ToUpper(strings.TrimSpace(options.SourceStage)),
- Expected: strings.TrimSpace(options.Expected), Actual: strings.TrimSpace(options.Actual), Evidence: strings.TrimSpace(options.Evidence),
- EvidenceSHA256: evidenceHash, Mechanism: strings.TrimSpace(options.Mechanism), MechanismSHA256: mechanismHash,
- Message: strings.TrimSpace(options.Message), Classification: classification, ResumeStage: resume,
- RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
- }
- switch {
- case published:
- if len(state.Slices) > 0 {
- observation.SliceID = state.Slices[len(state.Slices)-1].ID
- }
- states, _, statesErr := allManagedDeliveryStates(repo)
- if statesErr != nil {
- return ChangeObservation{}, DeliveryState{}, statesErr
- }
- observation.Outcome = "CORRECTIVE_CHILD_REQUIRED"
- observation.ParentDelivery = state.Feature
- observation.SuggestedFeatureID = suggestedCorrectionFeature(states, state.Feature)
- case publishedOpen:
- observation.SliceID = targetSlice.ID
- observation.Outcome = "RESUME_PUBLISHED_SLICE"
- default:
- observation.SliceID = targetSlice.ID
- observation.Outcome = "RESUME_ACTIVE"
- }
- if err := appendChangeObservation(repo, observation); err != nil {
- return ChangeObservation{}, DeliveryState{}, err
- }
- // Under the merged terminal, a recorded post-publish correction advances
- // the targeted published slice's fix-cycle bookkeeping (and, after an
- // escape, is the operator's explicit reset for a fresh cycle). The
- // published default records nothing — its state files stay byte-stable.
- // control-law: goal-escape-demotes-to-operator-and-stops
- trackPostPublishCycle := resolveDeliveryTerminal(repo, options.Feature) == TerminalMerged
- if published {
- if trackPostPublishCycle && len(state.Slices) > 0 {
- bumpPostPublishFixCycle(&state.Slices[len(state.Slices)-1])
- if err := saveDeliveryState(repo, state); err != nil {
- return ChangeObservation{}, DeliveryState{}, err
- }
- }
- return observation, state, nil
- }
- if publishedOpen {
- // In-place re-gate of a published-but-open slice: archive its stale gate
- // receipts and reset its Status so test/review re-run against the fix, while
- // its PRState="OPEN" preserves the published identity that publish-pr --action
- // update targets. The active slice's build loop (RepairAttempt, Mode,
- // ResumeStage, ActiveObservationID) is deliberately left untouched — the
- // correction belongs to the published slice, not the active one.
- slice := &state.Slices[targetIndex]
- gates := []string{"review"}
- if resume == "BUILD" || resume == "TEST_GATE" {
- gates = []string{"test", "review"}
- }
- for _, gate := range gates {
- archived, archiveErr := archiveDeliveryReceipt(repo, options.Feature, slice.ID, gate, id)
- if archiveErr != nil {
- return ChangeObservation{}, DeliveryState{}, archiveErr
- }
- if archived != "" {
- state.SupersededReceipts = append(state.SupersededReceipts, archived)
- }
- }
- if resume == "REVIEW_GATE" {
- slice.Status = StatusTestPassed
- } else {
- slice.Status = StatusBuild
- }
- if trackPostPublishCycle {
- bumpPostPublishFixCycle(slice)
- }
- if err := saveDeliveryState(repo, state); err != nil {
- return ChangeObservation{}, DeliveryState{}, err
- }
- return observation, state, nil
- }
- state.ActiveObservationID = id
- state.ResumeStage = resume
- if classification == "needs_clarification" || classification == "requirement_amendment" {
- state.Mode = "AMENDMENT_REQUIRED"
- if classification == "needs_clarification" {
- state.ResumeStage = ""
- }
- } else if classification == "plan_invalid" {
- state.Mode = "PLAN_INVALID"
- state.ResumeStage = "AUTO_PLAN"
- } else {
- state.Mode = "REWORK"
- slice := &state.Slices[state.ActiveIndex]
- gates := []string{"review"}
- if resume == "BUILD" || resume == "TEST_GATE" {
- gates = []string{"test", "review"}
- }
- for _, gate := range gates {
- archived, archiveErr := archiveDeliveryReceipt(repo, options.Feature, slice.ID, gate, id)
- if archiveErr != nil {
- return ChangeObservation{}, DeliveryState{}, archiveErr
- }
- if archived != "" {
- state.SupersededReceipts = append(state.SupersededReceipts, archived)
- }
- }
- if resume == "REVIEW_GATE" {
- slice.Status = StatusTestPassed
- } else {
- slice.Status = StatusBuild
- }
- }
- if err := saveDeliveryState(repo, state); err != nil {
- return ChangeObservation{}, DeliveryState{}, err
- }
- return observation, state, nil
-}
-
-func activeDeliverySlice(state DeliveryState) (DeliverySlice, error) {
- if state.ActiveIndex >= len(state.Slices) {
- return DeliverySlice{}, fmt.Errorf("all delivery slices are already published")
- }
- return state.Slices[state.ActiveIndex], nil
-}
-
-// isTerminalPRState reports whether a published slice's pull request has reached
-// a state (merged or closed) that a corrective child delivery must address,
-// rather than an in-place re-gate of the original slice.
-func isTerminalPRState(prState string) bool {
- switch strings.ToUpper(strings.TrimSpace(prState)) {
- case "MERGED", "CLOSED", "PUBLISHED_MERGED", "PUBLISHED_CLOSED":
- return true
- }
- return false
-}
-
-// resolveAddressableSlice selects the slice a gate or ship operation may act on.
-//
-// The delivery keeps a single BUILD pointer (ActiveIndex) that advances on
-// publication so the next slice can start building. Addressability, however, is
-// broader than that pointer: a slice that has been PUBLISHED but whose PR is not
-// yet terminal must remain re-gateable and updatable *in place*, because its
-// postcondition (CI/merge) has not been observed. Conflating "which slice builds
-// next" with "which slices may still be corrected" is what stranded a just
-// published slice — publication advanced the pointer and thereby revoked the
-// bounded correction actuator before the slice's postcondition was terminal.
-//
-// The addressable set is therefore {active slice} ∪ {PUBLISHED slices whose PR
-// is not terminal}. Resolution is network-free: terminal-ness is read from the
-// persisted PRState cache, never a live gh call.
-func resolveAddressableSlice(state DeliveryState, sliceID string) (int, DeliverySlice, error) {
- sliceID = strings.TrimSpace(sliceID)
- if sliceID == "" {
- if state.ActiveIndex >= len(state.Slices) {
- return -1, DeliverySlice{}, fmt.Errorf("all delivery slices are already published")
- }
- return state.ActiveIndex, state.Slices[state.ActiveIndex], nil
- }
- activeID := "n/a"
- if state.ActiveIndex < len(state.Slices) {
- activeID = state.Slices[state.ActiveIndex].ID
- }
- for i, s := range state.Slices {
- if s.ID != sliceID {
- continue
- }
- if i == state.ActiveIndex {
- return i, s, nil
- }
- // An earlier slice remains addressable while it has been published (PRState
- // set on publication) but its PR is not yet terminal. PRState — not Status —
- // is the correctability marker, because an in-place re-gate transitions the
- // slice's Status back through TEST_PASSED/REVIEW_PASSED while it is still the
- // same open PR being corrected.
- if i < state.ActiveIndex && strings.TrimSpace(s.PRState) != "" {
- if isTerminalPRState(s.PRState) {
- return -1, DeliverySlice{}, fmt.Errorf("delivery slice %s has a %s pull request; draft a corrective child delivery instead of re-gating it in place", sliceID, strings.ToLower(strings.TrimPrefix(strings.ToUpper(s.PRState), "PUBLISHED_")))
- }
- return i, s, nil
- }
- return -1, DeliverySlice{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", sliceID, activeID)
- }
- return -1, DeliverySlice{}, fmt.Errorf("delivery slice %s does not exist", sliceID)
-}
-
-// resolveAddressableSliceByBranch selects the addressable slice a correction on
-// the given branch may act on. It applies the same addressable set as
-// resolveAddressableSlice — {active slice} ∪ {PUBLISHED slices whose PR is not
-// terminal} — but keys off the slice's recorded head branch rather than its id.
-//
-// Advisors (recovery routing, safety findings, change recording) start from the
-// current git branch, not a slice id, and historically resolved corrections
-// against state.ActiveIndex alone. That left the advisor layer blind to a
-// published-but-open earlier slice that the actuator layer (resolveAddressableSlice)
-// could still correct in place, so a correction pushed on the published slice's
-// branch was mis-routed to the active slice. Routing every advisor through this one
-// resolver keeps "which slice this correction may act on" defined in a single place,
-// so the advisor layer cannot drift from the actuator layer again. ok is false when
-// no addressable slice owns the branch (including a slice whose PR is terminal — the
-// caller then falls through to its corrective-child path unchanged).
-func resolveAddressableSliceByBranch(state DeliveryState, branch string) (int, DeliverySlice, bool) {
- branch = strings.TrimSpace(branch)
- if branch == "" {
- return -1, DeliverySlice{}, false
- }
- for i, s := range state.Slices {
- if strings.TrimSpace(s.HeadBranch) != branch {
- continue
- }
- if i == state.ActiveIndex {
- return i, s, true
- }
- if i < state.ActiveIndex && strings.TrimSpace(s.PRState) != "" && !isTerminalPRState(s.PRState) {
- return i, s, true
- }
- }
- return -1, DeliverySlice{}, false
-}
-
-func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error {
- lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.lock.json")
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- return fmt.Errorf("managed delivery requires its current plan lock: %w", err)
- }
- if state.PlanLockHash == "" || state.PlanLockHash != lockHash {
- return fmt.Errorf("managed delivery state is stale for the current plan lock; reactivate the authorized plan")
- }
- return nil
-}
-
-func CurrentDeliveryState(repoPath, feature string) (DeliveryState, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return DeliveryState{}, err
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return DeliveryState{}, err
- }
- if err := checkDeliveryPlanLock(repo, feature, state); err != nil {
- return DeliveryState{}, err
- }
- return state, nil
-}
-
-func currentDiffIdentity(repo, base, previewPath string) (string, string, string, []string, error) {
- head, err := gitCommand(repo, "branch", "--show-current")
- if err != nil || head == "" {
- return "", "", "", nil, fmt.Errorf("delivery gate requires a named branch")
- }
- baseCommit, err := resolveBaseCommit(repo, base)
- if err != nil {
- return "", "", "", nil, err
- }
- mergeBase, err := gitCommand(repo, "merge-base", baseCommit, "HEAD")
- if err != nil || mergeBase == "" {
- return "", "", "", nil, fmt.Errorf("cannot determine delivery diff against %s", base)
- }
- diff, changed, err := productDiff(repo, mergeBase, previewPath)
- if err != nil {
- return "", "", "", nil, err
- }
- if len(changed) == 0 {
- return "", "", "", nil, fmt.Errorf("delivery slice has no committed changes relative to %s", base)
- }
- headCommit, err := gitCommand(repo, "rev-parse", "HEAD")
- if err != nil {
- return "", "", "", nil, err
- }
- return head, headCommit, SHA256Bytes(diff), changed, nil
-}
-
-func pathMatchesDeliveryScope(path string, patterns []string) bool {
- path = filepath.ToSlash(path)
- for _, pattern := range patterns {
- pattern = filepath.ToSlash(strings.TrimSpace(pattern))
- if pattern == "" {
- continue
- }
- if pattern == "**" || pattern == "*" {
- return true
- }
- if strings.HasSuffix(pattern, "/**") {
- root := strings.TrimSuffix(pattern, "/**")
- if path == root || strings.HasPrefix(path, root+"/") {
- return true
- }
- }
- prefix := strings.TrimSuffix(pattern, "/")
- matched, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(path))
- if matched || path == prefix || strings.HasPrefix(path, prefix+"/") {
- return true
- }
- }
- return false
-}
-
-func validateDeliveryScope(feature string, slice DeliverySlice, changed []string) error {
- if len(slice.AffectedPaths) == 0 {
- return nil
- }
- unexpected := []string{}
- artifactPrefix := ".product-loop/features/" + feature + "/"
- for _, path := range changed {
- path = filepath.ToSlash(path)
- if strings.HasPrefix(path, artifactPrefix) || pathMatchesDeliveryScope(path, slice.AffectedPaths) {
- continue
- }
- unexpected = append(unexpected, path)
- }
- if len(unexpected) > 0 {
- sort.Strings(unexpected)
- return fmt.Errorf("delivery slice %s contains changes outside its affected_paths: %s", slice.ID, strings.Join(unexpected, ", "))
- }
- return nil
-}
-
-func readDeliveryReceipt(repo, feature, sliceID, gate string) (DeliveryGateReceipt, error) {
- path, err := deliveryReceiptPath(repo, feature, sliceID, gate)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return DeliveryGateReceipt{}, fmt.Errorf("%s gate receipt is missing for delivery slice %s", gate, sliceID)
- }
- var receipt DeliveryGateReceipt
- if err := DecodeJSON("load delivery gate receipt", path, value, &receipt); err != nil {
- return DeliveryGateReceipt{}, err
- }
- if receipt.SchemaVersion != deliveryStateSchemaVersion || receipt.Feature != feature || receipt.SliceID != sliceID || receipt.Gate != gate {
- return DeliveryGateReceipt{}, fmt.Errorf("%s gate receipt is invalid for delivery slice %s", gate, sliceID)
- }
- return receipt, nil
-}
-
-func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- gate := strings.ToLower(strings.TrimSpace(options.Gate))
- status := strings.ToUpper(strings.TrimSpace(options.Status))
- if gate != "test" && gate != "review" {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery gate must be test or review")
- }
- if status != "PASS" && status != "PASS_WITH_GAPS" {
- return DeliveryGateReceipt{}, fmt.Errorf("a delivery gate receipt may record only PASS or PASS_WITH_GAPS")
- }
- config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if configErr != nil {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery gate requires a valid Boatstack project configuration: %w", configErr)
- }
- if err := validateDeliveryGatePolicy(config, gate, status, nil, options.ReviewerIdentity, options.ReviewMethod); err != nil {
- return DeliveryGateReceipt{}, err
- }
- state, err := LoadDeliveryState(repo, options.Feature)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- if err := checkDeliveryPlanLock(repo, options.Feature, state); err != nil {
- return DeliveryGateReceipt{}, err
- }
- if state.Mode == "AMENDMENT_REQUIRED" || state.Mode == "PLAN_INVALID" {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery requires an approved plan amendment before gates may continue")
- }
- sliceIndex, slice, err := resolveAddressableSlice(state, options.SliceID)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- base := strings.TrimSpace(options.BaseBranch)
- if base == "" {
- base = slice.BaseBranch
- }
- if base == "" {
- base = defaultPRBase(repo)
- }
- base, err = canonicalPRBase(repo, base)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- previewPath, _ := expectedPRPreviewPath("managed", options.Feature, "")
- head, headCommit, diffHash, changed, err := currentDiffIdentity(repo, base, previewPath)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- if err := validateDeliveryScope(options.Feature, slice, changed); err != nil {
- return DeliveryGateReceipt{}, err
- }
- if err := validateDeliveryGatePolicy(config, gate, status, changed, options.ReviewerIdentity, options.ReviewMethod); err != nil {
- return DeliveryGateReceipt{}, err
- }
- if slice.HeadBranch != "" && slice.HeadBranch != head {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s requires head branch %s; current branch is %s", slice.ID, slice.HeadBranch, head)
- }
- if err := checkCurrentJourneyResults(repo, options.Feature, base, headCommit, diffHash); err != nil {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery gate requires current journey evidence: %w", err)
- }
- if gate == "review" {
- if slice.Status != StatusTestPassed {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s must pass its test gate before review", slice.ID)
- }
- testReceipt, receiptErr := readDeliveryReceipt(repo, options.Feature, slice.ID, "test")
- if receiptErr != nil {
- return DeliveryGateReceipt{}, receiptErr
- }
- if testReceipt.HeadCommit != headCommit || testReceipt.DiffSHA256 != diffHash || testReceipt.BaseBranch != base {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery diff changed after the test gate; rerun test-gate for slice %s", slice.ID)
- }
- baseCommit, baseErr := resolveBaseCommit(repo, base)
- if baseErr != nil {
- return DeliveryGateReceipt{}, baseErr
- }
- mergeBase, mergeErr := gitCommand(repo, "merge-base", baseCommit, "HEAD")
- if mergeErr != nil || mergeBase == "" {
- return DeliveryGateReceipt{}, fmt.Errorf("cannot determine changelog diff against %s", base)
- }
- changelogBase, changelogBaseErr := changelogComparisonBase(repo, options.Feature, options.SliceID, mergeBase)
- if changelogBaseErr != nil {
- return DeliveryGateReceipt{}, changelogBaseErr
- }
- if changelogErr := validateChangelogChange(repo, changelogBase, config); changelogErr != nil {
- return DeliveryGateReceipt{}, changelogErr
- }
- }
- evidencePath := strings.TrimSpace(options.EvidencePath)
- if evidencePath == "" {
- evidencePath = featureEvidencePath(WorkspaceFor(repo).FeatureDir(options.Feature))
- } else if !filepath.IsAbs(evidencePath) {
- evidencePath = filepath.Join(repo, evidencePath)
- }
- if resolved, resolveErr := filepath.EvalSymlinks(evidencePath); resolveErr == nil {
- evidencePath = resolved
- }
- evidenceHash, err := SHA256File(evidencePath)
- if err != nil {
- return DeliveryGateReceipt{}, fmt.Errorf("delivery gate requires current evidence: %w", err)
- }
- evidenceValue, err := os.ReadFile(evidencePath)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- explicit := len(state.Slices) > 1 || state.Slices[0].ID != "delivery"
- gateLabel := strings.ToUpper(gate[:1]) + gate[1:]
- if recorded := deliveryEvidenceGateStatus(string(evidenceValue), gateLabel, slice.ID, explicit); recorded != status {
- return DeliveryGateReceipt{}, fmt.Errorf("evidence ledger must mark the %s gate for delivery slice %s as %s; found %q", gate, slice.ID, status, recorded)
- }
- relEvidence, err := repositoryRelativePath(WorkspaceFor(repo).ExportRoot(), evidencePath)
- if err != nil {
- return DeliveryGateReceipt{}, err
- }
- previous, _ := readDeliveryReceipt(repo, options.Feature, slice.ID, gate)
- receipt := DeliveryGateReceipt{
- SchemaVersion: deliveryStateSchemaVersion, Feature: options.Feature, SliceID: slice.ID,
- Gate: gate, Status: status, BaseBranch: base, HeadBranch: head, HeadCommit: headCommit,
- DiffSHA256: diffHash, EvidencePath: relEvidence, EvidenceHash: evidenceHash,
- RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
- Attempt: state.RepairAttempt + 1, TriggerObservationID: state.ActiveObservationID,
- ReviewerIdentity: strings.TrimSpace(options.ReviewerIdentity), ReviewMethod: strings.ToLower(strings.TrimSpace(options.ReviewMethod)),
- }
- if previous.RecordedAt != "" {
- receipt.Supersedes = previous.RecordedAt
- } else if state.ActiveObservationID != "" && len(state.SupersededReceipts) > 0 {
- receipt.Supersedes = state.SupersededReceipts[len(state.SupersededReceipts)-1]
- }
- path, _ := deliveryReceiptPath(repo, options.Feature, slice.ID, gate)
- value, _ := MarshalJSON(receipt)
- if err := atomicWriteMode(path, value, 0o644); err != nil {
- return DeliveryGateReceipt{}, err
- }
- state.Slices[sliceIndex].BaseBranch = base
- state.Slices[sliceIndex].HeadBranch = head
- if gate == "test" {
- state.Slices[sliceIndex].Status = StatusTestPassed
- if reviewPath, pathErr := deliveryReceiptPath(repo, options.Feature, slice.ID, "review"); pathErr == nil {
- _ = os.Remove(reviewPath)
- }
- } else {
- state.Slices[sliceIndex].Status = StatusReviewPassed
- state.Mode = "NORMAL"
- state.ResumeStage = ""
- state.ActiveObservationID = ""
- }
- if err := saveDeliveryState(repo, state); err != nil {
- return DeliveryGateReceipt{}, err
- }
- return receipt, nil
-}
-
-func CheckDeliveryReadyForShip(repo, feature, sliceID, base, head, diffHash string, changed []string) (DeliveryState, DeliverySlice, []PRSource, error) {
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return DeliveryState{}, DeliverySlice{}, nil, err
- }
- if err := checkDeliveryPlanLock(repo, feature, state); err != nil {
- return DeliveryState{}, DeliverySlice{}, nil, err
- }
- if state.Mode != "" && state.Mode != "NORMAL" {
- return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("delivery has unresolved repair state %s", state.Mode)
- }
- _, slice, err := resolveAddressableSlice(state, sliceID)
- if err != nil {
- return DeliveryState{}, DeliverySlice{}, nil, err
- }
- // A published-open slice that has been re-gated in place is REVIEW_PASSED again;
- // an already-PUBLISHED slice that has not been re-gated is still shippable as an
- // idempotent --action update of its open PR.
- if slice.Status != StatusReviewPassed && slice.Status != StatusPublished {
- return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("delivery slice %s has not passed test and review gates", slice.ID)
- }
- if err := validateDeliveryScope(feature, slice, changed); err != nil {
- return DeliveryState{}, DeliverySlice{}, nil, err
- }
- sources := []PRSource{}
- config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if configErr != nil {
- return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("ship readiness requires a valid Boatstack project configuration: %w", configErr)
- }
- for _, gate := range []string{"test", "review"} {
- receipt, receiptErr := readDeliveryReceipt(repo, feature, slice.ID, gate)
- if receiptErr != nil {
- return DeliveryState{}, DeliverySlice{}, nil, receiptErr
- }
- if receipt.BaseBranch != base || receipt.HeadBranch != head || receipt.DiffSHA256 != diffHash {
- return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("stale delivery receipt: diff changed after the %s gate; rerun gates for slice %s", gate, slice.ID)
- }
- if err := validateDeliveryGatePolicy(config, gate, receipt.Status, changed, receipt.ReviewerIdentity, receipt.ReviewMethod); err != nil {
- return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("%s gate receipt violates current workflow policy: %w", gate, err)
- }
- path, _ := deliveryReceiptPath(repo, feature, slice.ID, gate)
- hash, _ := SHA256File(path)
- sources = append(sources, PRSource{Kind: gate + "_gate_receipt", Path: ".git/boatstack/deliveries/" + feature + "/receipts/" + slice.ID + "/" + gate + ".json", SHA256: hash})
- }
- return state, slice, sources, nil
-}
-
-func MarkDeliveryPublished(repo, feature, sliceID, url string) error {
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return err
- }
- if err := checkDeliveryPlanLock(repo, feature, state); err != nil {
- return err
- }
- sliceIndex, slice, err := resolveAddressableSlice(state, sliceID)
- if err != nil {
- return err
- }
- if slice.ID != sliceID {
- return fmt.Errorf("delivery slice %s is not ready to publish", sliceID)
- }
- // Re-publishing an already-PUBLISHED, non-terminal slice is an idempotent
- // --action update of its still-open PR: refresh the recorded PR URL and keep
- // PRState OPEN, but do NOT advance the BUILD pointer a second time.
- if slice.Status == StatusPublished {
- state.Slices[sliceIndex].PRURL = url
- if strings.TrimSpace(state.Slices[sliceIndex].PRState) == "" {
- state.Slices[sliceIndex].PRState = "OPEN"
- }
- if err := saveDeliveryState(repo, state); err != nil {
- return err
- }
- if err := syncEngagementLease(repo, state); err != nil {
- return err
- }
- reconcileInsightsForFeature(repo, feature)
- return nil
- }
- if slice.Status != StatusReviewPassed {
- return fmt.Errorf("delivery slice %s is not ready to publish", sliceID)
- }
- state.Slices[sliceIndex].Status = StatusPublished
- state.Slices[sliceIndex].PRURL = url
- state.Slices[sliceIndex].PRState = "OPEN"
- // Only a first publication of the active slice advances the BUILD pointer to
- // the next slice; a re-publication of an earlier published-open slice does not.
- if sliceIndex == state.ActiveIndex {
- state.ActiveIndex++
- if state.ActiveIndex < len(state.Slices) {
- state.Slices[state.ActiveIndex].Status = StatusBuild
- state.RepairAttempt = 0
- state.RepairCounters = map[string]int{"implementation_repair": 0, "verification_repair": 0, "review_repair": 0}
- state.ActiveObservationID = ""
- state.ResumeStage = ""
- state.Mode = "NORMAL"
- }
- }
- if err := saveDeliveryState(repo, state); err != nil {
- return err
- }
- if err := syncEngagementLease(repo, state); err != nil {
- return err
- }
- reconcileInsightsForFeature(repo, feature)
- return nil
-}
-
-// scanManagedDeliveries partitions the delivery-state store into deliveries
-// whose state.json is valid and still in progress (active) and slugs whose
-// state.json is unreadable or malformed (invalid). Unlike ActiveManagedDeliveries
-// it never lets one corrupt delivery poison the scan: invalid deliveries are
-// returned as data, not as a fatal error, so a read-only caller (ResolveNext)
-// can apply the ignored-deliveries filter and surface an actionable remedy for
-// exactly the offending delivery instead of escalating one stale delivery into a
-// repo-wide INVALID_STATE that blocks unrelated new features. It errors only
-// when the store directory itself cannot be read.
-func scanManagedDeliveries(repo string) (active []string, invalid []string, err error) {
- directory, err := deliveryStateDirectory(repo)
- if err != nil {
- return nil, nil, err
- }
- entries, err := os.ReadDir(directory)
- if os.IsNotExist(err) {
- return nil, nil, nil
- }
- if err != nil {
- return nil, nil, err
- }
- for _, entry := range entries {
- if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) {
- continue
- }
- state, loadErr := LoadDeliveryState(repo, entry.Name())
- if loadErr != nil {
- invalid = append(invalid, entry.Name())
- continue
- }
- if state.ActiveIndex < len(state.Slices) || (state.Mode != "" && state.Mode != "NORMAL") {
- active = append(active, entry.Name())
- }
- }
- sort.Strings(active)
- sort.Strings(invalid)
- return active, invalid, nil
-}
-
-// ActiveManagedDeliveries is the strict, fail-closed enumeration used by the
-// mutation and guard boundaries (run, publish, safety, workspace): any invalid
-// delivery in the store aborts with an error so no mutation proceeds over
-// unverifiable state. The tolerant read-only counterpart is scanManagedDeliveries.
-func ActiveManagedDeliveries(repo string) ([]string, error) {
- active, invalid, err := scanManagedDeliveries(repo)
- if err != nil {
- return nil, err
- }
- if len(invalid) > 0 {
- _, loadErr := LoadDeliveryState(repo, invalid[0])
- return nil, fmt.Errorf("invalid managed delivery state for %s: %w", invalid[0], loadErr)
- }
- return active, nil
-}
-
-// withoutIgnoredDeliveries returns the feature slugs in features that are not
-// listed in ignored. Order is preserved. It is used to scope delivery-ambiguity
-// resolution to the deliveries the user still cares about; new, unlisted
-// deliveries are unaffected.
-func withoutIgnoredDeliveries(features []string, ignored []string) []string {
- if len(ignored) == 0 {
- return features
- }
- skip := make(map[string]bool, len(ignored))
- for _, slug := range ignored {
- skip[slug] = true
- }
- kept := make([]string, 0, len(features))
- for _, f := range features {
- if !skip[f] {
- kept = append(kept, f)
- }
- }
- return kept
-}
-
-// withoutIgnoredDeliveryStates is the DeliveryState-slice variant of
-// withoutIgnoredDeliveries, filtering by each state's Feature slug.
-func withoutIgnoredDeliveryStates(states []DeliveryState, ignored []string) []DeliveryState {
- if len(ignored) == 0 {
- return states
- }
- skip := make(map[string]bool, len(ignored))
- for _, slug := range ignored {
- skip[slug] = true
- }
- kept := make([]DeliveryState, 0, len(states))
- for _, state := range states {
- if !skip[state.Feature] {
- kept = append(kept, state)
- }
- }
- return kept
-}
-
-// IgnoreDelivery appends a feature slug to workflow.ignored_deliveries through
-// the authority-aware configuration mutation boundary. It deduplicates while
-// preserving order and returns whether the slug was newly added.
-func IgnoreDelivery(repo, feature string) (bool, error) {
- feature = strings.TrimSpace(feature)
- if feature == "" {
- return false, fmt.Errorf("ignore-delivery requires a feature slug")
- }
- if !featureSlugPattern.MatchString(feature) {
- return false, fmt.Errorf("feature slug %q is not a valid Boatstack feature slug", feature)
- }
- mutation, err := mutateManagedConfiguration(repo, func(config *ProjectConfig) (bool, error) {
- for _, existing := range config.Workflow.IgnoredDeliveries {
- if existing == feature {
- return false, nil
- }
- }
- config.Workflow.IgnoredDeliveries = append(config.Workflow.IgnoredDeliveries, feature)
- return true, nil
- })
- if err != nil {
- return false, err
- }
- return mutation.Changed, nil
-}
-
-// DiscardDeliveryResult is the host-neutral outcome of discarding one managed
-// delivery's state from the store.
-type DiscardDeliveryResult struct {
- Feature string `json:"feature"`
- Action string `json:"action"` // discarded | refused | none
- ArchivePath string `json:"archive_path,omitempty"`
- Reason string `json:"reason"`
- Published []string `json:"published_slices,omitempty"`
-}
-
-// DiscardDelivery removes one managed delivery's state from the delivery store
-// so a stale or abandoned delivery can no longer block unrelated new features on
-// the branches and worktrees that share the store. It is the bounded actuator
-// behind the discard-delivery helper, and the escape hatch that makes a new
-// mutation possible after Boatstack itself has been fixed but a divergent live
-// delivery remains.
-//
-// control-law: discard-preserves-published-authority
-//
-// Boundary: operator request -> deletion of managed delivery state
-// Control law: a delivery bearing PUBLISHED authority (any slice with a
-// recorded PRState) is NOT discardable without an explicit
-// force override; a discard NEVER touches git-tracked
-// artifacts, the plan, the lock, or merged history, and it
-// ARCHIVES (never hard-deletes) the state so the action is
-// reversible.
-// Authorized actor: operator naming the exact feature slug (no implicit/bulk delete)
-// Required evidence: the delivery's own state.json (feature match) and its
-// recorded per-slice PRState — deterministic and offline; no
-// live gh call gates the unblock.
-// Failure behavior: refuse and leave the store unchanged when the slug is
-// invalid, no such delivery exists, or a published slice is
-// present without force (fail closed).
-// Release condition: the named delivery exists and either bears no published
-// authority or force is set.
-//
-// discardOrphanFeatureArtifacts archives an orphaned product-loop feature
-// directory — one carrying a pr.md but no plan.lock.json, with no managed delivery
-// state — reversibly to a dotted .discarded sibling (which the slug pattern skips,
-// so it is never re-scanned as live). It is the accepting side of the Coreachability
-// contract for the orphan cause: ResolveNext prescribes discard-delivery for an
-// orphan, so discard-delivery must clear it. It refuses a dir carrying a
-// plan.lock.json (a registered, live feature) so it never touches active work.
-func discardOrphanFeatureArtifacts(repo, feature string) (DiscardDeliveryResult, bool, error) {
- dir := WorkspaceFor(repo).FeatureDir(feature)
- info, statErr := os.Stat(dir)
- if os.IsNotExist(statErr) {
- return DiscardDeliveryResult{}, false, nil
- }
- if statErr != nil {
- return DiscardDeliveryResult{}, false, statErr
- }
- if !info.IsDir() {
- return DiscardDeliveryResult{}, false, nil
- }
- if !fileExists(filepath.Join(dir, "pr.md")) || fileExists(filepath.Join(dir, "plan.lock.json")) {
- return DiscardDeliveryResult{}, false, nil
- }
- archiveDir := filepath.Join(filepath.Dir(dir), ".discarded")
- destination := filepath.Join(archiveDir, feature)
- for suffix := 2; ; suffix++ {
- if _, existErr := os.Stat(destination); os.IsNotExist(existErr) {
- break
- } else if existErr != nil {
- return DiscardDeliveryResult{}, false, existErr
- }
- destination = filepath.Join(archiveDir, fmt.Sprintf("%s-%d", feature, suffix))
- }
- if err := os.MkdirAll(archiveDir, 0o755); err != nil {
- return DiscardDeliveryResult{}, false, err
- }
- if err := os.Rename(dir, destination); err != nil {
- return DiscardDeliveryResult{}, false, err
- }
- archive := destination
- if rel, relErr := filepath.Rel(repo, destination); relErr == nil {
- archive = filepath.ToSlash(rel)
- }
- return DiscardDeliveryResult{
- Feature: feature, Action: "discarded", ArchivePath: archive,
- Reason: "orphaned feature artifacts (pr.md without a plan lock) archived; the feature can be re-planned",
- }, true, nil
-}
-
-func DiscardDelivery(repoPath, feature string, force bool) (DiscardDeliveryResult, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return DiscardDeliveryResult{}, err
- }
- feature = strings.TrimSpace(feature)
- if !featureSlugPattern.MatchString(feature) {
- return DiscardDeliveryResult{}, fmt.Errorf("discard-delivery requires a valid feature slug")
- }
- statePath, err := deliveryStatePath(repo, feature)
- if err != nil {
- return DiscardDeliveryResult{}, err
- }
- featureDir := filepath.Dir(statePath)
- if info, statErr := os.Stat(featureDir); os.IsNotExist(statErr) {
- // No delivery-state dir. The resolver also prescribes discard-delivery for an
- // ORPHAN — a product-loop feature dir carrying a pr.md but no plan.lock.json —
- // so discard-delivery must accept and archive that too (Coreachability: the
- // verb accepts every state that prescribes it).
- if archived, ok, orphanErr := discardOrphanFeatureArtifacts(repo, feature); orphanErr != nil {
- return DiscardDeliveryResult{}, orphanErr
- } else if ok {
- return archived, nil
- }
- return DiscardDeliveryResult{
- Feature: feature, Action: "none",
- Reason: "no managed delivery state exists for this feature",
- }, nil
- } else if statErr != nil {
- return DiscardDeliveryResult{}, statErr
- } else if !info.IsDir() {
- return DiscardDeliveryResult{}, fmt.Errorf("managed delivery path for %s is not a directory", feature)
- }
-
- // Published-authority gate: deterministic and offline, read from the recorded
- // per-slice PRState. A malformed state that cannot be loaded carries no
- // verifiable published authority — it IS the stuck state we must be able to
- // clear — so it is discardable without force. A loadable state is refused when
- // any slice has been published, unless force is set.
- published := []string{}
- if state, loadErr := LoadDeliveryState(repo, feature); loadErr == nil {
- for _, slice := range state.Slices {
- if strings.TrimSpace(slice.PRState) != "" {
- published = append(published, slice.ID)
- }
- }
- }
- if len(published) > 0 && !force {
- return DiscardDeliveryResult{
- Feature: feature, Action: "refused", Published: published,
- Reason: "delivery has published slices; re-run with --force to discard published delivery state (git history and merged PRs are unaffected)",
- }, nil
- }
-
- // Archive rather than hard-delete so the discard is reversible. The archive
- // lives under a dotted sibling that the slug pattern skips, so it is never
- // re-scanned as a live delivery. Collision handling is deterministic (no
- // clock/rng) so the actuator is replayable.
- archiveDir := filepath.Join(filepath.Dir(featureDir), ".discarded")
- destination := filepath.Join(archiveDir, feature)
- for suffix := 2; ; suffix++ {
- if _, statErr := os.Stat(destination); os.IsNotExist(statErr) {
- break
- } else if statErr != nil {
- return DiscardDeliveryResult{}, statErr
- }
- destination = filepath.Join(archiveDir, fmt.Sprintf("%s-%d", feature, suffix))
- }
- if err := os.MkdirAll(archiveDir, 0o755); err != nil {
- return DiscardDeliveryResult{}, err
- }
- engagement := ResolveEngagement(repo, EngagementRequest{})
- if err := os.Rename(featureDir, destination); err != nil {
- return DiscardDeliveryResult{}, err
- }
- if engagement.Mode == EngagementActive && engagement.Feature == feature {
- if err := clearEngagementLease(repo); err != nil {
- return DiscardDeliveryResult{}, err
- }
- }
- return DiscardDeliveryResult{
- Feature: feature, Action: "discarded",
- ArchivePath: ".git/boatstack/deliveries/.discarded/" + filepath.Base(destination),
- Reason: "managed delivery state archived; the feature can be rebuilt or re-planned",
- }, nil
-}
diff --git a/boatstack/delivery_boundary_conformance_test.go b/boatstack/delivery_boundary_conformance_test.go
deleted file mode 100644
index 1a27190..0000000
--- a/boatstack/delivery_boundary_conformance_test.go
+++ /dev/null
@@ -1,412 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-// This file holds boundary-conformance tests for two control laws (see
-// AGENTS.md "Boundary Conformance Requirement"):
-//
-// control-law: stale-delivery-cannot-block-unrelated-feature
-// A stale/invalid delivery in the shared store must never escalate into a
-// repo-wide block on resolution of an unrelated delivery. This holds at EVERY
-// read-only resolution boundary — ResolveNext (new work) and ResolveRecovery
-// (recovering an existing delivery) alike: each partitions the store instead
-// of failing closed and applies the ignored-deliveries filter BEFORE
-// invalidity becomes fatal, blocking only on a still-unignored invalid
-// delivery. The mutation boundary (ActiveManagedDeliveries) stays fail-closed.
-//
-// control-law: discard-preserves-published-authority
-// A delivery bearing published authority (any slice with a recorded PRState)
-// is not discardable without an explicit force override; a discard archives
-// (never hard-deletes) the state and never touches git-tracked artifacts.
-
-// writeInvalidDelivery plants a structurally-malformed state.json in the store so
-// LoadDeliveryState fails for that slug — modelling a corrupt or partially
-// written delivery left behind by an interrupted run.
-func writeInvalidDelivery(t *testing.T, repo, feature string) string {
- t.Helper()
- path, err := deliveryStatePath(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("{ this is not valid delivery state"), 0o644); err != nil {
- t.Fatal(err)
- }
- return path
-}
-
-// writePublishedDelivery plants a valid delivery whose single slice carries a
-// recorded PRState — i.e. it bears published authority.
-func writePublishedDelivery(t *testing.T, repo, feature, prState string) {
- t.Helper()
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: "hash",
- ActiveIndex: 1,
- Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "PUBLISHED", PRState: prState}},
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-// ---- control-law: stale-delivery-cannot-block-unrelated-feature ----
-
-// Negative + relation conformance: an unignored invalid delivery blocks, but the
-// block names exactly the offending delivery and routes to the discard-delivery
-// remedy (not the opaque repair-state) — request -> boundary -> decision.
-func TestResolveNextInvalidDeliveryBlocksWithDiscardRemedy(t *testing.T) {
- repo := nextTestRepo(t)
- writeInvalidDelivery(t, repo, "stale-one")
- writeSavedFeaturePlan(t, repo, "new-feature")
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" {
- t.Fatalf("invalid delivery did not block: %+v", status)
- }
- if status.NextOperation != "discard-delivery" {
- t.Fatalf("block did not route to discard-delivery remedy: %+v", status)
- }
- found := false
- for _, slug := range status.BlockingAmbiguity {
- if slug == "stale-one" {
- found = true
- }
- }
- if !found {
- t.Fatalf("block did not name the offending delivery: %+v", status.BlockingAmbiguity)
- }
-}
-
-// Positive + bypass conformance: an IGNORED invalid delivery no longer blocks;
-// the unrelated new feature resolves. This is the core fix — the ignore filter
-// runs before invalidity can become fatal, so one stale delivery cannot poison
-// resolution of distinct work.
-func TestResolveNextIgnoredInvalidDeliveryDoesNotBlockNewFeature(t *testing.T) {
- repo := nextTestRepo(t)
- writeInvalidDelivery(t, repo, "stale-one")
- writeSavedFeaturePlan(t, repo, "new-feature")
-
- if _, err := IgnoreDelivery(repo, "stale-one"); err != nil {
- t.Fatal(err)
- }
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "VERIFIED" || status.Feature != "new-feature" ||
- status.ObservedStage != "DRAFT_PLAN" || status.NextOperation != "plan-gate" {
- t.Fatalf("ignored invalid delivery still blocked the new feature: %+v", status)
- }
-}
-
-// Bypass conformance (enforce at the correct boundary): the tolerance is scoped
-// to the read-only ResolveNext boundary. The strict mutation-path enumeration
-// still fails closed on the same invalid delivery even when it is ignored, so
-// corrupt state cannot be laundered into a mutation via the ignore list.
-func TestActiveManagedDeliveriesStaysFailClosedOnInvalid(t *testing.T) {
- repo := nextTestRepo(t)
- writeInvalidDelivery(t, repo, "stale-one")
- if _, err := IgnoreDelivery(repo, "stale-one"); err != nil {
- t.Fatal(err)
- }
- if _, err := ActiveManagedDeliveries(repo); err == nil {
- t.Fatal("strict ActiveManagedDeliveries did not fail closed on invalid delivery state")
- }
-}
-
-// Negative conformance for
-// control-law: ambient-plans-never-activate-workflow-control. Invalid delivery
-// observations remain actionable through ResolveNext, but they do not acquire
-// ambient authority over unrelated product tools.
-func TestInvalidUnselectedDeliveryDoesNotBlockOrdinaryMutation(t *testing.T) {
- repo := nextTestRepo(t)
- writeInvalidDelivery(t, repo, "stale-one")
- if _, err := IgnoreDelivery(repo, "stale-one"); err != nil {
- t.Fatal(err)
- }
- if finding, blocked := preActivationFinding(repo, "product.go"); blocked {
- t.Fatalf("invalid unselected delivery controlled an ordinary product path: %+v", finding)
- }
- // The bounded recovery remains available when Boatstack is explicitly used.
- if _, err := DiscardDelivery(repo, "stale-one", true); err != nil {
- t.Fatalf("discard-delivery refused the invalid delivery it was prescribed for: %v", err)
- }
- if _, stillBlocked := preActivationFinding(repo, "product.go"); stillBlocked {
- t.Fatal("mutation still blocked after discarding the invalid delivery")
- }
-}
-
-// Failure-state conformance: ResolveNext is read-only. A blocking decision must
-// leave the offending state file byte-for-byte unchanged (no partial repair).
-func TestResolveNextLeavesInvalidStateUntouched(t *testing.T) {
- repo := nextTestRepo(t)
- statePath := writeInvalidDelivery(t, repo, "stale-one")
- before, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := ResolveNext(repo, ""); err != nil {
- t.Fatal(err)
- }
- after, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatalf("read-only resolution mutated the invalid state file\nbefore=%s\nafter=%s", before, after)
- }
-}
-
-// The same law holds at the OTHER read-only resolution boundary: ResolveRecovery.
-// ResolveNext resolves new work; ResolveRecovery resolves an existing delivery
-// that hit a problem. Both scan the shared store, so both must tolerate an
-// unrelated stale delivery. These tests are the recovery-boundary twins of the
-// ResolveNext cases above — the defect that motivated generalizing the law was
-// that recovery had none of them and fell through to a repo-wide block.
-
-// Positive + bypass conformance: an IGNORED invalid delivery no longer poisons
-// recovery of an unrelated healthy delivery on the current branch. The ignore
-// filter runs before invalidity can become fatal, so recovery selects and routes
-// the real target instead of blocking on abandoned state.
-func TestResolveRecoveryIgnoredInvalidDeliveryDoesNotBlockHealthyBranch(t *testing.T) {
- repo := nextTestRepo(t)
- branch, _ := gitCommand(repo, "branch", "--show-current")
-
- writeNextDelivery(t, repo, "healthy-feature", "BUILD", 0)
- updateRecoveryDelivery(t, repo, "healthy-feature", branch, "", "")
-
- writeInvalidDelivery(t, repo, "stale-one")
- if _, err := IgnoreDelivery(repo, "stale-one"); err != nil {
- t.Fatal(err)
- }
-
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the test failed", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "VERIFIED" || status.Feature != "healthy-feature" ||
- status.Lifecycle != "ACTIVE" || status.NextOperation != "repair_active" {
- t.Fatalf("ignored invalid delivery poisoned recovery of an unrelated healthy branch: %#v", status)
- }
-}
-
-// Negative + relation conformance: a still-unignored invalid delivery does block
-// recovery, but the block names exactly the offending delivery and routes to the
-// discard-delivery remedy — request -> boundary -> decision.
-func TestResolveRecoveryUnignoredInvalidDeliveryBlocksWithDiscardRemedy(t *testing.T) {
- repo := nextTestRepo(t)
- branch, _ := gitCommand(repo, "branch", "--show-current")
- writeNextDelivery(t, repo, "healthy-feature", "BUILD", 0)
- updateRecoveryDelivery(t, repo, "healthy-feature", branch, "", "")
- writeInvalidDelivery(t, repo, "stale-one")
-
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the test failed", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.NextOperation != "discard-delivery" {
- t.Fatalf("unignored invalid delivery did not block with discard remedy: %#v", status)
- }
- found := false
- for _, slug := range status.Blockers {
- if slug == "stale-one" {
- found = true
- }
- }
- if !found {
- t.Fatalf("block did not name the offending delivery: %#v", status.Blockers)
- }
-}
-
-// Failure-state conformance: ResolveRecovery is read-only. A blocking decision on
-// an invalid delivery must leave the offending state file byte-for-byte unchanged.
-func TestResolveRecoveryLeavesInvalidStateUntouched(t *testing.T) {
- repo := nextTestRepo(t)
- statePath := writeInvalidDelivery(t, repo, "stale-one")
- before, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "boom", SourceStage: "ci"}); err != nil {
- t.Fatal(err)
- }
- after, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatalf("read-only recovery mutated the invalid state file\nbefore=%s\nafter=%s", before, after)
- }
-}
-
-// ---- control-law: discard-preserves-published-authority ----
-
-// Positive + relation conformance: an unpublished delivery is discardable; the
-// live state directory is gone, an archive exists, the store no longer surfaces
-// it, and an unrelated new feature then resolves cleanly.
-// request -> boundary -> effect -> resulting state.
-func TestDiscardDeliveryUnpublishedIsRemovedAndUnblocks(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "stale-active", "BUILD", 0)
- statePath, err := deliveryStatePath(repo, "stale-active")
- if err != nil {
- t.Fatal(err)
- }
- featureDir := filepath.Dir(statePath)
-
- result, err := DiscardDelivery(repo, "stale-active", false)
- if err != nil {
- t.Fatal(err)
- }
- if result.Action != "discarded" {
- t.Fatalf("unpublished delivery was not discarded: %+v", result)
- }
- if _, statErr := os.Stat(featureDir); !os.IsNotExist(statErr) {
- t.Fatalf("live delivery directory still present after discard: %v", statErr)
- }
- // Effect: the archive exists and the store no longer surfaces the delivery.
- archive := filepath.Join(filepath.Dir(featureDir), ".discarded", "stale-active")
- if _, statErr := os.Stat(archive); statErr != nil {
- t.Fatalf("discard did not archive the state: %v", statErr)
- }
- active, err := ActiveManagedDeliveries(repo)
- if err != nil || len(active) != 0 {
- t.Fatalf("discarded delivery still active: %#v %v", active, err)
- }
-
- // Resulting state: a fresh feature now resolves without interference.
- writeSavedFeaturePlan(t, repo, "new-feature")
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.Feature != "new-feature" || status.NextOperation != "plan-gate" {
- t.Fatalf("new feature did not resolve after discard: %+v", status)
- }
-}
-
-// Bypass conformance: the archive lives under a dotted sibling the slug pattern
-// skips, so a discarded delivery can never re-enter through the live scan.
-func TestDiscardedDeliveryIsNotRescanned(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "stale-active", "BUILD", 0)
- if _, err := DiscardDelivery(repo, "stale-active", false); err != nil {
- t.Fatal(err)
- }
- active, invalid, err := scanManagedDeliveries(repo)
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 0 || len(invalid) != 0 {
- t.Fatalf("archived delivery leaked back into the scan: active=%#v invalid=%#v", active, invalid)
- }
-}
-
-// Negative + failure-state conformance: a published delivery is refused without
-// force, and the refusal leaves the live state directory unchanged (the effect
-// is not partially applied).
-func TestDiscardDeliveryPublishedRefusedWithoutForce(t *testing.T) {
- repo := nextTestRepo(t)
- writePublishedDelivery(t, repo, "shipped-feature", "OPEN")
- statePath, err := deliveryStatePath(repo, "shipped-feature")
- if err != nil {
- t.Fatal(err)
- }
- before, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
-
- result, err := DiscardDelivery(repo, "shipped-feature", false)
- if err != nil {
- t.Fatal(err)
- }
- if result.Action != "refused" {
- t.Fatalf("published delivery was not refused: %+v", result)
- }
- if len(result.Published) == 0 {
- t.Fatalf("refusal did not report the published slices: %+v", result)
- }
- // Failure-state: state untouched.
- after, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatalf("refused discard removed the live state: %v", err)
- }
- if string(before) != string(after) {
- t.Fatalf("refused discard mutated the live state\nbefore=%s\nafter=%s", before, after)
- }
-}
-
-// Positive (override) conformance: the authorized actor may discard a published
-// delivery with an explicit force override.
-func TestDiscardDeliveryPublishedForced(t *testing.T) {
- repo := nextTestRepo(t)
- writePublishedDelivery(t, repo, "shipped-feature", "OPEN")
-
- result, err := DiscardDelivery(repo, "shipped-feature", true)
- if err != nil {
- t.Fatal(err)
- }
- if result.Action != "discarded" {
- t.Fatalf("forced discard of published delivery did not succeed: %+v", result)
- }
-}
-
-// Negative conformance: a malformed request (invalid slug) is rejected, and a
-// request for a delivery that does not exist is a no-op — never a spurious effect.
-func TestDiscardDeliveryRejectsBadRequests(t *testing.T) {
- repo := nextTestRepo(t)
- if _, err := DiscardDelivery(repo, "Not A Slug", false); err == nil {
- t.Fatal("discard accepted an invalid feature slug")
- }
- result, err := DiscardDelivery(repo, "never-existed", false)
- if err != nil {
- t.Fatal(err)
- }
- if result.Action != "none" {
- t.Fatalf("discard of a nonexistent delivery was not a no-op: %+v", result)
- }
-}
-
-// Idempotency + reversal conformance: discarding twice does not duplicate the
-// effect (second call is a no-op), and re-planting then discarding the same slug
-// archives to a deterministic, collision-free location — the discard is
-// reversible and replayable.
-func TestDiscardDeliveryIdempotentAndCollisionFree(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "stale-active", "BUILD", 0)
-
- first, err := DiscardDelivery(repo, "stale-active", false)
- if err != nil || first.Action != "discarded" {
- t.Fatalf("first discard failed: %+v %v", first, err)
- }
- second, err := DiscardDelivery(repo, "stale-active", false)
- if err != nil {
- t.Fatal(err)
- }
- if second.Action != "none" {
- t.Fatalf("repeating discard duplicated the effect: %+v", second)
- }
-
- // Re-plant the same slug and discard again — the archive must not collide.
- writeNextDelivery(t, repo, "stale-active", "BUILD", 0)
- third, err := DiscardDelivery(repo, "stale-active", false)
- if err != nil || third.Action != "discarded" {
- t.Fatalf("third discard failed: %+v %v", third, err)
- }
- if first.ArchivePath == third.ArchivePath {
- t.Fatalf("second archive collided with the first: %s", third.ArchivePath)
- }
- if _, statErr := os.Stat(filepath.Join(repo, ".git", "boatstack", "deliveries", ".discarded", "stale-active-2")); statErr != nil {
- t.Fatalf("collision-free archive not created deterministically: %v", statErr)
- }
-}
diff --git a/boatstack/delivery_migrate.go b/boatstack/delivery_migrate.go
deleted file mode 100644
index 85f41e8..0000000
--- a/boatstack/delivery_migrate.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-)
-
-// deliveryStateMigration upgrades a managed delivery-state document one schema
-// version forward. `from` and `to` bound the step; `apply` transforms the decoded
-// document. The registered set is empty today — schema version 1 is the only
-// version that has ever been written — so migration is a behavior-preserving
-// pass-through. It exists so a future deliveryStateSchemaVersion bump has a
-// defined, tested upgrade path instead of silently failing old state closed.
-type deliveryStateMigration struct {
- from int
- to int
- apply func(map[string]any) (map[string]any, error)
-}
-
-// deliveryStateMigrations is the ordered, one-step-at-a-time upgrade chain for the
-// managed delivery state. Schema v2 maps the legacy global repair attempt to every
-// typed counter so old exhaustion cannot create fresh authority.
-var deliveryStateMigrations = []deliveryStateMigration{
- {
- from: 1, to: 2,
- apply: func(value map[string]any) (map[string]any, error) {
- attempt := intValue(value["repair_attempt"])
- value["repair_counters"] = map[string]any{
- "implementation_repair": attempt,
- "verification_repair": attempt,
- "review_repair": attempt,
- }
- delete(value, "repair_attempt")
- return value, nil
- },
- },
-}
-
-// migrateDeliveryStateBytes upgrades raw managed-delivery-state JSON to the current
-// deliveryStateSchemaVersion using the registered migration chain. It is the
-// production wrapper over the pure migrateDeliveryStateBytesWith.
-func migrateDeliveryStateBytes(raw []byte) (upgraded []byte, changed bool, err error) {
- return migrateDeliveryStateBytesWith(raw, deliveryStateMigrations, deliveryStateSchemaVersion)
-}
-
-// migrateDeliveryStateBytesWith is the pure migration engine, taking its migration
-// chain and target version as parameters so it is testable independently of the
-// production (empty) chain. It mirrors MigrateConfigBytes exactly:
-//
-// - blank input is a no-op pass-through (nothing to migrate);
-// - a document already at the target version passes through byte-for-byte
-// unchanged (the only path that runs today at version 1);
-// - an older document is walked forward one registered step at a time and its
-// schema_version stamped to the target;
-// - a document written by a NEWER Boatstack fail-closes with an actionable
-// message rather than being silently treated as corrupt;
-// - a missing step in the chain fail-closes rather than guessing.
-func migrateDeliveryStateBytesWith(raw []byte, migrations []deliveryStateMigration, target int) (upgraded []byte, changed bool, err error) {
- if len(strings.TrimSpace(string(raw))) == 0 {
- return raw, false, nil
- }
-
- var partial map[string]any
- if err := json.Unmarshal(raw, &partial); err != nil {
- return nil, false, fmt.Errorf("failed to parse delivery state JSON: %w", err)
- }
-
- fromVer := 1 // a document with no schema_version predates versioning: treat as v1.
- if v, ok := partial["schema_version"]; ok {
- switch val := v.(type) {
- case float64:
- fromVer = int(val)
- case int:
- fromVer = val
- default:
- return nil, false, fmt.Errorf("delivery state schema_version must be an integer")
- }
- }
-
- if fromVer > target {
- return nil, false, fmt.Errorf("managed delivery state was written by a newer Boatstack (schema %d > %d); update Boatstack", fromVer, target)
- }
- if fromVer == target {
- return raw, false, nil
- }
-
- current := fromVer
- data := partial
- for current < target {
- var found *deliveryStateMigration
- for i := range migrations {
- if migrations[i].from == current {
- found = &migrations[i]
- break
- }
- }
- if found == nil {
- return nil, false, fmt.Errorf("no delivery-state migration found from schema %d toward %d", current, target)
- }
- if found.to <= current {
- return nil, false, fmt.Errorf("invalid delivery-state migration path from %d to %d", found.from, found.to)
- }
- data, err = found.apply(data)
- if err != nil {
- return nil, false, fmt.Errorf("failed to apply delivery-state migration from %d to %d: %w", found.from, found.to, err)
- }
- current = found.to
- }
-
- data["schema_version"] = target
- upgraded, err = MarshalJSON(data)
- if err != nil {
- return nil, false, fmt.Errorf("failed to marshal migrated delivery state: %w", err)
- }
- return upgraded, true, nil
-}
diff --git a/boatstack/delivery_migrate_conformance_test.go b/boatstack/delivery_migrate_conformance_test.go
deleted file mode 100644
index 91c76f0..0000000
--- a/boatstack/delivery_migrate_conformance_test.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "strings"
- "testing"
-)
-
-// control-law: delivery-state-migrates-forward-or-fails-closed
-//
-// The managed delivery state carries a schema version. The migration hook upgrades
-// an older document forward through a registered one-step chain and stamps it to the
-// target (Positive/Relation); a document already at the target passes through
-// byte-for-byte unchanged so today's behavior is preserved exactly (Negative); a
-// document written by a NEWER Boatstack, or one with a gap in the chain, fails
-// closed with an actionable error rather than being loaded or silently upgraded
-// (Bypass/Failure-state). Blank input is a no-op.
-
-// bumpSchema is a synthetic one-step migration (v1 -> v2) used only by these tests,
-// so the forward-walk is exercised without a real schema bump.
-func bumpSchema() []deliveryStateMigration {
- return []deliveryStateMigration{{
- from: 1, to: 2,
- apply: func(m map[string]any) (map[string]any, error) {
- m["migrated_marker"] = true
- return m, nil
- },
- }}
-}
-
-// Negative: a document already at the target version is returned byte-for-byte
-// unchanged — the production path at schema version 1 must not rewrite state.
-func TestMigrateDeliveryStateCurrentIsPassThrough(t *testing.T) {
- raw := []byte(`{"schema_version":1,"feature":"demo"}`)
- out, changed, err := migrateDeliveryStateBytesWith(raw, bumpSchema(), 1)
- if err != nil {
- t.Fatalf("current-version document must not error: %v", err)
- }
- if changed {
- t.Error("a current-version document must report no change")
- }
- if string(out) != string(raw) {
- t.Errorf("current-version document must pass through unchanged; got %s", out)
- }
-}
-
-// Positive + Relation: an older document is walked forward through the registered
-// step, the migration's transform is applied, and schema_version is stamped to the
-// target.
-func TestMigrateDeliveryStateWalksForward(t *testing.T) {
- raw := []byte(`{"schema_version":1,"feature":"demo"}`)
- out, changed, err := migrateDeliveryStateBytesWith(raw, bumpSchema(), 2)
- if err != nil {
- t.Fatalf("forward migration must succeed: %v", err)
- }
- if !changed {
- t.Error("a forward migration must report a change")
- }
- var got map[string]any
- if err := json.Unmarshal(out, &got); err != nil {
- t.Fatalf("migrated output must be valid JSON: %v", err)
- }
- if v, _ := got["schema_version"].(float64); int(v) != 2 {
- t.Errorf("schema_version must be stamped to the target 2, got %v", got["schema_version"])
- }
- if marker, _ := got["migrated_marker"].(bool); !marker {
- t.Error("the migration's transform must have been applied")
- }
-}
-
-// Bypass: a document written by a newer Boatstack is never loaded or downgraded —
-// it fails closed with a message that tells the operator to update.
-func TestMigrateDeliveryStateNewerFailsClosed(t *testing.T) {
- raw := []byte(`{"schema_version":99,"feature":"demo"}`)
- _, _, err := migrateDeliveryStateBytesWith(raw, bumpSchema(), 1)
- if err == nil {
- t.Fatal("a newer-schema document must fail closed, not load")
- }
- if !strings.Contains(err.Error(), "newer Boatstack") {
- t.Errorf("error must point at updating Boatstack, got %q", err)
- }
-}
-
-// Failure-state: a gap in the migration chain fails closed rather than guessing a
-// path forward.
-func TestMigrateDeliveryStateMissingStepFailsClosed(t *testing.T) {
- raw := []byte(`{"schema_version":1,"feature":"demo"}`)
- _, _, err := migrateDeliveryStateBytesWith(raw, nil, 3) // no migrations registered
- if err == nil {
- t.Fatal("a missing migration step must fail closed")
- }
- if !strings.Contains(err.Error(), "no delivery-state migration") {
- t.Errorf("error must name the missing step, got %q", err)
- }
-}
-
-// Blank input is a no-op pass-through — there is nothing to migrate.
-func TestMigrateDeliveryStateBlankIsNoOp(t *testing.T) {
- out, changed, err := migrateDeliveryStateBytesWith([]byte(" "), bumpSchema(), 2)
- if err != nil || changed {
- t.Fatalf("blank input must be a silent no-op; changed=%v err=%v", changed, err)
- }
- if strings.TrimSpace(string(out)) != "" {
- t.Errorf("blank input must pass through, got %q", out)
- }
-}
diff --git a/boatstack/delivery_reactivation_test.go b/boatstack/delivery_reactivation_test.go
deleted file mode 100644
index 7b94441..0000000
--- a/boatstack/delivery_reactivation_test.go
+++ /dev/null
@@ -1,318 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// publishedThenBuilding is the canonical partially-delivered state the incident
-// hit: slice "a" shipped (PR open or merged), the pointer advanced, slice "b" is
-// mid-build. pr_state is a parameter because real projects were observed to leave
-// it empty even on merged PRs, so the fix must key "published" off the pointer and
-// Status, never pr_state.
-func publishedThenBuilding(prState string) DeliveryState {
- return DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion,
- Feature: "f",
- PlanLockHash: "old-lock",
- ActiveIndex: 1,
- Slices: []DeliverySlice{
- {ID: "a", Title: "First", Status: "PUBLISHED", PRURL: "https://x/pr/1", PRState: prState, TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}},
- {ID: "b", Title: "Second", Status: "TEST_PASSED", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go"}},
- },
- }
-}
-
-func TestValidateAmendmentPreservesProgressBoundaries(t *testing.T) {
- base := publishedThenBuilding("OPEN")
-
- t.Run("tail-only amendment is allowed", func(t *testing.T) {
- newSlices := []DeliverySlice{
- {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}},
- {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}},
- }
- if err := validateAmendmentPreservesProgress(base, newSlices); err != nil {
- t.Fatalf("widening the building tail slice must be allowed: %v", err)
- }
- })
-
- t.Run("nothing published yet is always allowed", func(t *testing.T) {
- fresh := base
- fresh.ActiveIndex = 0
- newSlices := []DeliverySlice{{ID: "z", TaskIDs: []string{"T-9"}, AffectedPaths: []string{"z.go"}}}
- if err := validateAmendmentPreservesProgress(fresh, newSlices); err != nil {
- t.Fatalf("pre-publication amendment must be allowed: %v", err)
- }
- })
-
- t.Run("changing a published slice is refused", func(t *testing.T) {
- newSlices := []DeliverySlice{
- {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go", "a-widened.go"}},
- {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go"}},
- }
- err := validateAmendmentPreservesProgress(base, newSlices)
- if err == nil || !strings.Contains(err.Error(), "changes published delivery slice a") || !strings.Contains(err.Error(), "corrective child") {
- t.Fatalf("changing a published slice must route to a corrective child: %v", err)
- }
- })
-
- t.Run("renaming a published slice is refused", func(t *testing.T) {
- newSlices := []DeliverySlice{
- {ID: "renamed", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}},
- {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go"}},
- }
- err := validateAmendmentPreservesProgress(base, newSlices)
- if err == nil || !strings.Contains(err.Error(), "reorders or renames published delivery slice a") {
- t.Fatalf("renaming a published slice must be refused: %v", err)
- }
- })
-
- t.Run("dropping a published slice is refused", func(t *testing.T) {
- newSlices := []DeliverySlice{}
- err := validateAmendmentPreservesProgress(base, newSlices)
- if err == nil || !strings.Contains(err.Error(), "drops published delivery slice a") {
- t.Fatalf("dropping a published slice must be refused: %v", err)
- }
- })
-
- t.Run("a fully published delivery stays immutable", func(t *testing.T) {
- done := base
- done.ActiveIndex = len(done.Slices)
- err := validateAmendmentPreservesProgress(done, base.Slices)
- if err == nil || !strings.Contains(err.Error(), "is immutable") {
- t.Fatalf("fully published delivery must remain immutable: %v", err)
- }
- })
-}
-
-// TestReconcileAmendedDeliveryStatePreservesPrefixAndPointer is the heart of the
-// fix: the published prefix (and everything about it) survives verbatim, the
-// pointer does not move, and only the tail adopts the amended definitions — a
-// newly added tail slice appears PENDING.
-func TestReconcileAmendedDeliveryStatePreservesPrefixAndPointer(t *testing.T) {
- existing := publishedThenBuilding("MERGED")
- newSlices := []DeliverySlice{
- {ID: "a", TaskIDs: []string{"T-1"}, AffectedPaths: []string{"a.go"}}, // published prefix, unchanged def
- {ID: "b", TaskIDs: []string{"T-2"}, AffectedPaths: []string{"b.go", "b-extra.go"}}, // widened building slice
- {ID: "c", TaskIDs: []string{"T-3"}, AffectedPaths: []string{"c.go"}}, // freshly added tail slice
- }
-
- result := reconcileAmendedDeliveryState(existing, newSlices, "new-lock")
-
- if result.ActiveIndex != 1 {
- t.Fatalf("reconcile moved the BUILD pointer: ActiveIndex=%d want 1", result.ActiveIndex)
- }
- // Published prefix preserved verbatim, including PR bookkeeping.
- if got := result.Slices[0]; got.ID != "a" || got.Status != "PUBLISHED" || got.PRURL != "https://x/pr/1" || got.PRState != "MERGED" {
- t.Fatalf("published prefix not preserved: %#v", got)
- }
- // Building slice adopts the amended definition and restarts at BUILD.
- if got := result.Slices[1]; got.ID != "b" || got.Status != "BUILD" || !equalStrings(got.AffectedPaths, []string{"b.go", "b-extra.go"}) {
- t.Fatalf("building slice not reconciled: %#v", got)
- }
- // New tail slice is PENDING.
- if got := result.Slices[2]; got.ID != "c" || got.Status != "PENDING" {
- t.Fatalf("new tail slice not appended as PENDING: %#v", got)
- }
- if result.PlanLockHash != "new-lock" {
- t.Fatalf("plan lock hash not updated: %q", result.PlanLockHash)
- }
- if !equalStrings(result.PreviousPlanLocks, []string{"old-lock"}) {
- t.Fatalf("previous plan lock not recorded: %#v", result.PreviousPlanLocks)
- }
-}
-
-func markPrefixPublished(t *testing.T, repo, feature, sliceID, url, prState string) {
- t.Helper()
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- found := -1
- for i := range state.Slices {
- if state.Slices[i].ID == sliceID {
- found = i
- }
- }
- if found < 0 {
- t.Fatalf("slice %s not found in state", sliceID)
- }
- state.Slices[found].Status = "PUBLISHED"
- state.Slices[found].PRURL = url
- state.Slices[found].PRState = prState
- state.ActiveIndex = found + 1
- if state.ActiveIndex < len(state.Slices) {
- state.Slices[state.ActiveIndex].Status = "BUILD"
- }
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
-}
-
-func reactivateWithAmendedPlan(t *testing.T, repo, feature string, mutate func(plan map[string]any)) error {
- t.Helper()
- dir := filepath.Join(repo, ".product-loop", "features", feature)
- plan := twoSlicePlan()
- plan["feature_id"] = feature
- plan["spec_path"] = "feature-spec.md"
- if mutate != nil {
- mutate(plan)
- }
- planPath := filepath.Join(dir, "plan.md")
- writeMarkdownPlan(t, planPath, plan, true)
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatalf("amended plan is invalid: %v", err)
- }
- approvalPath := filepath.Join(dir, "approval.md")
- writeApprovalReceipt(t, approvalPath, check.Fingerprint)
- return ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: planPath,
- ApprovalPath: approvalPath,
- OutDir: filepath.Join(dir, "compiled"),
- OutputPath: filepath.Join(dir, "plan.lock.json"),
- SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
- })
-}
-
-// TestReactivationPreservesPublishedPrefixThroughActivatePlan reproduces the
-// exact incident end to end: slice one is published/merged, the pointer is at
-// slice two, and an approved amendment widens slice two's scope. Before the fix,
-// re-activation reset the pointer to 0 and dropped slice one back to BUILD.
-func TestReactivationPreservesPublishedPrefixThroughActivatePlan(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- markPrefixPublished(t, repo, feature, "phase-one", "https://example.invalid/pr/328", "MERGED")
-
- err := reactivateWithAmendedPlan(t, repo, feature, func(plan map[string]any) {
- second := plan["tasks"].([]any)[1].(map[string]any)
- second["affected_paths"] = []any{"second.go", "second_extra.go"}
- })
- if err != nil {
- t.Fatalf("amending the building tail slice must succeed: %v", err)
- }
-
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if state.ActiveIndex != 1 {
- t.Fatalf("re-activation reset the pointer: ActiveIndex=%d want 1", state.ActiveIndex)
- }
- if s := state.Slices[0]; s.ID != "phase-one" || s.Status != "PUBLISHED" || s.PRURL != "https://example.invalid/pr/328" || s.PRState != "MERGED" {
- t.Fatalf("published+merged slice one was not preserved: %#v", s)
- }
- if s := state.Slices[1]; s.ID != "phase-two" || s.Status != "BUILD" {
- t.Fatalf("building slice two not preserved as active: %#v", s)
- }
- if !containsString(state.Slices[1].AffectedPaths, "second_extra.go") {
- t.Fatalf("amended scope not adopted by the tail slice: %#v", state.Slices[1].AffectedPaths)
- }
- if len(state.PreviousPlanLocks) != 1 {
- t.Fatalf("previous plan lock not recorded on amendment: %#v", state.PreviousPlanLocks)
- }
-}
-
-// TestReactivationRefusalLeavesPriorStateIntact proves the guard runs before the
-// transactional promote, so refusing to alter a published slice leaves both the
-// plan lock and the delivery state byte-for-byte unchanged (no half-apply).
-func TestReactivationRefusalLeavesPriorStateIntact(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- markPrefixPublished(t, repo, feature, "phase-one", "https://example.invalid/pr/328", "OPEN")
-
- dir := filepath.Join(repo, ".product-loop", "features", feature)
- lockPath := filepath.Join(dir, "plan.lock.json")
- statePath, err := deliveryStatePath(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- lockBefore, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- stateBefore, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
-
- err = reactivateWithAmendedPlan(t, repo, feature, func(plan map[string]any) {
- first := plan["tasks"].([]any)[0].(map[string]any)
- first["affected_paths"] = []any{"feature.go", "feature_widened.go"} // touches the PUBLISHED slice
- })
- if err == nil || !strings.Contains(err.Error(), "corrective child") {
- t.Fatalf("amending a published slice must be refused with a corrective-child directive: %v", err)
- }
-
- lockAfter, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- stateAfter, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if string(lockBefore) != string(lockAfter) {
- t.Fatalf("refused re-activation half-applied the plan lock:\nbefore=%s\nafter=%s", lockBefore, lockAfter)
- }
- if string(stateBefore) != string(stateAfter) {
- t.Fatalf("refused re-activation mutated delivery state:\nbefore=%s\nafter=%s", stateBefore, stateAfter)
- }
-}
-
-// TestReactivationIdempotentAndBenignPrePublish covers the two non-destructive
-// paths that must keep working: re-activating the identical plan is a no-op, and
-// amending before anything is published simply recomputes at the head.
-func TestReactivationIdempotentAndBenignPrePublish(t *testing.T) {
- t.Run("identical re-activation is a no-op", func(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- statePath, err := deliveryStatePath(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- before, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if err := reactivateWithAmendedPlan(t, repo, feature, nil); err != nil {
- t.Fatalf("identical re-activation errored: %v", err)
- }
- after, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatalf("identical re-activation changed state:\nbefore=%s\nafter=%s", before, after)
- }
- })
-
- t.Run("pre-publication amendment recomputes at the head", func(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- if err := reactivateWithAmendedPlan(t, repo, feature, func(plan map[string]any) {
- second := plan["tasks"].([]any)[1].(map[string]any)
- second["affected_paths"] = []any{"second.go", "second_extra.go"}
- }); err != nil {
- t.Fatalf("pre-publication amendment must succeed: %v", err)
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if state.ActiveIndex != 0 || state.Slices[0].Status != "BUILD" {
- t.Fatalf("pre-publication amendment mis-seated the head: %#v", state)
- }
- if !containsString(state.Slices[1].AffectedPaths, "second_extra.go") {
- t.Fatalf("amended tail scope not adopted: %#v", state.Slices[1].AffectedPaths)
- }
- })
-}
-
-func containsString(values []string, target string) bool {
- for _, value := range values {
- if value == target {
- return true
- }
- }
- return false
-}
diff --git a/boatstack/delivery_terminal.go b/boatstack/delivery_terminal.go
deleted file mode 100644
index 7fb2ac3..0000000
--- a/boatstack/delivery_terminal.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package boatstack
-
-import "strings"
-
-// The delivery terminal is the standing goal of the flow — the state past
-// which nothing more is owed. It resolves in a fixed order: the goal the
-// delivery was ACTIVATED under (state.Goal — hysteresis, so a mid-flight
-// config change never silently changes an in-progress delivery's goal), then
-// the repository config (delivery.terminal), then the published default.
-// Every unreadable or invalid input resolves to the narrower published goal:
-// a goal is widened only by an explicit, verifiable operator choice.
-// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
-type DeliveryTerminal string
-
-const (
- // TerminalPublished — the flow is done when the slice's PR is open.
- TerminalPublished DeliveryTerminal = "published"
- // TerminalMerged — the flow keeps naming read-only post-publish steps
- // until the PR is observed merged.
- TerminalMerged DeliveryTerminal = "merged"
-)
-
-func normalizeDeliveryTerminal(value string) (DeliveryTerminal, bool) {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case string(TerminalPublished):
- return TerminalPublished, true
- case string(TerminalMerged):
- return TerminalMerged, true
- default:
- return "", false
- }
-}
-
-// configuredDeliveryTerminal reads the repository's standing terminal from
-// the project config. Absent, invalid, or unreadable configuration resolves
-// to published — never an error, because the terminal is consulted from
-// read-only paths that must not gain a new failure mode.
-func configuredDeliveryTerminal(repo string) DeliveryTerminal {
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil || config.Delivery == nil {
- return TerminalPublished
- }
- if terminal, ok := normalizeDeliveryTerminal(config.Delivery.Terminal); ok {
- return terminal
- }
- return TerminalPublished
-}
-
-// resolveDeliveryTerminal resolves the terminal for one feature: the
-// activation snapshot first, then config, then the default.
-func resolveDeliveryTerminal(repo, feature string) DeliveryTerminal {
- if strings.TrimSpace(feature) != "" {
- if state, err := LoadDeliveryState(repo, feature); err == nil {
- if terminal, ok := normalizeDeliveryTerminal(state.Goal); ok {
- return terminal
- }
- }
- }
- return configuredDeliveryTerminal(repo)
-}
-
-// deliveryGoalSnapshot is what activation records on the new delivery state.
-// Only the non-default goal is snapshotted: a default-config delivery keeps
-// an empty Goal, so its persisted state is byte-identical to before this
-// field existed.
-func deliveryGoalSnapshot(repo string) string {
- if configuredDeliveryTerminal(repo) == TerminalMerged {
- return string(TerminalMerged)
- }
- return ""
-}
diff --git a/boatstack/delivery_terminal_conformance_test.go b/boatstack/delivery_terminal_conformance_test.go
deleted file mode 100644
index 49a7f0c..0000000
--- a/boatstack/delivery_terminal_conformance_test.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package boatstack
-
-// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
-//
-// The delivery terminal — the standing goal of the flow — resolves in a fixed
-// order: the goal the delivery was ACTIVATED under (state.Goal), then the
-// repository config (delivery.terminal), then the published default. The
-// default is a hard no-op: with no delivery block (or an explicit
-// "published"), every advisory output is identical to the pre-field
-// behavior, because a goal this standing is widened only by an explicit
-// operator choice, never by an upgrade. Invalid and unreadable inputs
-// resolve to the NARROWER published goal (fail-closed direction: the wider
-// goal implies more agent-owned steps).
-//
-// Test classes: positive (config merged → Terminal merged; activation
-// snapshots the non-default goal), relation (state.Goal overrides config both
-// ways — hysteresis), negative (invalid config value fails validation;
-// invalid state.Goal is ignored), bypass (default vs explicit published →
-// byte-identical rendering and JSON across the slice lifecycle), failure-state
-// (a pre-field state file without goal loads clean and resolves from config).
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func writeTerminalConfig(t *testing.T, repo, terminal string) {
- t.Helper()
- config := testConfig()
- if terminal != "" {
- config.Delivery = &DeliveryPolicy{Terminal: terminal}
- }
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-// Positive: the configured terminal surfaces on the advisory, and only the
-// widened goal earns a rendered line.
-func TestConfiguredTerminalSurfacesOnAdvisory(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "feature", "BUILD", 0)
- writeTerminalConfig(t, repo, "merged")
-
- next, err := NextControl(repo, "feature")
- if err != nil {
- t.Fatal(err)
- }
- if next.Terminal != TerminalMerged {
- t.Fatalf("terminal = %q, want merged", next.Terminal)
- }
- if !strings.Contains(FormatFlowNext(next), "Terminal goal: merged") {
- t.Fatal("widened goal must be visible in the rendering")
- }
-}
-
-// Relation: the activation snapshot outranks config in BOTH directions — a
-// delivery keeps the goal it was started under when config flips mid-flight.
-func TestActivationSnapshotOverridesConfig(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "feature", "BUILD", 0)
-
- // Delivery activated under merged; config later narrowed to published.
- state, err := LoadDeliveryState(repo, "feature")
- if err != nil {
- t.Fatal(err)
- }
- state.Goal = string(TerminalMerged)
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- writeTerminalConfig(t, repo, "published")
- if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged {
- t.Fatalf("mid-flight narrowing changed the goal: %q", got)
- }
-
- // Delivery activated under the default; config later widened to merged.
- // The empty snapshot means "resolve from config", so the widening applies.
- state.Goal = ""
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- writeTerminalConfig(t, repo, "merged")
- if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged {
- t.Fatalf("config terminal not hydrated: %q", got)
- }
-
- // An invalid snapshot value is ignored, never trusted.
- state.Goal = "deployed"
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- writeTerminalConfig(t, repo, "")
- if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalPublished {
- t.Fatalf("invalid snapshot must resolve to published: %q", got)
- }
-}
-
-// Positive: first activation snapshots the non-default goal onto the new
-// delivery state; the default snapshots nothing (byte-stable state files).
-func TestActivationSnapshotsNonDefaultGoalOnly(t *testing.T) {
- for _, test := range []struct {
- terminal string
- wantGoal string
- }{
- {"merged", "merged"},
- {"published", ""},
- {"", ""},
- } {
- repo := nextTestRepo(t)
- writeTerminalConfig(t, repo, test.terminal)
- if got := deliveryGoalSnapshot(repo); got != test.wantGoal {
- t.Fatalf("terminal %q: snapshot = %q, want %q", test.terminal, got, test.wantGoal)
- }
- }
-}
-
-// Negative: an explicit invalid enum fails config validation fail-closed.
-func TestInvalidTerminalRejectedByValidation(t *testing.T) {
- config := testConfig()
- config.Delivery = &DeliveryPolicy{Terminal: "deployed"}
- if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "delivery.terminal") {
- t.Fatalf("invalid terminal accepted: %v", err)
- }
- config.Delivery = &DeliveryPolicy{}
- if err := ValidateConfig(config); err != nil {
- t.Fatalf("empty terminal must stay legal: %v", err)
- }
-}
-
-// Bypass: the default is a hard no-op — for every slice-lifecycle stage, the
-// advisory under an absent delivery block is byte-identical (JSON and
-// rendering) to an explicit published terminal, and carries no merged
-// wording anywhere.
-func TestDefaultTerminalIsByteIdenticalToExplicitPublished(t *testing.T) {
- for _, stage := range []string{"BUILD", "TEST_PASSED", "REVIEW_PASSED", "PUBLISHED"} {
- capture := func(terminal string) (string, string) {
- repo := nextTestRepo(t)
- activeIndex := 0
- if stage == "PUBLISHED" {
- activeIndex = 1
- }
- writeNextDelivery(t, repo, "feature", stage, activeIndex)
- writeTerminalConfig(t, repo, terminal)
- if stage == "PUBLISHED" {
- updateRecoveryDelivery(t, repo, "feature", "feat/phase", "https://example.invalid/pr/9", "")
- withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPass))
- }
- next, err := NextControl(repo, "feature")
- if err != nil {
- t.Fatal(err)
- }
- // The repo path differs per fixture; blank it out of the compared
- // values so only behavior is compared. JSON escapes Windows path
- // separators, so the escaped form must be blanked too.
- value, err := MarshalJSON(next)
- if err != nil {
- t.Fatal(err)
- }
- escaped, err := json.Marshal(repo)
- if err != nil {
- t.Fatal(err)
- }
- blank := func(s string) string {
- s = strings.ReplaceAll(s, strings.Trim(string(escaped), `"`), "")
- return strings.ReplaceAll(s, repo, "")
- }
- return blank(string(value)), blank(FormatFlowNext(next))
- }
- defaultJSON, defaultText := capture("")
- publishedJSON, publishedText := capture("published")
- if defaultJSON != publishedJSON {
- t.Fatalf("stage %s: default and explicit published diverge:\n%s\n---\n%s", stage, defaultJSON, publishedJSON)
- }
- if defaultText != publishedText {
- t.Fatalf("stage %s: rendering diverges:\n%s\n---\n%s", stage, defaultText, publishedText)
- }
- if strings.Contains(defaultText, "merged (delivery.terminal)") {
- t.Fatalf("stage %s: default rendering mentions the widened goal:\n%s", stage, defaultText)
- }
- }
-}
-
-// Failure-state: a pre-field state file (no goal key) loads clean and
-// resolves from config — the migration law is untouched by the additive
-// field.
-func TestPreFieldStateResolvesFromConfig(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "feature", "BUILD", 0)
- statePath, err := deliveryStatePath(repo, "feature")
- if err != nil {
- t.Fatal(err)
- }
- raw, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if strings.Contains(string(raw), "\"goal\"") {
- t.Fatal("fixture unexpectedly contains a goal key")
- }
- writeTerminalConfig(t, repo, "merged")
- if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged {
- t.Fatalf("pre-field state did not hydrate from config: %q", got)
- }
- if _, err := LoadDeliveryState(repo, "feature"); err != nil {
- t.Fatalf("pre-field state failed to load: %v", err)
- }
-}
diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go
deleted file mode 100644
index f816c51..0000000
--- a/boatstack/delivery_test.go
+++ /dev/null
@@ -1,624 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "reflect"
- "strings"
- "testing"
-)
-
-func TestIgnoreDeliveryAppendsDedupsAndPreservesConfig(t *testing.T) {
- repo := nextTestRepo(t)
-
- added, err := IgnoreDelivery(repo, "old-feature")
- if err != nil {
- t.Fatal(err)
- }
- if !added {
- t.Fatal("first ignore-delivery should report the slug as newly added")
- }
-
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- if !reflect.DeepEqual(config.Workflow.IgnoredDeliveries, []string{"old-feature"}) {
- t.Fatalf("ignored delivery not persisted: %+v", config.Workflow.IgnoredDeliveries)
- }
- // Other config must survive the round-trip.
- if config.Project.Name != "fixture" || config.Workflow.HumanPlanApproval != true {
- t.Fatalf("ignore-delivery clobbered unrelated config: %+v", config)
- }
-
- added, err = IgnoreDelivery(repo, "old-feature")
- if err != nil {
- t.Fatal(err)
- }
- if added {
- t.Fatal("repeat ignore-delivery should be a no-op")
- }
- config, _, err = LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- if len(config.Workflow.IgnoredDeliveries) != 1 {
- t.Fatalf("ignore-delivery duplicated the slug: %+v", config.Workflow.IgnoredDeliveries)
- }
-}
-
-func twoSlicePlan() map[string]any {
- plan := validPlan()
- plan["acceptance_criteria"] = []any{
- map[string]any{"id": "AC-1", "text": "first observable result"},
- map[string]any{"id": "AC-2", "text": "second observable result"},
- }
- first := plan["tasks"].([]any)[0].(map[string]any)
- first["affected_paths"] = []any{"feature.go"}
- second := map[string]any{
- "id": "T-2", "title": "implement second result", "depends_on": []any{"T-1"},
- "acceptance_criteria": []any{"AC-2"}, "affected_paths": []any{"second.go"},
- "validation": []any{map[string]any{
- "criteria": []any{"AC-2"}, "run": "go test ./...", "origin": "AC-2",
- "oracle": "second contract assertion", "independence": "contract-derived",
- }},
- }
- plan["tasks"] = []any{first, second}
- plan["delivery_slices"] = []any{
- map[string]any{"id": "phase-one", "title": "First reviewer outcome", "task_ids": []any{"T-1"}},
- map[string]any{"id": "phase-two", "title": "Second reviewer outcome", "task_ids": []any{"T-2"}},
- }
- return plan
-}
-
-func TestDeliverySlicesPartitionTasksAndRejectForwardDependencies(t *testing.T) {
- plan := twoSlicePlan()
- if err := ValidatePlan(plan, nil); err != nil {
- t.Fatalf("valid two-slice plan rejected: %v", err)
- }
- plan["delivery_slices"].([]any)[1].(map[string]any)["task_ids"] = []any{"T-1", "T-2"}
- if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "assigned") {
- t.Fatalf("duplicate task assignment did not block: %v", err)
- }
- plan = twoSlicePlan()
- plan["tasks"].([]any)[0].(map[string]any)["depends_on"] = []any{"T-2"}
- plan["tasks"].([]any)[1].(map[string]any)["depends_on"] = []any{}
- if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "future slice") {
- t.Fatalf("forward delivery dependency did not block: %v", err)
- }
-}
-
-func activateTwoSliceDelivery(t *testing.T) (string, string) {
- return activateTwoSliceDeliveryConfigured(t, false, nil)
-}
-
-func activateTwoSliceDeliveryWithChangelog(t *testing.T, maintainChangelog bool) (string, string) {
- return activateTwoSliceDeliveryConfigured(t, maintainChangelog, nil)
-}
-
-func activateTwoSliceDeliveryConfigured(t *testing.T, maintainChangelog bool, configure func(*ProjectConfig)) (string, string) {
- t.Helper()
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.MaintainChangelog = maintainChangelog
- if configure != nil {
- configure(config)
- }
- })
- feature := "phased-feature"
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- plan := twoSlicePlan()
- if maintainChangelog {
- for _, task := range plan["tasks"].([]any) {
- item := task.(map[string]any)
- paths := item["affected_paths"].([]any)
- item["affected_paths"] = append(paths, changelogPath)
- }
- }
- plan["feature_id"] = feature
- plan["spec_path"] = "feature-spec.md"
- if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Two PR proposal\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "feature-spec.md"), []byte("# Accepted phased feature\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- planPath := filepath.Join(directory, "plan.md")
- writeMarkdownPlan(t, planPath, plan, true)
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- approvalPath := filepath.Join(directory, "approval.md")
- writeApprovalReceipt(t, approvalPath, check.Fingerprint)
- if err := ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: planPath, ApprovalPath: approvalPath, OutDir: filepath.Join(directory, "compiled"),
- OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
- }); err != nil {
- t.Fatal(err)
- }
- evidence := "# Evidence ledger\n\n- Test gate (phase-one): `PASS`\n- Review gate (phase-one): `PASS`\n- Test gate (phase-two): `BLOCKED`\n- Review gate (phase-two): `BLOCKED`\n"
- if err := os.WriteFile(filepath.Join(directory, "evidence.md"), []byte(evidence), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".product-loop/features/"+feature)
- runGit(t, repo, "commit", "-m", "activate phased delivery")
- return repo, feature
-}
-
-func TestDeliveryGatePoliciesControlGapsAndHighRiskReview(t *testing.T) {
- t.Run("gaps disabled", func(t *testing.T) {
- repo, feature := activateTwoSliceDeliveryConfigured(t, false, func(config *ProjectConfig) {
- config.Workflow.AllowPassWithGaps = false
- })
- evidencePath := filepath.Join(repo, ".product-loop", "features", feature, "evidence.md")
- value, err := os.ReadFile(evidencePath)
- if err != nil {
- t.Fatal(err)
- }
- value = []byte(strings.Replace(string(value), "Test gate (phase-one): `PASS`", "Test gate (phase-one): `PASS_WITH_GAPS`", 1))
- if err := os.WriteFile(evidencePath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- _, err = RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS_WITH_GAPS"})
- if err == nil || !strings.Contains(err.Error(), "allow_pass_with_gaps is false") {
- t.Fatalf("disabled gap policy did not block PASS_WITH_GAPS: %v", err)
- }
- })
-
- t.Run("high risk review", func(t *testing.T) {
- repo, feature := activateTwoSliceDeliveryConfigured(t, false, func(config *ProjectConfig) {
- config.Workflow.IndependentReviewForHighRisk = true
- })
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil {
- t.Fatal(err)
- }
- base := DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"}
- if _, err := RecordDeliveryGate(base); err == nil || !strings.Contains(err.Error(), "reviewer_identity") {
- t.Fatalf("high-risk review accepted missing provenance: %v", err)
- }
- base.ReviewerIdentity = "reviewer-2"
- base.ReviewMethod = "same_agent"
- if _, err := RecordDeliveryGate(base); err == nil || !strings.Contains(err.Error(), "human_peer or separate_agent") {
- t.Fatalf("high-risk review accepted unsupported method: %v", err)
- }
- base.ReviewMethod = "separate_agent"
- receipt, err := RecordDeliveryGate(base)
- if err != nil {
- t.Fatal(err)
- }
- if receipt.ReviewerIdentity != "reviewer-2" || receipt.ReviewMethod != "separate_agent" {
- t.Fatalf("review provenance was not persisted: %#v", receipt)
- }
- })
-}
-
-func TestManagedReviewRequiresChangelogEntryAndBindsItToTestEvidence(t *testing.T) {
- repo, feature := activateTwoSliceDeliveryWithChangelog(t, true)
- options := DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Status: "PASS"}
- options.Gate = "test"
- if _, err := RecordDeliveryGate(options); err != nil {
- t.Fatal(err)
- }
- options.Gate = "review"
- if _, err := RecordDeliveryGate(options); err == nil || !strings.Contains(err.Error(), "requires CHANGELOG.md") {
- t.Fatalf("managed review ignored missing changelog: %v", err)
- }
- writeChangelog(t, repo, "# Changelog\n\n## Unreleased\n\n### Added\n\n- Make the first delivery outcome available.\n")
- runGit(t, repo, "add", changelogPath)
- runGit(t, repo, "commit", "-m", "add first slice changelog entry")
- if _, err := RecordDeliveryGate(options); err == nil || !strings.Contains(err.Error(), "changed after the test gate") {
- t.Fatalf("changelog edit did not invalidate test evidence: %v", err)
- }
- options.Gate = "test"
- if _, err := RecordDeliveryGate(options); err != nil {
- t.Fatal(err)
- }
- options.Gate = "review"
- if _, err := RecordDeliveryGate(options); err != nil {
- t.Fatalf("managed review rejected valid changelog entry: %v", err)
- }
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil {
- t.Fatal(err)
- }
- base, err := changelogComparisonBase(repo, feature, "", runGit(t, repo, "merge-base", "main", "HEAD"))
- if err != nil {
- t.Fatal(err)
- }
- if err := validateChangelogChange(repo, base, changelogConfig()); err == nil || !strings.Contains(err.Error(), "new categorized entry") {
- t.Fatalf("second slice reused the first slice changelog entry: %v", err)
- }
-}
-
-func TestDeliveryGateReceiptsBindTheActiveSliceAndAdvanceOnce(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "test gate") {
- t.Fatalf("review passed without a test receipt: %v", err)
- }
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package fixture\n\nconst PhaseOne = true\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "feature.go")
- runGit(t, repo, "commit", "-m", "change phase after test")
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "changed after the test gate") {
- t.Fatalf("review accepted a diff not covered by the test receipt: %v", err)
- }
- for _, gate := range []string{"test", "review"} {
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: gate, Status: "PASS"}); err != nil {
- t.Fatalf("record %s gate: %v", gate, err)
- }
- }
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil {
- t.Fatal(err)
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if state.ActiveIndex != 1 || state.Slices[0].Status != "PUBLISHED" || state.Slices[1].Status != "BUILD" {
- t.Fatalf("publication advanced the wrong state: %#v", state)
- }
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: filepath.Join(directory, "approval.md"),
- OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
- SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
- }); err != nil {
- t.Fatalf("idempotent build activation failed: %v", err)
- }
- state, err = LoadDeliveryState(repo, feature)
- if err != nil || state.ActiveIndex != 1 {
- t.Fatalf("rerunning build reset delivery progress: state=%#v err=%v", state, err)
- }
- // A published slice whose PR is still open (PRState defaults to OPEN) remains
- // re-gateable in place: publication advanced the BUILD pointer to phase-two but
- // did not revoke phase-one's correction actuator. Re-gating must NOT double
- // advance ActiveIndex.
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil {
- t.Fatalf("published-open slice was not re-gateable in place: %v", err)
- }
- state, err = LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if state.ActiveIndex != 1 {
- t.Fatalf("re-gating a published-open slice advanced the BUILD pointer: %#v", state)
- }
- if state.Slices[0].Status != "TEST_PASSED" {
- t.Fatalf("re-gate did not transition the published slice: %#v", state.Slices[0])
- }
-}
-
-func TestRepairObservationPersistsAndSupersedesAffectedGates(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- for _, gate := range []string{"test", "review"} {
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: gate, Status: "PASS"}); err != nil {
- t.Fatalf("record %s gate: %v", gate, err)
- }
- }
- observation, state, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "the modal remains stuck on Gathering",
- SourceStage: "review_gate", Expected: "close after success", Actual: "stays pending",
- Evidence: "manual reproduction", Classification: "implementation_repair",
- })
- if err != nil {
- t.Fatal(err)
- }
- if observation.ID != "CHG-001" || state.Mode != "REWORK" || state.ResumeStage != "BUILD" || state.Slices[0].Status != "BUILD" {
- t.Fatalf("unexpected repair state: observation=%#v state=%#v", observation, state)
- }
- if len(state.SupersededReceipts) != 2 {
- t.Fatalf("expected both receipts to be superseded: %#v", state.SupersededReceipts)
- }
- changes, err := os.ReadFile(filepath.Join(repo, ".product-loop", "features", feature, "changes.md"))
- if err != nil || !strings.Contains(string(changes), "CHG-001") || !strings.Contains(string(changes), "the modal remains stuck") {
- t.Fatalf("change observation was not durably recorded: %v %s", err, changes)
- }
- if _, err := readDeliveryReceipt(repo, feature, "phase-one", "test"); err == nil {
- t.Fatal("superseded test receipt remained current")
- }
-}
-
-func TestRepairBudgetPersistsAcrossInvocations(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- state.RepairAttempt = 3
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- if _, _, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "another asynchronous failure arrived",
- SourceStage: "ci", Classification: "verification_repair",
- }); err == nil || !strings.Contains(err.Error(), "persistent repair budget exhausted") {
- t.Fatalf("new invocation reset the durable repair budget: %v", err)
- }
- current, err := LoadDeliveryState(repo, feature)
- if err != nil || current.RepairAttempt != 3 {
- t.Fatalf("exhausted repair state changed: %+v %v", current, err)
- }
-}
-
-func TestRequirementAmendmentBlocksGates(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- _, state, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "keep the modal open and show a summary",
- SourceStage: "build", Expected: "new summary state", Classification: "requirement_amendment",
- })
- if err != nil {
- t.Fatal(err)
- }
- if state.Mode != "AMENDMENT_REQUIRED" || state.ResumeStage != "PLAN_GATE" {
- t.Fatalf("amendment did not block: %#v", state)
- }
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "approved plan amendment") {
- t.Fatalf("gate accepted stale intent: %v", err)
- }
-}
-
-func TestPublishedChangeRemainsDiscoverableAndCannotResetOriginalDelivery(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- for _, gate := range []string{"test", "review"} {
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: gate, Status: "PASS"}); err != nil {
- t.Fatal(err)
- }
- }
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil {
- t.Fatal(err)
- }
- // Collapse the unused second fixture slice; publication behavior itself is
- // already covered above and the correction boundary only needs a completed parent.
- completed, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- completed.Slices[1].Status = "PUBLISHED"
- completed.ActiveIndex = len(completed.Slices)
- if err := saveDeliveryState(repo, completed); err != nil {
- t.Fatal(err)
- }
- statePath, err := deliveryStatePath(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- before, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- observation, unchanged, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "production needs a different success state",
- SourceStage: "ci", Classification: "verification_repair",
- })
- if err != nil {
- t.Fatal(err)
- }
- if observation.Outcome != "CORRECTIVE_CHILD_REQUIRED" || observation.ParentDelivery != feature || observation.SuggestedFeatureID != feature+"-correction-01" {
- t.Fatalf("unexpected corrective-child result: %#v", observation)
- }
- changes, err := os.ReadFile(filepath.Join(repo, ".product-loop", "features", feature, "changes.md"))
- if err != nil || !strings.Contains(string(changes), "CORRECTIVE_CHILD_REQUIRED") || !strings.Contains(string(changes), feature+"-correction-01") {
- t.Fatalf("corrective-child outcome was not recorded in the parent ledger: %v %s", err, changes)
- }
- if unchanged.Mode != completed.Mode || unchanged.ActiveIndex != completed.ActiveIndex {
- t.Fatalf("published parent was mutated in memory: %#v", unchanged)
- }
- after, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatalf("published parent state changed while recording correction\nbefore=%s\nafter=%s", before, after)
- }
- active, err := ActiveManagedDeliveries(repo)
- if err != nil || len(active) != 0 {
- t.Fatalf("published parent became active: %#v %v", active, err)
- }
- lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("replacement-lock"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := initializeDeliveryState(repo, feature, filepath.Join(repo, ".product-loop", "features", feature, "plan.md"), lockPath); err == nil || !strings.Contains(err.Error(), "published delivery") {
- t.Fatalf("published parent was reset instead of requiring a child: %v", err)
- }
-}
-
-func TestVerificationRepairPreservesImplementationAndRerunsGates(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- for _, gate := range []string{"test", "review"} {
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: gate, Status: "PASS"}); err != nil {
- t.Fatal(err)
- }
- }
- _, state, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "the test checks the wrong success state",
- SourceStage: "review_gate", Classification: "verification_repair",
- })
- if err != nil {
- t.Fatal(err)
- }
- if state.ResumeStage != "TEST_GATE" || state.Slices[0].Status != "BUILD" {
- t.Fatalf("verification repair resumed incorrectly: %#v", state)
- }
- runGit(t, repo, "add", ".product-loop/features/"+feature+"/changes.md")
- runGit(t, repo, "commit", "-m", "record verification repair")
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil {
- t.Fatalf("rerun test gate failed: %v", err)
- }
- receipt, err := readDeliveryReceipt(repo, feature, "phase-one", "test")
- if err != nil || receipt.TriggerObservationID != "CHG-001" || receipt.Attempt != 2 {
- t.Fatalf("repair lineage missing from receipt: %#v %v", receipt, err)
- }
-}
-
-func TestDeliveryGateRejectsChangesOwnedByALaterSlice(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- if err := os.WriteFile(filepath.Join(repo, "second.go"), []byte("package fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "second.go")
- runGit(t, repo, "commit", "-m", "implement future slice early")
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "outside its affected_paths") {
- t.Fatalf("active slice accepted a later slice's file: %v", err)
- }
-}
-
-func TestDeliveryGateRejectsStateFromAnotherPlanLock(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- state.PlanLockHash = strings.Repeat("b", 64)
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "stale for the current plan lock") {
- t.Fatalf("delivery state crossed plan locks: %v", err)
- }
-}
-
-func TestManagedDeliveryHookDeniesDirectPublicationRoutes(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir("phased-feature"), "plan.lock.json")
- if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(lockPath, []byte("publication fixture lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: lockHash,
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD", HeadBranch: "main"}},
- }); err != nil {
- t.Fatal(err)
- }
- for _, command := range []string{
- "git push origin feature", "git -C " + repo + " push origin feature", "gh pr create --title phase-one",
- "gh api repos/example/project/pulls --method POST", "hub pull-request -m phase-one",
- } {
- findings := ClassifyCommand(repo, command)
- if len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" {
- t.Fatalf("direct publication was not denied for %q: %#v", command, findings)
- }
- if findings[0].BlockingFeature != "phased-feature" || findings[0].BlockingSlice != "phase-one" || findings[0].NextOperation != "recovery-status" {
- t.Fatalf("publication denial omitted recovery context for %q: %#v", command, findings)
- }
- }
- state, err := LoadDeliveryState(repo, "phased-feature")
- if err != nil {
- t.Fatal(err)
- }
- state.Slices[0].HeadBranch = "main"
- state.ParentDelivery = "published-parent"
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- if err := syncEngagementLease(repo, state); err != nil {
- t.Fatal(err)
- }
- related := ClassifyCommand(repo, "git push origin main")
- if len(related) != 1 || related[0].BranchRelation != "current_branch" || related[0].ParentDelivery != "published-parent" {
- t.Fatalf("related publication denial was not identified: %#v", related)
- }
- findings := ClassifyTool(repo, "github_create_pull_request", map[string]any{"title": "phase one"})
- if len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" {
- t.Fatalf("GitHub tool publication was not denied: %#v", findings)
- }
- if findings[0].BlockingFeature != related[0].BlockingFeature || findings[0].BlockingSlice != related[0].BlockingSlice || findings[0].BranchRelation != related[0].BranchRelation || findings[0].NextOperation != related[0].NextOperation || findings[0].ParentDelivery != related[0].ParentDelivery {
- t.Fatalf("shell and tool publication findings diverged:\nshell=%#v\ntool=%#v", related[0], findings[0])
- }
- hostEvents := map[string]string{
- "cursor": `{"hook_event_name":"beforeShellExecution","command":"git push origin main"}`,
- "claude": `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git push origin main"}}`,
- "codex": `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git push origin main"}}`,
- }
- for host, event := range hostEvents {
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(event)})
- if !denied {
- t.Fatalf("%s publication event was allowed", host)
- }
- for _, expected := range []string{"phased-feature", "slice=phase-one", "relation=current_branch", "parent=published-parent", "next=recovery-status", "do not repeat this push"} {
- if !strings.Contains(string(output), expected) {
- t.Fatalf("%s hook omitted recovery context %q: %s", host, expected, output)
- }
- }
- }
- if findings := ClassifyCommand(repo, "git status --short"); len(findings) != 0 {
- t.Fatalf("read-only Git was unexpectedly denied: %#v", findings)
- }
- statePath, err := deliveryStatePath(repo, "phased-feature")
- if err != nil {
- t.Fatal(err)
- }
- findings = ClassifyCommand(repo, "rm "+statePath)
- if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" {
- t.Fatalf("direct delivery-state mutation was not denied: %#v", findings)
- }
- if err := os.WriteFile(statePath, []byte("{bad"), 0o644); err != nil {
- t.Fatal(err)
- }
- findings = ClassifyCommand(repo, "git push origin feature")
- if len(findings) == 0 || findings[0].Category != "workflow-state-invalid" {
- t.Fatalf("corrupt delivery state failed open: %#v", findings)
- }
-}
-
-// Relation conformance for
-// control-law: managed-publication-scope-follows-current-branch.
-func TestManagedDeliveryPublicationGuardIgnoresUnrelatedBranch(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64),
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD", HeadBranch: "feature/phased-feature"}},
- }); err != nil {
- t.Fatal(err)
- }
- if findings := ClassifyCommand(repo, "git push origin main"); len(findings) != 0 {
- t.Fatalf("delivery on another branch controlled publication here: %#v", findings)
- }
-}
-
-func TestManagedDeliveryStateDoesNotBlockUnrelatedWorktrees(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "README.md")
- runGit(t, repo, "commit", "-m", "base")
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64),
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD"}},
- }); err != nil {
- t.Fatal(err)
- }
- linked := filepath.Join(t.TempDir(), "linked")
- runGit(t, repo, "worktree", "add", "-b", "other-work", linked)
- active, err := ActiveManagedDeliveries(linked)
- if err != nil {
- t.Fatal(err)
- }
- if len(active) != 0 {
- t.Fatalf("delivery state leaked into unrelated worktree: %v", active)
- }
- if findings := ClassifyCommand(linked, "git push origin other-work"); len(findings) != 0 {
- t.Fatalf("unrelated worktree publication was denied: %#v", findings)
- }
-}
diff --git a/boatstack/deliverycontrol_parity_test.go b/boatstack/deliverycontrol_parity_test.go
deleted file mode 100644
index f5214f0..0000000
--- a/boatstack/deliverycontrol_parity_test.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package boatstack
-
-import (
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: registry-mirrors-real-transitions
-// The deliverycontrol registry is a second projection of the real delivery
-// state machine in this package. These tests are the boundary that keeps the two
-// from drifting: every HandlerRef must name a real exported function here, and
-// the registry's slice-status states must equal the DeliverySlice.Status
-// literals the real machine uses.
-
-// realDeliveryHandlers maps the exported functions the registry may reference to
-// their values. Referencing the values makes this fail to COMPILE if any handler
-// is renamed or removed, so a registry HandlerRef can never point at a function
-// that no longer exists.
-var realDeliveryHandlers = map[string]any{
- "ActivatePlan": ActivatePlan,
- "RecordDeliveryGate": RecordDeliveryGate,
- "RecordChangeObservation": RecordChangeObservation,
- "RecordJourneyResults": RecordJourneyResults,
- "PublishPR": PublishPR,
- "UndoManagedMutation": UndoManagedMutation,
- "DiscardDelivery": DiscardDelivery,
- "RepairState": RepairState,
- "IgnoreDelivery": IgnoreDelivery,
- "CurrentDeliveryState": CurrentDeliveryState,
- "CheckDeliveryReadyForShip": CheckDeliveryReadyForShip,
- "ResolveNext": ResolveNext,
- "ResolveRecovery": ResolveRecovery,
- "WritePlanningArtifact": WritePlanningArtifact,
- "RecordApproval": RecordApproval,
-}
-
-func TestRegistryHandlerRefsAreRealFunctions(t *testing.T) {
- for _, tr := range deliverycontrol.Transitions() {
- if _, ok := realDeliveryHandlers[tr.HandlerRef]; !ok {
- t.Errorf("transition %s references handler %q which is not a known real delivery function", tr.ID, tr.HandlerRef)
- }
- }
-}
-
-func TestRegistrySliceStatusMatchesRealLiterals(t *testing.T) {
- // The real slice lifecycle literals derive from a single source —
- // DeliverySliceStatuses() in delivery.go, which the real DeliverySlice.Status
- // assignments and the nextForDelivery switch use as named constants. A new
- // slice status therefore cannot be introduced as a raw literal that this
- // hand-maintained test would miss: it must appear in DeliverySliceStatuses(),
- // and the registry's SliceStatusStates() must then match it.
- realLiterals := map[string]bool{}
- for _, s := range DeliverySliceStatuses() {
- realLiterals[s] = true
- }
- registryStatus := map[string]bool{}
- for _, s := range deliverycontrol.SliceStatusStates() {
- registryStatus[string(s)] = true
- }
- for lit := range realLiterals {
- if !registryStatus[lit] {
- t.Errorf("registry SliceStatusStates is missing real literal %q", lit)
- }
- }
- for s := range registryStatus {
- if !realLiterals[s] {
- t.Errorf("registry declares slice-status %q that the real machine does not use", s)
- }
- }
-}
diff --git a/boatstack/denial.go b/boatstack/denial.go
deleted file mode 100644
index 3ad1a84..0000000
--- a/boatstack/denial.go
+++ /dev/null
@@ -1,651 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "strings"
-)
-
-// Boatstack denials describe a guardrail, not a crash. A denial is captured once
-// as a structured value and rendered four ways so every surface — each coding host
-// and the raw terminal — shows the calmest treatment it can render, while the flat
-// text stays a complete fallback everywhere:
-//
-// - Plain : multi-line text (default hook reason; safe on every host)
-// - Markdown : light inline markup for hosts that render it
-// - ANSI : soft-coral pill + reassurance for a real terminal (CLI, guards)
-// - Structured: a machine object for a host that adopts rich denial rendering
-//
-// Nothing here carries secrets: a Denial holds only category slugs, fixed guidance,
-// and bounded finding fields, mirroring SafetyFinding's secret-free contract.
-
-// Severity selects the calm color family. Protected = a guardrail stopped a
-// protected effect (coral); Advisory = recoverable / retry / input problem (amber).
-type Severity int
-
-const (
- SeverityProtected Severity = iota
- SeverityAdvisory
- SeverityInfo
-)
-
-func (s Severity) slug() string {
- switch s {
- case SeverityAdvisory:
- return "advisory"
- case SeverityInfo:
- return "info"
- default:
- return "protected"
- }
-}
-
-// RenderMode chooses how a Denial becomes text.
-type RenderMode int
-
-const (
- RenderPlain RenderMode = iota
- RenderMarkdown
- RenderANSI
-)
-
-// Denial is the single structured description of a blocked action.
-type Denial struct {
- Category string // machine slug, e.g. "workflow-state-tamper"
- Badge string // "Blocked by Boatstack"
- Qualifier string // "protected path" | "managed runtime authority" | ""
- Severity Severity //
- Detail string // guidance; may contain `code` spans
- Reassurance string // "Nothing was written; your files are untouched." (empty if an effect occurred)
- Hint string // recovery command, e.g. "boatstack-helper diagnose-hook"
- // Options is the denial's computed solution set: the admissible commands
- // from exactly the position the finding describes, so a weaker model picks
- // a legal move instead of retrying the blocked one. Derived from the same
- // declarations the guard enforces; renders as a short "You can:" list and
- // rides in full on the structured payload.
- // control-law: solution-set-derives-from-guard-declarations
- Options []PrescribedCommand
- OptionsTruncated bool
- // OwnerVerbs names the verbs that own a protected path (state-tamper
- // denials), derived from the state-ownership map. Named, never compiled
- // into runnable commands — their full arguments are not derivable here.
- OwnerVerbs []string
- // Escalated marks a denial that has repeated past the ledger threshold:
- // the rendering lifts the pick-list cap to the full set and prescribes a
- // fresh diagnostic. More corrective information, never more severity —
- // what is denied never changes.
- // control-law: repeated-denials-escalate-to-solutions
- Escalated bool
- RepeatCount int
-}
-
-// optionTextLimit is the pick-list cap for the text renderings: compact by
-// default, the full structured cap once the denial has escalated.
-func (d Denial) optionTextLimit() int {
- if d.Escalated {
- return solutionSetCap
- }
- return solutionSetTextCap
-}
-
-// escalationLine is the repeat notice with the fresh-probe prescription; empty
-// until the denial escalates.
-func (d Denial) escalationLine() string {
- if !d.Escalated {
- return ""
- }
- return fmt.Sprintf("This denial repeated %d times. Run: .product-loop/boatstack doctor --repo .", d.RepeatCount)
-}
-
-// --- ANSI palette (truecolor; matches the approved mockup) -------------------
-
-const (
- ansiReset = "\x1b[0m"
- ansiBold = "\x1b[1m"
- ansiDim = "\x1b[2m"
-
- // soft coral #e59280 / amber #e6b566 / calm gray #8b8f98 / pill ink #17181c
- fgCoral = "\x1b[38;2;229;146;128m"
- bgCoral = "\x1b[48;2;229;146;128m"
- fgAmber = "\x1b[38;2;230;181;102m"
- bgAmber = "\x1b[48;2;230;181;102m"
- fgGray = "\x1b[38;2;139;143;152m"
- fgInk = "\x1b[38;2;23;24;28m"
- fgCode = "\x1b[38;2;199;205;215m"
-)
-
-func (d Denial) sevFG() string {
- if d.Severity == SeverityAdvisory {
- return fgAmber
- }
- return fgCoral
-}
-
-func (d Denial) sevBG() string {
- if d.Severity == SeverityAdvisory {
- return bgAmber
- }
- return bgCoral
-}
-
-// Render turns a Denial into text for the given mode.
-func (d Denial) Render(mode RenderMode) string {
- badge := d.Badge
- if badge == "" {
- badge = "Blocked by Boatstack"
- }
- switch mode {
- case RenderMarkdown:
- return d.renderMarkdown(badge)
- case RenderANSI:
- return d.renderANSI(badge)
- default:
- return d.renderPlain(badge)
- }
-}
-
-// optionLines renders the solution set as at most `limit` numbered command
-// lines, plus an overflow note. Shared by the three text renderers so every
-// surface shows the same picks.
-// control-law: solution-set-derives-from-guard-declarations
-func (d Denial) optionLines(limit int) []string {
- if len(d.Options) == 0 {
- return nil
- }
- shown := d.Options
- if len(shown) > limit {
- shown = shown[:limit]
- }
- lines := make([]string, 0, len(shown)+1)
- for i, option := range shown {
- lines = append(lines, fmt.Sprintf(" %d) %s", i+1, option.CommandLine()))
- }
- hidden := len(d.Options) - len(shown)
- if d.OptionsTruncated || hidden > 0 {
- lines = append(lines, " (more legal moves: run .product-loop/boatstack next-status)")
- }
- return lines
-}
-
-func (d Denial) renderPlain(badge string) string {
- var b strings.Builder
- head := badge
- if d.Qualifier != "" {
- head += " · " + d.Qualifier
- }
- b.WriteString(head)
- if d.Detail != "" {
- b.WriteString("\n\n")
- b.WriteString(d.Detail)
- }
- if d.Reassurance != "" {
- b.WriteString("\n\n↳ ")
- b.WriteString(d.Reassurance)
- }
- if len(d.OwnerVerbs) > 0 {
- b.WriteString("\n\nThis path is owned by: " + strings.Join(d.OwnerVerbs, ", ") + ".")
- }
- if lines := d.optionLines(d.optionTextLimit()); len(lines) > 0 {
- b.WriteString("\n\nYou can:\n")
- b.WriteString(strings.Join(lines, "\n"))
- }
- if escalation := d.escalationLine(); escalation != "" {
- b.WriteString("\n\n" + escalation)
- }
- if d.Hint != "" {
- b.WriteString("\n\nFalse positive? run: ")
- b.WriteString(d.Hint)
- }
- return b.String()
-}
-
-func (d Denial) renderMarkdown(badge string) string {
- var b strings.Builder
- b.WriteString("**" + badge + "**")
- if d.Qualifier != "" {
- b.WriteString(" · " + d.Qualifier)
- }
- if d.Detail != "" {
- b.WriteString("\n\n" + d.Detail)
- }
- if d.Reassurance != "" {
- b.WriteString("\n\n↳ _" + d.Reassurance + "_")
- }
- if len(d.OwnerVerbs) > 0 {
- b.WriteString("\n\nThis path is owned by: `" + strings.Join(d.OwnerVerbs, "`, `") + "`.")
- }
- if lines := d.optionLines(d.optionTextLimit()); len(lines) > 0 {
- b.WriteString("\n\nYou can:\n")
- for _, line := range lines {
- b.WriteString("\n" + line)
- }
- }
- if escalation := d.escalationLine(); escalation != "" {
- b.WriteString("\n\n" + escalation)
- }
- if d.Hint != "" {
- b.WriteString("\n\nFalse positive? run `" + d.Hint + "`")
- }
- return b.String()
-}
-
-func (d Denial) renderANSI(badge string) string {
- var b strings.Builder
- // pill: severity background + dark ink, self-contained contrast on any terminal
- b.WriteString(d.sevBG() + fgInk + ansiBold + " ⊘ " + badge + " " + ansiReset)
- if d.Qualifier != "" {
- b.WriteString(" " + d.sevFG() + ansiDim + d.Qualifier + ansiReset)
- }
- if d.Detail != "" {
- b.WriteString("\n\n" + ansiCode(d.Detail))
- }
- if d.Reassurance != "" {
- b.WriteString("\n" + fgGray + "↳ " + d.Reassurance + ansiReset)
- }
- if len(d.OwnerVerbs) > 0 {
- b.WriteString("\n" + fgGray + "this path is owned by: " + ansiReset + fgCode + strings.Join(d.OwnerVerbs, ", ") + ansiReset)
- }
- if lines := d.optionLines(d.optionTextLimit()); len(lines) > 0 {
- b.WriteString("\n" + fgGray + "you can:" + ansiReset)
- for _, line := range lines {
- b.WriteString("\n" + fgCode + line + ansiReset)
- }
- }
- if escalation := d.escalationLine(); escalation != "" {
- b.WriteString("\n" + fgGray + escalation + ansiReset)
- }
- if d.Hint != "" {
- b.WriteString("\n" + fgGray + ansiDim + "false positive? run " + ansiReset + fgCode + d.Hint + ansiReset)
- }
- return b.String()
-}
-
-// ansiCode dims the surrounding text and brightens inline `code` spans.
-func ansiCode(s string) string {
- parts := strings.Split(s, "`")
- var b strings.Builder
- for i, part := range parts {
- if i%2 == 1 {
- b.WriteString(fgCode + part + ansiReset)
- } else {
- b.WriteString(part)
- }
- }
- return b.String()
-}
-
-// Structured is the machine object for a host that adopts rich denial rendering.
-// It is emitted only when denialRichEnabled() is set, and the flat reason string
-// is always populated alongside it as the fallback.
-func (d Denial) Structured() map[string]any {
- badge := d.Badge
- if badge == "" {
- badge = "Blocked by Boatstack"
- }
- out := map[string]any{
- "schema_version": 1,
- "category": d.Category,
- "badge": badge,
- "severity": d.Severity.slug(),
- }
- if d.Qualifier != "" {
- out["qualifier"] = d.Qualifier
- }
- if d.Detail != "" {
- out["detail"] = d.Detail
- }
- if d.Reassurance != "" {
- out["reassurance"] = d.Reassurance
- }
- if d.Hint != "" {
- out["hint"] = d.Hint
- }
- // Additive keys only — schema_version stays 1; a consumer that ignores them
- // loses nothing (the flat reason string already carries the capped picks).
- // control-law: solution-set-derives-from-guard-declarations
- if len(d.Options) > 0 {
- options := make([]map[string]any, 0, len(d.Options))
- for _, option := range d.Options {
- row := map[string]any{
- "verb": option.Verb,
- "command_line": option.CommandLine(),
- "transition": string(option.Transition),
- }
- if len(option.Args) > 0 {
- row["args"] = option.Args
- }
- if len(option.RequiresHumanInput) > 0 {
- row["requires_human_input"] = option.RequiresHumanInput
- }
- options = append(options, row)
- }
- out["options"] = options
- if d.OptionsTruncated {
- out["options_truncated"] = true
- }
- }
- if len(d.OwnerVerbs) > 0 {
- out["owner_verbs"] = d.OwnerVerbs
- }
- if d.Escalated {
- out["escalated"] = true
- out["repeat_count"] = d.RepeatCount
- }
- return out
-}
-
-// --- environment gating ------------------------------------------------------
-
-// colorEnabled reports whether ANSI styling should be emitted to f. Honors
-// BOATSTACK_COLOR=always|never|auto (default auto), NO_COLOR, TERM=dumb, and
-// otherwise requires f to be a character device (a real terminal). stdlib only.
-func colorEnabled(f *os.File) bool {
- switch strings.ToLower(strings.TrimSpace(os.Getenv("BOATSTACK_COLOR"))) {
- case "always", "1", "true", "yes", "on":
- return true
- case "never", "0", "false", "no", "off":
- return false
- }
- if os.Getenv("NO_COLOR") != "" {
- return false
- }
- if strings.EqualFold(strings.TrimSpace(os.Getenv("TERM")), "dumb") {
- return false
- }
- if f == nil {
- return false
- }
- info, err := f.Stat()
- if err != nil {
- return false
- }
- return info.Mode()&os.ModeCharDevice != 0
-}
-
-// denialRichEnabled reports whether the structured denial object should be added
-// to a host's hook decision. Default off: no host documents tolerating unknown
-// keys, so we opt in explicitly (the flat reason string is always the fallback).
-func denialRichEnabled() bool {
- switch strings.ToLower(strings.TrimSpace(os.Getenv("BOATSTACK_DENIAL_RICH"))) {
- case "1", "true", "yes", "on":
- return true
- default:
- return false
- }
-}
-
-// renderModeForFile picks ANSI when color is enabled for f, else Plain. Used by
-// the terminal surfaces (CLI errors, guard fallbacks routed through Go).
-func renderModeForFile(f *os.File) RenderMode {
- if colorEnabled(f) {
- return RenderANSI
- }
- return RenderPlain
-}
-
-// FormatBlocked renders a CLI "BLOCKED" error line for the given stream. On a
-// color-capable terminal it is a soft-coral pill; otherwise it is the literal
-// "BLOCKED: " plain form, so scripts and tests that match that prefix are
-// unaffected when output is piped or captured.
-func FormatBlocked(f *os.File, msg string) string {
- if colorEnabled(f) {
- return bgCoral + fgInk + ansiBold + " ⊘ Blocked " + ansiReset + " " + msg
- }
- return "BLOCKED: " + msg
-}
-
-// ParseRenderMode maps a flag value to a RenderMode ("ansi"|"plain"|"markdown").
-func ParseRenderMode(value string) RenderMode {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "ansi", "color", "terminal":
- return RenderANSI
- case "markdown", "md":
- return RenderMarkdown
- default:
- return RenderPlain
- }
-}
-
-// DenialDemo renders a representative set of denials for the render-denial --demo
-// command, so an operator can see the plain/markdown/ANSI treatments directly.
-func DenialDemo(host string, mode RenderMode) string {
- samples := []SafetyFinding{
- {Category: "workflow-state-tamper"},
- {Category: "filesystem-destruction"},
- {Category: "workflow-phase-bypass", BlockingFeature: "checkout-flow", WorkflowStage: "PLAN_PENDING", NextOperation: "plan-gate"},
- {Category: "malformed-tool-input", Reason: "empty-command"},
- {Category: "operation-already-succeeded", OperationID: "op_9f2c", OperationState: "SUCCEEDED", AttemptNumber: 1},
- }
- var b strings.Builder
- for i, finding := range samples {
- if i > 0 {
- b.WriteString("\n\n")
- }
- b.WriteString(denialWithOptions(".", host, finding).Render(mode))
- }
- return b.String()
-}
-
-// denialWithOptions composes the pure finding→Denial mapping with the
-// enumerated solution set for the finding's position. denialFor stays pure
-// (DenialDemo and tests use it directly); the hook deny paths call this so
-// every real denial carries its picks.
-// control-law: solution-set-derives-from-guard-declarations
-func denialWithOptions(repo, host string, finding SafetyFinding) Denial {
- d := denialFor(host, finding)
- set := enumerateDenialSolutions(repo, host, finding)
- d.Options = set.Options
- d.OptionsTruncated = set.Truncated
- if finding.Category == "workflow-state-tamper" {
- d.OwnerVerbs = tamperOwnerVerbs(repo, finding.AttemptedPath)
- }
- if finding.RepeatCount >= denialEscalationThreshold {
- d.Escalated = true
- d.RepeatCount = finding.RepeatCount
- }
- return d
-}
-
-const reassureUntouched = "Nothing was written; your files are untouched."
-
-// denialFor maps a SafetyFinding to a structured Denial. It preserves every
-// piece of information the previous flat messages carried (guidance, machine
-// tokens like HOST_PAYLOAD_MALFORMED, and the interpolated recovery context),
-// and adds the calm framing: a badge, a qualifier, a severity, and — when the
-// action was blocked before any effect — a reassurance line.
-func denialFor(host string, finding SafetyFinding) Denial {
- d := Denial{Category: finding.Category, Badge: "Blocked by Boatstack", Severity: SeverityProtected}
-
- switch finding.Category {
- case "malformed-tool-input":
- name := strings.ToUpper(strings.TrimSpace(host))
- if name == "" {
- name = "HOST"
- }
- d.Severity = SeverityAdvisory
- d.Qualifier = "unreadable tool event"
- d.Detail = "Boatstack could not inspect the " + name + " hook event (HOST_PAYLOAD_MALFORMED:" + finding.Reason +
- "). No unsafe operation was detected; execution is denied because the intended command or tool call is unavailable. Retry once with an explicit non-empty command. If this repeats, stop shell and tool retries and preserve current edits."
- if strings.EqualFold(host, "cursor") {
- d.Detail += " Start a new Cursor task and run `.product-loop/boatstack diagnose-hook --host cursor --repo .` from an external terminal. Do not reinstall Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime."
- } else {
- d.Detail += " Run `.product-loop/boatstack diagnose-hook --host " + strings.ToLower(host) + " --repo .` from an external terminal before changing the installation."
- }
- return d
-
- case "planning-transport-invalid":
- d.Severity = SeverityAdvisory
- d.Qualifier = "planning input incomplete"
- d.Detail = "Boatstack did not run the planning write because its literal Markdown envelope was incomplete, stale, or ambiguous (PLANNING_TRANSPORT_INVALID:" + finding.Reason + "). Resolve a fresh mode-aware `flow bootstrap` prescription and execute only its returned planning_envelope. Do not construct a helper path, paste Markdown at a shell prompt, or retry a truncated command."
- d.Reassurance = reassureUntouched
- return d
-
- case "workflow-state-invalid":
- d.Severity = SeverityAdvisory
- d.Qualifier = "delivery state unverified"
- d.Detail = "Publication is denied because managed delivery state cannot be verified. Re-run the active Boatstack operation or repair the installation before publishing."
- d.Reassurance = "Nothing was published."
- return d
-
- case "workflow-state-tamper":
- d.Qualifier = "managed runtime authority"
- d.Detail = "Change `.git/boatstack/` only through the command that owns it — a build, test, review, or ship transition for delivery state, `publish-update-pr` for a version update, or `workspace-reap` to reclaim a finished worktree and its runtime state."
- d.Reassurance = "Nothing was written; your runtime state is unchanged."
- d.Hint = "boatstack-helper diagnose-hook"
- return d
-
- case "workflow-phase-bypass":
- target := "the saved Boatstack plan"
- if finding.BlockingFeature != "" {
- target = fmt.Sprintf("Boatstack feature %q", finding.BlockingFeature)
- }
- next := finding.NextOperation
- if next == "" {
- next = "repair-state"
- }
- attempted := ""
- if finding.AttemptedPath != "" {
- attempted = " Attempted path: " + finding.AttemptedPath + "."
- }
- d.Qualifier = "plan gate"
- d.Detail = fmt.Sprintf("Product mutation is denied because %s is at %s.%s Continue with `%s`; unrelated task completions do not authorize implementation.", target, finding.WorkflowStage, attempted, next)
- // A planning-state denial must name the owned authoring channel, not just
- // the cleanup verb — otherwise the corrective move (planning-write) is
- // discoverable only by failing again.
- // control-law: prescriptive-closure-every-stage-names-a-runnable-command
- if finding.Source == "planning-state" {
- slug := ""
- if finding.BlockingFeature != "" {
- slug = finding.BlockingFeature
- }
- d.Detail += fmt.Sprintf(" Planning Markdown is authored through the owned channel: resolve `flow bootstrap --feature %s` with the current source plan, artifact, shell, and complete Markdown, then execute only its returned planning_envelope — never a raw host write into `.product-loop/features/` or a manual shell paste.", slug)
- }
- d.Reassurance = reassureUntouched
- return d
-
- case "workflow-publication-bypass":
- target := "the active managed delivery"
- if finding.BlockingFeature != "" {
- target = fmt.Sprintf("managed delivery %q", finding.BlockingFeature)
- }
- relation := ""
- switch finding.BranchRelation {
- case "unrelated":
- relation = " It is unrelated to the current branch."
- case "ambiguous":
- relation = " More than one delivery may be blocking publication."
- }
- context := publicationRecoveryContext(finding)
- d.Qualifier = "publication authority"
- d.Detail = target + " still owns publication authority." + relation + context + " Resolve the reported change through the managed recovery path; do not repeat this push or PR mutation manually."
- d.Reassurance = "No push or pull request was made."
- return d
-
- case "workflow-visual-evidence-missing":
- target := "this delivery"
- if finding.BlockingFeature != "" {
- target = fmt.Sprintf("feature %q", finding.BlockingFeature)
- }
- d.Qualifier = "visual evidence is owed"
- d.Detail = "PR publication is blocked until required visual evidence is current for " + target + "."
- if finding.PolicySource == "plan-escalated" {
- d.Detail += " The approved plan declares visual scenarios, so the configured suggest policy ships with require semantics for this feature."
- }
- if reason := strings.TrimSpace(finding.Reason); reason != "" {
- d.Detail += " Automatic capture reported: " + reason + "."
- }
- d.Detail += " Boatstack captures the plan's approved scenarios itself once a repository command is registered; declare pr_visual_evidence not_relevant (with a reason) only for a genuinely nonvisual change."
- d.Reassurance = "No pull request was created or updated."
- return d
-
- case "operation-in-flight":
- d.Severity = SeverityAdvisory
- d.Qualifier = "already supervised"
- d.Detail = "Boatstack is already supervising this exact operation." + operationContext(finding) + " Wait for its completion event; do not launch it again."
- d.Reassurance = "The original operation is still running."
- d.Hint = "boatstack-helper operation-status"
- return d
-
- case "operation-already-succeeded":
- d.Severity = SeverityInfo
- d.Qualifier = "already completed"
- d.Detail = "Boatstack already observed this exact operation succeed." + operationContext(finding) + " Continue from the resulting repository state instead of repeating it."
- d.Reassurance = "The earlier run's result stands."
- return d
-
- case "operation-reconciliation-required":
- d.Severity = SeverityAdvisory
- d.Qualifier = "needs reconciliation"
- d.Detail = "Boatstack cannot yet distinguish success from an interrupted response." + operationContext(finding) + " Reconcile the expected postcondition with operation-status before any retry."
- d.Hint = "boatstack-helper operation-status"
- return d
-
- case "operation-retry-exhausted":
- d.Severity = SeverityAdvisory
- d.Qualifier = "retry budget spent"
- d.Detail = "Boatstack exhausted the persistent retry budget for this operation." + operationContext(finding) + " Preserve current state and use the reported manual recovery; do not repeat the tool call."
- return d
-
- case "git-history-destruction":
- d.Qualifier = "history-destructive git"
- d.Detail = "Use the project-local workspace-sync operation to checkpoint current state and align the exact branch; do not scan delivery artifacts or retry the destructive command."
- d.Reassurance = "Nothing was written; your Git history is intact."
- return d
-
- case "workspace-sync-bypass":
- d.Qualifier = "unverified workspace sync"
- d.Detail = "Invoke only the exact project-local workspace-sync helper for the current repository."
- d.Reassurance = reassureUntouched
- return d
-
- case "filesystem-destruction":
- d.Qualifier = "recursive deletion"
- d.Detail = "Recursive deletion of a broad or protected path is denied. To reclaim a finished managed worktree and its branch, use `workspace-reap` (or single-feature `workspace-cleanup`); Boatstack removes them through its own sanctioned actuator. For any other path, preserve current state and use fix-forward recovery — destructive deletion is operator-only outside the agent workflow."
- d.Reassurance = reassureUntouched
- return d
- }
-
- // operation-* fallthrough (operation-state-invalid and any other operation-*)
- if strings.HasPrefix(finding.Category, "operation-") {
- d.Severity = SeverityAdvisory
- d.Qualifier = "operation state unverified"
- d.Detail = "Boatstack could not verify the durable operation state." + operationContext(finding) + " Inspect operation-status before retrying."
- d.Hint = "boatstack-helper operation-status"
- return d
- }
-
- // Generic protected-effect denial: database/filesystem/infrastructure/recovery
- // destruction, unbounded mutation, external-resource destruction, entrypoint
- // safety, unsupported host, unresolved repository, and anything new.
- d.Qualifier = "protected effect"
- d.Detail = fmt.Sprintf("Boatstack denied an irreversible operation (%s). Preserve the current state and use read-only diagnosis or fix-forward recovery; destructive recovery is operator-only outside the agent workflow.", finding.Category)
- d.Reassurance = reassureUntouched
- return d
-}
-
-func operationContext(finding SafetyFinding) string {
- if finding.OperationID == "" && finding.OperationState == "" && finding.AttemptNumber == 0 {
- return ""
- }
- return fmt.Sprintf(" operation=%s state=%s attempt=%d.", finding.OperationID, finding.OperationState, finding.AttemptNumber)
-}
-
-func publicationRecoveryContext(finding SafetyFinding) string {
- var parts []string
- if finding.BlockingSlice != "" {
- parts = append(parts, "slice="+finding.BlockingSlice)
- }
- if finding.BranchRelation != "" {
- parts = append(parts, "relation="+finding.BranchRelation)
- }
- if finding.ParentDelivery != "" {
- parts = append(parts, "parent="+finding.ParentDelivery)
- }
- if finding.NextOperation != "" {
- parts = append(parts, "next="+finding.NextOperation)
- }
- if len(parts) == 0 {
- return ""
- }
- return " Recovery context: " + strings.Join(parts, " ") + "."
-}
diff --git a/boatstack/denial_escalation_conformance_test.go b/boatstack/denial_escalation_conformance_test.go
deleted file mode 100644
index f09f2c7..0000000
--- a/boatstack/denial_escalation_conformance_test.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// control-law: repeated-denials-escalate-to-solutions
-//
-// Repetition of one denial is the signal that the stated law is not reaching
-// the model (the sibling harness's paid canary: sixteen identical no-progress
-// repair attempts, then a protected-boundary write). A repeat denial escalates
-// its corrective INFORMATION — the full solution set plus a fresh diagnostic
-// probe — never its severity or its admissibility. Boundaries held here:
-// the per-worktree ledger (recordDenial/resetDenialLedger), the HookDecision
-// deny/allow wiring, and the escalated rendering. The constitutional corpus
-// floor is held separately by safety_corpus_test.go and is untouched: this law
-// changes what a denial says, never what is denied.
-
-func tamperEvent(path string) []byte {
- return []byte(fmt.Sprintf(
- `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":%q,"content":"{}"}}`, path))
-}
-
-// Positive: the third identical denial escalates — the rendering carries the
-// repeat notice and the fresh-probe prescription; the first two do not.
-func TestThirdIdenticalDenialEscalates(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- event := tamperEvent(".git/boatstack/deliveries/demo/state.json")
- for attempt := 1; attempt <= denialEscalationThreshold; attempt++ {
- output, denied := HookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event})
- if !denied {
- t.Fatalf("attempt %d: tamper write must be denied", attempt)
- }
- escalated := strings.Contains(string(output), "This denial repeated")
- if attempt < denialEscalationThreshold && escalated {
- t.Fatalf("attempt %d escalated before the threshold:\n%s", attempt, output)
- }
- if attempt == denialEscalationThreshold {
- if !escalated {
- t.Fatalf("attempt %d must escalate:\n%s", attempt, output)
- }
- if !strings.Contains(string(output), fmt.Sprintf("repeated %d times", denialEscalationThreshold)) {
- t.Fatalf("escalation must carry the repeat count:\n%s", output)
- }
- if !strings.Contains(string(output), ".product-loop/boatstack doctor") {
- t.Fatalf("escalation must prescribe the fresh diagnostic:\n%s", output)
- }
- }
- }
-}
-
-// Negative/reset: an ALLOWED mutation-capable call is forward progress and
-// clears the ledger — the next denial starts unescalated.
-func TestAllowedMutationResetsTheLedger(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- event := tamperEvent(".git/boatstack/deliveries/demo/state.json")
- for i := 0; i < denialEscalationThreshold-1; i++ {
- if _, denied := HookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}); !denied {
- t.Fatal("tamper write must be denied")
- }
- }
- allowed := []byte(fmt.Sprintf(
- `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":%q,"content":"export const x = 1"}}`,
- filepath.Join(repo, "src", "app.ts")))
- if _, denied := HookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: allowed}); denied {
- t.Fatal("ordinary product write must be allowed")
- }
- output, denied := HookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event})
- if !denied {
- t.Fatal("tamper write must still be denied after the reset")
- }
- if strings.Contains(string(output), "This denial repeated") {
- t.Fatalf("ledger must reset after allowed progress:\n%s", output)
- }
-}
-
-// Relation: different denial keys never cross-escalate, and a read-only
-// allowed call does NOT reset the ledger (only mutation progress does).
-func TestDenialKeysAreIsolated(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- tamper := SafetyFinding{Category: "workflow-state-tamper", Source: "delivery-state"}
- phase := SafetyFinding{Category: "workflow-phase-bypass", WorkflowStage: "DRAFT_PLAN", Source: "planning-state"}
- if got := recordDenial(repo, tamper); got != 1 {
- t.Fatalf("first tamper count = %d, want 1", got)
- }
- if got := recordDenial(repo, tamper); got != 2 {
- t.Fatalf("second tamper count = %d, want 2", got)
- }
- if got := recordDenial(repo, phase); got != 1 {
- t.Fatalf("a different category must count from 1, got %d", got)
- }
- // Same category at a different stage is a different key.
- other := SafetyFinding{Category: "workflow-phase-bypass", WorkflowStage: "APPROVED", Source: "planning-state"}
- if got := recordDenial(repo, other); got != 1 {
- t.Fatalf("same category at a different stage must count from 1, got %d", got)
- }
- // A read-only allowed call must not reset: HookDecision only resets on
- // mutation-capable allows, so the tamper key keeps its count.
- readonly := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"}}`)
- if _, denied := HookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: readonly}); denied {
- t.Fatal("git status must be allowed")
- }
- if got := recordDenial(repo, tamper); got != 3 {
- t.Fatalf("read-only allow must not reset the ledger; tamper count = %d, want 3", got)
- }
-}
-
-// Failure-state: bookkeeping degrades, the denial never does. A corrupt ledger
-// starts fresh; an unresolvable repository still yields a usable count; the
-// escalated rendering is identical in admissibility to the unescalated one.
-func TestLedgerFailuresDegradeCalmly(t *testing.T) {
- repo := safetyTestRepo(t)
- path, ok := denialLedgerPath(repo)
- if !ok {
- t.Fatal("ledger path must resolve in a git fixture")
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
- t.Fatal(err)
- }
- finding := SafetyFinding{Category: "workflow-state-tamper", Source: "delivery-state"}
- if got := recordDenial(repo, finding); got != 1 {
- t.Fatalf("corrupt ledger must start fresh, got %d", got)
- }
- if got := recordDenial(t.TempDir(), finding); got < 1 {
- t.Fatalf("an unresolvable ledger must still return a usable count, got %d", got)
- }
-}
-
-// Bypass/bound: the ledger is pruned to its key bound, oldest first, so a
-// category-spraying session cannot grow unbounded per-worktree state.
-func TestLedgerStaysBounded(t *testing.T) {
- repo := safetyTestRepo(t)
- for i := 0; i < denialLedgerMaxKeys+8; i++ {
- recordDenial(repo, SafetyFinding{Category: fmt.Sprintf("category-%02d", i), Source: "test"})
- }
- path, _ := denialLedgerPath(repo)
- ledger := loadDenialLedger(path)
- if len(ledger.Counts) > denialLedgerMaxKeys {
- t.Fatalf("ledger holds %d keys, bound is %d", len(ledger.Counts), denialLedgerMaxKeys)
- }
-}
-
-// Rendering: escalation lifts the pick cap to the full structured set and the
-// structured payload carries escalated/repeat_count; severity is unchanged.
-func TestEscalatedRenderingLiftsTheCapNotTheSeverity(t *testing.T) {
- finding := SafetyFinding{
- Category: "workflow-phase-bypass", Source: "planning-state",
- WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo",
- }
- calm := denialWithOptions(".", "claude", finding)
- finding.RepeatCount = denialEscalationThreshold
- escalated := denialWithOptions(".", "claude", finding)
- if !escalated.Escalated || escalated.Severity != calm.Severity || escalated.Category != calm.Category {
- t.Fatalf("escalation must change information only: %+v vs %+v", escalated, calm)
- }
- if len(escalated.Options) != len(calm.Options) {
- t.Fatalf("the option SET is identical; only the rendered cap lifts")
- }
- if escalated.optionTextLimit() <= calm.optionTextLimit() && len(calm.Options) > calm.optionTextLimit() {
- t.Fatal("escalated rendering must show more of the set")
- }
- structured := escalated.Structured()
- if structured["escalated"] != true || structured["repeat_count"] != denialEscalationThreshold {
- t.Fatalf("structured payload must carry escalation: %v", structured)
- }
- if _, present := calm.Structured()["escalated"]; present {
- t.Fatal("unescalated payload must not carry the escalation keys")
- }
-}
diff --git a/boatstack/denial_ledger.go b/boatstack/denial_ledger.go
deleted file mode 100644
index b945c45..0000000
--- a/boatstack/denial_ledger.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "sort"
- "time"
-)
-
-// The sibling harness's paid canary recorded the trajectory this law exists to
-// stop: an agent under repair pressure repeated the same denied move sixteen
-// times and then escalated into a protected-boundary write. Repetition of one
-// denial is the signal that the stated law is not reaching the model — so a
-// repeat denial escalates its corrective information (the full solution set
-// plus a fresh diagnostic probe), never its severity. The law is pure
-// optimization: it changes WHAT a denial says, never what is denied or
-// allowed, and a broken ledger degrades to the unescalated rendering — a
-// denial must never turn into a crash or an allow because bookkeeping failed.
-// control-law: repeated-denials-escalate-to-solutions
-
-const (
- // denialEscalationThreshold is the identical-denial count at which the
- // rendering escalates (matches the fresh-probe discipline: two repeats of
- // the same failure without a new probe is thrash).
- denialEscalationThreshold = 3
- // denialLedgerMaxKeys bounds the ledger; the oldest keys are pruned.
- denialLedgerMaxKeys = 32
-)
-
-type denialLedgerEntry struct {
- Count int `json:"count"`
- LastUnix int64 `json:"last_unix"`
-}
-
-type denialLedger struct {
- Counts map[string]denialLedgerEntry `json:"counts"`
-}
-
-// denialLedgerKey identifies "the same denial": the category at the workflow
-// stage it fired. Two different categories never cross-escalate.
-func denialLedgerKey(finding SafetyFinding) string {
- return finding.Category + "\x00" + finding.WorkflowStage
-}
-
-func denialLedgerPath(repo string) (string, bool) {
- w, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return "", false
- }
- base, err := w.GuardDir()
- if err != nil {
- return "", false
- }
- return filepath.Join(base, "denials.json"), true
-}
-
-func loadDenialLedger(path string) denialLedger {
- ledger := denialLedger{Counts: map[string]denialLedgerEntry{}}
- value, err := os.ReadFile(path)
- if err != nil {
- return ledger
- }
- // An unreadable or corrupt ledger starts fresh — fail-calm, never fail-open
- // or crash.
- var parsed denialLedger
- if json.Unmarshal(value, &parsed) == nil && parsed.Counts != nil {
- ledger = parsed
- }
- if ledger.Counts == nil {
- ledger.Counts = map[string]denialLedgerEntry{}
- }
- return ledger
-}
-
-func saveDenialLedger(path string, ledger denialLedger) {
- // Prune to the bound, dropping the oldest keys first.
- if len(ledger.Counts) > denialLedgerMaxKeys {
- type aged struct {
- key string
- last int64
- }
- entries := make([]aged, 0, len(ledger.Counts))
- for key, entry := range ledger.Counts {
- entries = append(entries, aged{key, entry.LastUnix})
- }
- sort.Slice(entries, func(i, j int) bool { return entries[i].last > entries[j].last })
- for _, stale := range entries[denialLedgerMaxKeys:] {
- delete(ledger.Counts, stale.key)
- }
- }
- value, err := json.Marshal(ledger)
- if err != nil {
- return
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return // fail-calm: persistence is best-effort
- }
- _ = os.WriteFile(path, value, 0o644)
-}
-
-// recordDenial bumps the finding's repeat count in the per-worktree ledger and
-// returns the new count. Every failure mode returns a usable count of at least
-// 1 — bookkeeping can degrade the escalation, never the denial itself.
-func recordDenial(repo string, finding SafetyFinding) int {
- path, ok := denialLedgerPath(repo)
- if !ok {
- return 1
- }
- ledger := loadDenialLedger(path)
- key := denialLedgerKey(finding)
- entry := ledger.Counts[key]
- entry.Count++
- entry.LastUnix = time.Now().Unix()
- ledger.Counts[key] = entry
- saveDenialLedger(path, ledger)
- return entry.Count
-}
-
-// resetDenialLedger clears the ledger. Called when the guard ALLOWS a
-// mutation-capable call: forward progress means the agent is no longer stuck,
-// so stale history must not escalate the next unrelated denial.
-func resetDenialLedger(repo string) {
- path, ok := denialLedgerPath(repo)
- if !ok {
- return
- }
- _ = os.Remove(path)
-}
diff --git a/boatstack/denial_solutions.go b/boatstack/denial_solutions.go
deleted file mode 100644
index a92bb9d..0000000
--- a/boatstack/denial_solutions.go
+++ /dev/null
@@ -1,246 +0,0 @@
-package boatstack
-
-import (
- "path/filepath"
- "strings"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// A denial that only states the law leaves a weaker model looping on the same
-// blocked call. Each denial therefore carries the law's computed solution set:
-// the admissible commands from exactly the position the finding describes,
-// derived from the same declarations the guard enforces (the planning
-// enumeration for phase findings, the registry's observe rows, the
-// state-ownership map for protected paths) — never a hand-written list.
-// The enumeration reads only the finding's own fields and the declared tables,
-// so the deny path stays fast and cannot itself fail on unreadable state.
-// control-law: solution-set-derives-from-guard-declarations
-
-// denialMarker names a denial-prescribed helper verb outside the delivery
-// model, mirroring the planning./recovery. marker convention: self-describing
-// provenance, never a legal registry transition, never auto-driven.
-func denialMarker(verb string) deliverycontrol.TransitionID {
- return deliverycontrol.TransitionID("denial." + strings.ReplaceAll(verb, "-", "_"))
-}
-
-// denialSolutionExceptions are the finding categories that deliberately carry
-// no solution set, with the reason. The totality sweep fails a new category
-// until it gains an enumeration rule or an entry here.
-var denialSolutionExceptions = map[string]string{
- "malformed-tool-input": "the tool event itself is unreadable; the Detail already names diagnose-hook with the exact host",
- "planning-transport-invalid": "the Markdown body is owed input; the Detail names both complete literal transport forms without fabricating content",
- "unsupported-host": "an unknown host has no trusted verb surface to enumerate",
- "unresolved-repository": "without a repository identity no command can be assembled faithfully",
-}
-
-// enumerateDenialSolutions computes the solution set for a denial finding.
-// host is the coding host the hook is serving (for diagnose-hook assembly).
-func enumerateDenialSolutions(repo, host string, finding SafetyFinding) SolutionSet {
- set := SolutionSet{Basis: "denial", Stage: finding.WorkflowStage}
- if _, excepted := denialSolutionExceptions[finding.Category]; excepted {
- return set
- }
- switch {
- case finding.Category == "workflow-phase-bypass", finding.Category == "workflow-state-invalid":
- // The finding carries the exact planning position; re-run the planning
- // enumeration from it. Pure — no filesystem reads on the deny path.
- status := NextStatus{
- ObservedStage: finding.WorkflowStage,
- NextOperation: finding.NextOperation,
- Feature: finding.BlockingFeature,
- }
- if finding.Category == "workflow-state-invalid" {
- status.ObservedStage = "INVALID_STATE"
- if finding.BlockingFeature != "" {
- status.BlockingAmbiguity = []string{finding.BlockingFeature}
- }
- }
- next := FlowNext{}
- if cmd, _ := prescribePlanning(repo, status); cmd != nil {
- next.Prescribed = cmd
- }
- planning := enumeratePlanningSolutions(repo, status, next)
- set.Options, set.Truncated = planning.Options, planning.Truncated
- return set
-
- case finding.Category == "workflow-state-tamper":
- // The state-ownership map already declares who may write the path; the
- // pick list is the position observers plus the hook diagnosis, and the
- // owning verbs surface separately (OwnerVerbs) — a verb whose full
- // arguments we cannot derive is named, never fabricated into a command.
- appendObserveOption(&set, repo, "", "delivery.next")
- appendDiagnoseHook(&set, repo, host)
- return set
-
- case finding.Category == "workflow-publication-bypass":
- if finding.BlockingFeature != "" {
- if cmd, ok := prescribeCommand(repo, finding.BlockingFeature, NextStatus{ActiveSlice: finding.BlockingSlice}, PublishTransition); ok {
- appendSolution(&set, *cmd)
- }
- }
- appendObserveOption(&set, repo, finding.BlockingFeature, "delivery.recovery_status")
- appendObserveOption(&set, repo, "", "delivery.next")
- return set
-
- case finding.Category == "workflow-visual-evidence-missing":
- // Every rung of the recovery ladder, in preference order: run the
- // registered harness, register or provision a harness, record
- // externally captured evidence, or amend the plan's relevance —
- // the only escape for a genuinely nonvisual change.
- captureArgs := repoFlagArgs(repo)
- captureOwed := []string{"--feature"}
- if feature := strings.TrimSpace(finding.BlockingFeature); feature != "" {
- captureArgs = append(captureArgs, "--feature", feature)
- captureOwed = nil
- }
- appendSolution(&set, PrescribedCommand{
- Verb: "capture-evidence", Args: captureArgs,
- RequiresHumanInput: captureOwed, AutoDerivable: len(captureOwed) == 0,
- Transition: denialMarker("capture-evidence"),
- })
- appendSolution(&set, PrescribedCommand{
- Verb: "capability-register", Args: append(repoFlagArgs(repo), "--capability", "visual"),
- RequiresHumanInput: []string{"--command"},
- Transition: denialMarker("capability-register"),
- })
- appendSolution(&set, PrescribedCommand{
- Verb: "provision-capability", Args: append(repoFlagArgs(repo), "--capability", "visual"),
- AutoDerivable: true,
- Transition: denialMarker("provision-capability"),
- })
- appendSolution(&set, PrescribedCommand{
- Verb: "record-pr-visual-evidence", Args: repoFlagArgs(repo),
- RequiresHumanInput: []string{"--manifest"},
- Transition: denialMarker("record-pr-visual-evidence"),
- })
- if feature := strings.TrimSpace(finding.BlockingFeature); feature != "" {
- // The artifact name and its Markdown (stdin) are authored content — owed.
- appendSolution(&set, PrescribedCommand{
- Verb: "planning-write", Args: append(repoFlagArgs(repo), "--feature", feature),
- RequiresHumanInput: []string{"--artifact", planningMarkdownInput},
- Transition: denialMarker("planning-write"),
- })
- }
- return set
-
- case strings.HasPrefix(finding.Category, "operation-"):
- // Observation-only by design: inspect the durable operation state before
- // any retry (the observed-effect discipline).
- appendSolution(&set, PrescribedCommand{
- Verb: "operation-status", Args: repoFlagArgs(repo), AutoDerivable: true,
- Transition: denialMarker("operation-status"),
- })
- appendSolution(&set, PrescribedCommand{
- Verb: "mutation-status", Args: repoFlagArgs(repo), AutoDerivable: true,
- Transition: denialMarker("mutation-status"),
- })
- return set
-
- case finding.Category == "filesystem-destruction":
- // The sanctioned actuator for the one deletion Boatstack owns; the
- // operator confirmation is owed, never assumed.
- appendSolution(&set, PrescribedCommand{
- Verb: "workspace-reap", Args: repoFlagArgs(repo),
- RequiresHumanInput: []string{"--confirm"},
- Transition: denialMarker("workspace-reap"),
- })
- appendDoctor(&set, repo)
- appendObserveOption(&set, repo, "", "delivery.next")
- return set
- }
-
- // Generic fallthrough (destruction families, sync bypass, anything new):
- // the position observers and the installation diagnosis are always legal.
- appendObserveOption(&set, repo, "", "delivery.next")
- appendDoctor(&set, repo)
- return set
-}
-
-// tamperOwnerVerbs derives the owning verbs of a protected path from the
-// state-ownership map: the guard-protected entry whose boatstack subtree the
-// attempted path names. Derived at runtime from StateRegistry — the same
-// declaration the statemap conformance holds to the guard patterns.
-// control-law: every-managed-path-has-a-declared-owner
-func tamperOwnerVerbs(repo, attempted string) []string {
- if attempted == "" {
- return nil
- }
- normalized := filepath_ToSlashLower(attempted)
- w := WorkspaceFor(repo)
- for _, entry := range StateRegistry() {
- if !entry.GuardProtected {
- continue
- }
- sample, err := entry.Sample(w)
- if err != nil {
- continue
- }
- if entry.Class == ClassCommittedInsight {
- root, rootErr := w.InsightDir()
- if rootErr == nil {
- relative, relErr := filepath.Rel(w.RepoRoot, root)
- if relErr == nil && strings.Contains(normalized, filepath_ToSlashLower(relative)) {
- return entry.OwnerVerbs
- }
- }
- }
- key := boatstackSubtreeKey(filepath_ToSlashLower(sample))
- if key != "" && strings.Contains(normalized, "boatstack/"+key) {
- return entry.OwnerVerbs
- }
- }
- return nil
-}
-
-// boatstackSubtreeKey extracts the first path segment after the last
-// "boatstack/" in a sample path — the subtree a guard-protected entry owns.
-func boatstackSubtreeKey(path string) string {
- marker := "boatstack/"
- index := strings.LastIndex(path, marker)
- if index < 0 {
- return ""
- }
- rest := path[index+len(marker):]
- if cut := strings.IndexByte(rest, '/'); cut >= 0 {
- return rest[:cut]
- }
- return rest
-}
-
-// filepath_ToSlashLower normalizes a path for fragment matching across
-// platforms and case conventions.
-func filepath_ToSlashLower(path string) string {
- return strings.ToLower(strings.ReplaceAll(path, "\\", "/"))
-}
-
-func appendObserveOption(set *SolutionSet, repo, feature, transition string) {
- descriptor, ok := deliverycontrol.Transition(deliverycontrol.TransitionID(transition))
- if !ok {
- return
- }
- if cmd, ok := prescribeObserve(repo, feature, descriptor); ok {
- appendSolution(set, *cmd)
- }
-}
-
-func appendDoctor(set *SolutionSet, repo string) {
- appendSolution(set, PrescribedCommand{
- Verb: "doctor", Args: repoFlagArgs(repo), AutoDerivable: true,
- Transition: MarkerRecoveryDoctor,
- })
-}
-
-func appendDiagnoseHook(set *SolutionSet, repo, host string) {
- host = strings.ToLower(strings.TrimSpace(host))
- if host == "" {
- appendDoctor(set, repo)
- return
- }
- appendSolution(set, PrescribedCommand{
- Verb: "diagnose-hook",
- Args: append([]string{"--host", host}, repoFlagArgs(repo)...),
- AutoDerivable: true,
- Transition: denialMarker("diagnose-hook"),
- })
-}
diff --git a/boatstack/denial_solutions_conformance_test.go b/boatstack/denial_solutions_conformance_test.go
deleted file mode 100644
index 8452c44..0000000
--- a/boatstack/denial_solutions_conformance_test.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package boatstack
-
-import (
- "strings"
- "testing"
-)
-
-// control-law: guard-never-prescribes-what-it-would-deny
-// control-law: solution-set-derives-from-guard-declarations
-//
-// Denial-basis conformance for the solution set. A denial that only states the
-// law leaves a weaker model looping on the same blocked call — the sibling
-// harness's paid canary recorded exactly that trajectory: sixteen no-progress
-// repair attempts escalating into a protected-boundary write. Every denial
-// therefore carries computed picks, and these sweeps hold that carrier total
-// (every category enumerates or is a documented exception) and closed (every
-// pick passes the guard's own text laws).
-
-// denialCategoryInventory is one representative finding per category
-// denialFor distinguishes, including the operation-* and generic fallthroughs.
-// Extend it together with denialFor; the totality sweep fails a category that
-// is neither enumerated nor excepted.
-var denialCategoryInventory = []SafetyFinding{
- {Category: "malformed-tool-input", Reason: "empty-command", Source: "hook"},
- {Category: "planning-transport-invalid", Reason: "missing-terminator", Source: "planning-transport"},
- {Category: "workflow-state-invalid", NextOperation: "discard-delivery", BlockingFeature: "stale", Source: "delivery-state"},
- {Category: "workflow-state-tamper", Source: "delivery-state", AttemptedPath: ".git/boatstack/deliveries/demo/state.json"},
- {Category: "workflow-phase-bypass", Source: "planning-state", WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo"},
- {Category: "workflow-phase-bypass", Source: "planning-state", WorkflowStage: "NOT_STARTED", NextOperation: "planning-write", AttemptedPath: ".product-loop/features/demo/plan.md"},
- {Category: "workflow-publication-bypass", BlockingFeature: "demo", BlockingSlice: "s1", Source: "tool-input"},
- {Category: "workflow-visual-evidence-missing", BlockingFeature: "demo", Source: "publication"},
- {Category: "operation-in-flight", OperationID: "op_1", OperationState: "RUNNING", Source: "operation-state"},
- {Category: "operation-already-succeeded", OperationID: "op_2", OperationState: "SUCCEEDED", Source: "operation-state"},
- {Category: "operation-reconciliation-required", OperationID: "op_3", Source: "operation-state"},
- {Category: "operation-retry-exhausted", OperationID: "op_4", Source: "operation-state"},
- {Category: "operation-state-invalid", Source: "operation-state"},
- {Category: "git-history-destruction", Source: "command"},
- {Category: "workspace-sync-bypass", Source: "command"},
- {Category: "filesystem-destruction", Source: "command"},
- {Category: "database-destruction", Source: "command"},
- {Category: "infrastructure-destruction", Source: "command"},
- {Category: "external-resource-destruction", Source: "tool-input"},
- {Category: "unsupported-host", Source: "hook"},
- {Category: "unresolved-repository", Source: "hook"},
- {Category: "symlink-entrypoint", Source: "entry.sh"},
-}
-
-// Totality: every denial category yields a non-empty solution set or sits on
-// the documented exception list with a reason.
-func TestEveryDenialCategoryEnumeratesOrIsExcepted(t *testing.T) {
- for _, finding := range denialCategoryInventory {
- set := enumerateDenialSolutions(".", "claude", finding)
- if reason, excepted := denialSolutionExceptions[finding.Category]; excepted {
- if reason == "" {
- t.Errorf("exception for %s must carry a reason", finding.Category)
- }
- if len(set.Options) != 0 {
- t.Errorf("%s is excepted but enumerates options — remove the stale exception", finding.Category)
- }
- continue
- }
- if len(set.Options) == 0 {
- t.Errorf("category %s enumerates no options and is not a documented exception", finding.Category)
- }
- }
-}
-
-// Closure: every denial pick, after owed-input substitution, passes the
-// text-level guard laws — the managed-state path law and the destruction
-// classifier. The guard never hands out a command it would then deny as text.
-func TestDenialSolutionCommandsPassTheTextGuards(t *testing.T) {
- for _, finding := range denialCategoryInventory {
- set := enumerateDenialSolutions(".", "claude", finding)
- for _, option := range set.Options {
- line := substituteOwedFlags(option.CommandLine())
- if deliveryStatePathPattern.MatchString(line) && !isPureReadOnlyCommand(line) && !approvedUpdatePublisherPattern.MatchString(line) {
- t.Errorf("%s: pick %q names managed state the guard would deny", finding.Category, line)
- }
- if findings := classifySafetyText(line, "command", commandExecutesLiveSQL(line)); len(findings) > 0 {
- t.Errorf("%s: pick %q trips the text guard: %+v", finding.Category, line, findings)
- }
- if option.AutoDerivable != (len(option.RequiresHumanInput) == 0) {
- t.Errorf("%s: AutoDerivable must equal owed-input emptiness: %+v", finding.Category, option)
- }
- for _, owed := range option.RequiresHumanInput {
- for _, arg := range option.Args {
- if arg == owed {
- t.Errorf("%s: owed flag %s fabricated into Args: %+v", finding.Category, owed, option)
- }
- }
- }
- }
- }
-}
-
-// Relation: a phase-bypass denial's picks are exactly guard-admitted at the
-// finding's own stage — the same closure the flow basis holds, entered through
-// the denial door.
-func TestPhaseBypassDenialPicksAreGuardAdmitted(t *testing.T) {
- finding := SafetyFinding{
- Category: "workflow-phase-bypass", Source: "planning-state",
- WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo",
- }
- set := enumerateDenialSolutions(".", "claude", finding)
- if len(set.Options) == 0 {
- t.Fatal("phase-bypass must enumerate picks")
- }
- for _, option := range set.Options {
- line := substituteOwedFlags(option.CommandLine())
- if !controlledPhaseTransition(line, finding.WorkflowStage) {
- t.Errorf("denial pick %q is not admitted at %s", line, finding.WorkflowStage)
- }
- }
-}
-
-// Ownership: a state-tamper denial names the attempted path's declared owner
-// verbs from the state-ownership map, and each named verb is a real one the
-// map declares for that subtree.
-func TestTamperDenialNamesDeclaredOwnerVerbs(t *testing.T) {
- repo := safetyTestRepo(t)
- cases := map[string][]string{
- ".git/boatstack/deliveries/demo/state.json": {"activate-plan", "record-delivery-gate", "record-change", "publish-pr", "repair-state", "discard-delivery"},
- ".git/boatstack/updates/v9.9.9/pr-preview.json": {"prepare-update-pr", "publish-update-pr"},
- ".git/boatstack/mutations/v1/abc.json": {"activate-plan", "undo"},
- ".git/boatstack/quarantine/demo/receipt.json": {"repair-state"},
- "state-root/boatstack/registry.json": {"attach", "detach"},
- ".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "review-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"},
- "boatstack/repositories/sample/binding.json": {"attach", "detach", "activate"},
- "docs/insights/ins-sample/capture.json": {"insight"},
- }
- for attempted, want := range cases {
- got := tamperOwnerVerbs(repo, attempted)
- if strings.Join(got, ",") != strings.Join(want, ",") {
- t.Errorf("owner verbs for %s = %v, want %v", attempted, got, want)
- }
- }
- if got := tamperOwnerVerbs(repo, ""); got != nil {
- t.Errorf("empty attempted path must derive no owners, got %v", got)
- }
- if got := tamperOwnerVerbs(repo, "src/app.ts"); got != nil {
- t.Errorf("unmanaged path must derive no owners, got %v", got)
- }
-}
-
-// Rendering: the plain denial carries the capped "You can:" list; the
-// structured payload carries the full set additively under schema_version 1.
-func TestDenialRenderingCarriesTheSolutionSet(t *testing.T) {
- finding := SafetyFinding{
- Category: "workflow-phase-bypass", Source: "planning-state",
- WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo",
- }
- denial := denialWithOptions(".", "claude", finding)
- if len(denial.Options) == 0 {
- t.Fatal("denial must carry options")
- }
- for _, mode := range []RenderMode{RenderPlain, RenderMarkdown, RenderANSI} {
- out := denial.Render(mode)
- if !strings.Contains(strings.ToLower(out), "you can:") {
- t.Errorf("mode %v must render the You can list:\n%s", mode, out)
- }
- }
- plain := denial.Render(RenderPlain)
- if got := strings.Count(plain, "\n "); got > solutionSetTextCap+1 {
- t.Errorf("plain rendering must cap the pick list, got %d lines:\n%s", got, plain)
- }
- structured := denial.Structured()
- if structured["schema_version"] != 1 {
- t.Fatalf("options are additive; schema_version must stay 1, got %v", structured["schema_version"])
- }
- options, ok := structured["options"].([]map[string]any)
- if !ok || len(options) != len(denial.Options) {
- t.Fatalf("structured options must carry the full set: %v", structured["options"])
- }
- for _, row := range options {
- if row["command_line"] == "" || row["verb"] == "" || row["transition"] == "" {
- t.Errorf("structured option incomplete: %v", row)
- }
- }
-
- // Ownership derivation resolves per-worktree sample paths, which needs a
- // real Git directory — the projected distribution runs these tests outside
- // any repository, so the tamper case uses the git-backed fixture.
- tamper := denialWithOptions(safetyTestRepo(t), "claude", SafetyFinding{
- Category: "workflow-state-tamper", Source: "delivery-state",
- AttemptedPath: ".git/boatstack/deliveries/demo/state.json",
- })
- if len(tamper.OwnerVerbs) == 0 {
- t.Fatal("tamper denial must derive owner verbs")
- }
- if out := tamper.Render(RenderPlain); !strings.Contains(out, "This path is owned by: activate-plan") {
- t.Errorf("tamper rendering must name the owners:\n%s", out)
- }
- if verbs, ok := tamper.Structured()["owner_verbs"].([]string); !ok || len(verbs) == 0 {
- t.Errorf("structured tamper payload must carry owner_verbs")
- }
-}
diff --git a/boatstack/denial_test.go b/boatstack/denial_test.go
deleted file mode 100644
index ddce1bf..0000000
--- a/boatstack/denial_test.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "strings"
- "testing"
-)
-
-func TestDenialRenderModesCarryTheSameInformation(t *testing.T) {
- d := denialFor("claude", SafetyFinding{Category: "workflow-state-tamper"})
-
- plain := d.Render(RenderPlain)
- if !strings.Contains(plain, "Blocked by Boatstack") || !strings.Contains(plain, "managed runtime authority") {
- t.Fatalf("plain missing badge/qualifier: %q", plain)
- }
- if !strings.Contains(plain, ".git/boatstack/") || !strings.Contains(plain, "publish-update-pr") {
- t.Fatalf("plain dropped guidance detail: %q", plain)
- }
- if !strings.Contains(plain, "Nothing was written") {
- t.Fatalf("plain missing reassurance: %q", plain)
- }
- if strings.Contains(plain, "\x1b[") {
- t.Fatalf("plain must not contain ANSI: %q", plain)
- }
-
- md := d.Render(RenderMarkdown)
- if !strings.Contains(md, "**Blocked by Boatstack**") {
- t.Fatalf("markdown missing bold badge: %q", md)
- }
-
- ansi := d.Render(RenderANSI)
- if !strings.Contains(ansi, "\x1b[") || !strings.Contains(ansi, "Blocked by Boatstack") {
- t.Fatalf("ansi missing escape/badge: %q", ansi)
- }
-}
-
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command — a
-// planning-state plan-gate denial names the owned authoring channel
-// (flow bootstrap), not just the cleanup verb, in every render mode.
-func TestPlanningPhaseBypassDenialNamesOwnedChannel(t *testing.T) {
- finding := SafetyFinding{
- Category: "workflow-phase-bypass", Source: "planning-state",
- WorkflowStage: "INVALID_STATE", NextOperation: "repair-state",
- BlockingFeature: "sample-feature",
- }
- d := denialFor("claude", finding)
- for mode, name := range map[RenderMode]string{RenderPlain: "plain", RenderMarkdown: "markdown", RenderANSI: "ansi"} {
- out := d.Render(mode)
- if !strings.Contains(out, "repair-state") {
- t.Fatalf("%s denial dropped the recovery verb: %q", name, out)
- }
- if !strings.Contains(out, "flow bootstrap --feature sample-feature") {
- t.Fatalf("%s denial must name the owned bootstrap channel: %q", name, out)
- }
- }
- // A non-planning finding must not gain the planning guidance.
- other := denialFor("claude", SafetyFinding{Category: "workflow-phase-bypass", Source: "delivery-state", WorkflowStage: "BUILD", NextOperation: "plan-gate"}).Render(RenderPlain)
- if strings.Contains(other, "planning-write") {
- t.Fatalf("non-planning denial must not mention planning-write: %q", other)
- }
-}
-
-func TestDenialReassuranceIsCategoryAware(t *testing.T) {
- // A blocked-before-effect denial reassures that nothing was written.
- tamper := denialFor("claude", SafetyFinding{Category: "workflow-state-tamper"}).Render(RenderPlain)
- if !strings.Contains(tamper, "Nothing was written") {
- t.Fatalf("protected denial should reassure nothing was written: %q", tamper)
- }
- // An already-succeeded operation did have an effect — it must NOT claim nothing happened.
- done := denialFor("claude", SafetyFinding{Category: "operation-already-succeeded", OperationID: "op_1"}).Render(RenderPlain)
- if strings.Contains(done, "Nothing was written") {
- t.Fatalf("already-succeeded must not claim nothing was written: %q", done)
- }
- if !strings.Contains(done, "earlier run's result stands") {
- t.Fatalf("already-succeeded should note the result stands: %q", done)
- }
-}
-
-func TestDenialPreservesMachineTokens(t *testing.T) {
- d := denialFor("cursor", SafetyFinding{Category: "malformed-tool-input", Reason: "empty-command"})
- plain := d.Render(RenderPlain)
- if !strings.Contains(plain, "HOST_PAYLOAD_MALFORMED:empty-command") {
- t.Fatalf("malformed denial dropped machine token: %q", plain)
- }
- if d.Severity != SeverityAdvisory {
- t.Fatalf("malformed input should be advisory, got %v", d.Severity)
- }
-}
-
-func TestDenialGenericFallbackNamesCategory(t *testing.T) {
- d := denialFor("claude", SafetyFinding{Category: "database-destruction"})
- plain := d.Render(RenderPlain)
- if !strings.Contains(plain, "(database-destruction)") {
- t.Fatalf("generic denial should name its category: %q", plain)
- }
- if !strings.Contains(plain, "Nothing was written") {
- t.Fatalf("generic protected denial should reassure: %q", plain)
- }
-}
-
-func TestColorEnabledHonorsEnvAndDevice(t *testing.T) {
- // A regular file is not a character device → auto = no color.
- f, err := os.CreateTemp(t.TempDir(), "notty")
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
-
- t.Setenv("NO_COLOR", "")
- t.Setenv("BOATSTACK_COLOR", "")
- if colorEnabled(f) {
- t.Fatal("auto mode on a regular file must not enable color")
- }
- t.Setenv("BOATSTACK_COLOR", "always")
- if !colorEnabled(f) {
- t.Fatal("BOATSTACK_COLOR=always must force color")
- }
- t.Setenv("BOATSTACK_COLOR", "never")
- if colorEnabled(f) {
- t.Fatal("BOATSTACK_COLOR=never must disable color")
- }
- t.Setenv("BOATSTACK_COLOR", "")
- t.Setenv("NO_COLOR", "1")
- if colorEnabled(f) {
- t.Fatal("NO_COLOR must disable color")
- }
-}
-
-func TestFormatBlockedPreservesPlainPrefix(t *testing.T) {
- f, err := os.CreateTemp(t.TempDir(), "notty")
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
- t.Setenv("NO_COLOR", "")
- t.Setenv("BOATSTACK_COLOR", "")
- if got := FormatBlocked(f, "check-plan requires --plan"); got != "BLOCKED: check-plan requires --plan" {
- t.Fatalf("non-terminal must keep the literal BLOCKED: prefix, got %q", got)
- }
- t.Setenv("BOATSTACK_COLOR", "always")
- got := FormatBlocked(f, "boom")
- if !strings.Contains(got, "\x1b[") || !strings.Contains(got, "Blocked") || !strings.Contains(got, "boom") {
- t.Fatalf("terminal form should be an ANSI pill carrying the message, got %q", got)
- }
-}
-
-func TestStructuredDenialObjectAndRichGate(t *testing.T) {
- finding := SafetyFinding{Category: "workflow-state-tamper"}
- obj := denialFor("claude", finding).Structured()
- if obj["category"] != "workflow-state-tamper" || obj["severity"] != "protected" || obj["badge"] == "" {
- t.Fatalf("structured object missing fields: %+v", obj)
- }
-
- // Rich object is gated off by default; the flat reason stays complete.
- t.Setenv("BOATSTACK_DENIAL_RICH", "")
- out, err := structuredHookDeny(".", "claude", finding)
- if err != nil {
- t.Fatal(err)
- }
- var decoded map[string]any
- if err := json.Unmarshal(out, &decoded); err != nil {
- t.Fatalf("deny output is not valid JSON: %v", err)
- }
- hook, _ := decoded["hookSpecificOutput"].(map[string]any)
- if hook["permissionDecisionReason"] == "" {
- t.Fatal("flat reason must always be present")
- }
- if _, present := hook["boatstackDenial"]; present {
- t.Fatal("structured object must be OFF by default")
- }
-
- // Opt-in adds the object while keeping the flat reason.
- t.Setenv("BOATSTACK_DENIAL_RICH", "1")
- out, err = structuredHookDeny(".", "claude", finding)
- if err != nil {
- t.Fatal(err)
- }
- decoded = map[string]any{}
- if err := json.Unmarshal(out, &decoded); err != nil {
- t.Fatal(err)
- }
- hook, _ = decoded["hookSpecificOutput"].(map[string]any)
- if _, present := hook["boatstackDenial"]; !present {
- t.Fatal("BOATSTACK_DENIAL_RICH=1 must add the structured object")
- }
- if hook["permissionDecisionReason"] == "" {
- t.Fatal("flat reason must remain complete alongside the structured object")
- }
-}
-
-func TestDenialDemoRendersAllSamples(t *testing.T) {
- out := DenialDemo("claude", RenderPlain)
- for _, want := range []string{"managed runtime authority", "plan gate", "HOST_PAYLOAD_MALFORMED", "already completed"} {
- if !strings.Contains(out, want) {
- t.Fatalf("demo missing %q in:\n%s", want, out)
- }
- }
-}
-
-// Guard-script generation stays syntactically emittable and the plain fallback
-// keeps the exact human message (asserts the ANSI helper did not alter wording).
-func TestGuardScriptPlainMessagesUnchanged(t *testing.T) {
- script := string(guardShellScript())
- for _, want := range []string{
- "boatstack/engagement.json",
- "exit 0",
- "shared runtime checksum is invalid; rerun the verified tagged installer.",
- "bs_deny ", "bs_color",
- } {
- if !strings.Contains(script, want) {
- t.Fatalf("guard script missing %q", want)
- }
- }
-}
diff --git a/boatstack/detached.go b/boatstack/detached.go
deleted file mode 100644
index cf6f143..0000000
--- a/boatstack/detached.go
+++ /dev/null
@@ -1,470 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "time"
-)
-
-// Detached Supervision keeps Boatstack's controller-owned state outside the target
-// repository. This file resolves the external control root, computes a stable
-// repository identity, and manages the attachment registry and per-repository
-// binding that let WorkspaceFor return a detached layout for an attached repo.
-
-const (
- // stateRootEnv overrides the external control-state root. Tests inject a temp
- // directory through it so they never read or write a real home directory.
- stateRootEnv = "BOATSTACK_STATE_ROOT"
- // detachedSchemaVersion versions the public detached status and binding
- // records. Version 2 binds exact configuration bytes. Version 3 also records
- // which source owns future configuration changes; version 2 remains readable
- // as LEGACY_UNKNOWN so an update never forces a migration.
- detachedSchemaVersion = 3
- detachedSchemaVersionWithConfigDigest = 2
- // The registry remains a path-to-repository index. Configuration provenance
- // belongs to the authoritative per-repository binding, not this index.
- detachedRegistrySchemaVersion = 1
- // repoIDLength is the hex width of a repository identity key.
- repoIDLength = 16
- // worktreeIDLength is the hex width of a per-worktree identity key.
- worktreeIDLength = 12
-)
-
-// detachedStateRoot returns the base directory for all detached controller state,
-// always ending in a "boatstack" segment. Resolution order: the test/override env
-// var, then the OS-appropriate user state directory, then a home-dir fallback.
-func detachedStateRoot() (string, error) {
- if override := strings.TrimSpace(os.Getenv(stateRootEnv)); override != "" {
- return filepath.Join(override, "boatstack"), nil
- }
- switch runtime.GOOS {
- case "windows":
- if base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); base != "" {
- return filepath.Join(base, "boatstack"), nil
- }
- case "darwin":
- if home, err := os.UserHomeDir(); err == nil {
- return filepath.Join(home, "Library", "Application Support", "boatstack"), nil
- }
- default: // linux and other unix
- if base := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); base != "" {
- return filepath.Join(base, "boatstack"), nil
- }
- if home, err := os.UserHomeDir(); err == nil {
- return filepath.Join(home, ".local", "state", "boatstack"), nil
- }
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("cannot resolve a user state directory for detached Boatstack: %w", err)
- }
- return filepath.Join(home, ".boatstack"), nil
-}
-
-// RepoIdentity is the independently verifiable identity of a repository, used to
-// bind an external control root to exactly one repo and to fail closed when the
-// bound repo's identity no longer matches.
-type RepoIdentity struct {
- RepoID string
- CanonicalRepoPath string
- GitCommonIdentity string
- InitialCommit string
- NormalizedOrigin string
- WorktreeID string
-}
-
-// normalizeOrigin reduces an origin URL to a host/owner/repo key that is stable
-// across https/ssh/git protocols and a trailing .git, so the same remote yields
-// the same repo identity regardless of clone URL form.
-func normalizeOrigin(url string) string {
- value := strings.TrimSpace(url)
- if value == "" {
- return ""
- }
- value = strings.TrimSuffix(value, ".git")
- for _, prefix := range []string{"https://", "http://", "ssh://", "git+ssh://"} {
- value = strings.TrimPrefix(value, prefix)
- }
- value = strings.TrimPrefix(value, "git@")
- // user@host:owner/repo → host/owner/repo
- if at := strings.Index(value, "@"); at >= 0 && at < strings.IndexAny(value+"/", "/") {
- value = value[at+1:]
- }
- value = strings.ReplaceAll(value, ":", "/")
- return strings.ToLower(strings.Trim(value, "/"))
-}
-
-func firstLine(value string) string {
- value = strings.TrimSpace(value)
- if idx := strings.IndexAny(value, "\r\n"); idx >= 0 {
- return strings.TrimSpace(value[:idx])
- }
- return value
-}
-
-// repoIdentity derives a stable identity for the repository containing repo. The
-// repo_id prefers remote-and-history identity (normalized origin + initial commit)
-// so a moved or renamed checkout keeps the same binding; it falls back to the Git
-// common directory only when neither is available (a local-only, history-less
-// repo). The worktree_id isolates per-worktree mutable state.
-func repoIdentity(repo string) (RepoIdentity, error) {
- root, err := ResolveRepository(repo)
- if err != nil {
- return RepoIdentity{}, err
- }
- common, err := gitCommonDir(root)
- if err != nil {
- return RepoIdentity{}, err
- }
- worktreeDir, err := worktreeGitDir(root)
- if err != nil {
- return RepoIdentity{}, err
- }
- initial := firstLine(gitOutput(root, "rev-list", "--max-parents=0", "HEAD"))
- origin := normalizeOrigin(gitOutput(root, "remote", "get-url", "origin"))
-
- seed := origin
- if initial != "" {
- seed += "@" + initial
- }
- if seed == "" {
- // Local-only, history-less repo: fall back to the clone-wide common dir so
- // the identity is at least stable for this clone.
- seed = "gitdir:" + common
- }
- identity := RepoIdentity{
- RepoID: SHA256Bytes([]byte(seed))[:repoIDLength],
- CanonicalRepoPath: root,
- GitCommonIdentity: common,
- InitialCommit: initial,
- NormalizedOrigin: origin,
- WorktreeID: SHA256Bytes([]byte(worktreeDir))[:worktreeIDLength],
- }
- return identity, nil
-}
-
-// DetachedBinding is the per-repository record stored under the external control
-// root. It carries enough independently verifiable identity that a control root
-// can never be applied to the wrong repository.
-type DetachedBinding struct {
- SchemaVersion int `json:"schema_version"`
- Mode string `json:"mode"`
- RepoID string `json:"repo_id"`
- CanonicalRepoPath string `json:"canonical_repo_path"`
- GitCommonIdentity string `json:"git_common_identity"`
- InitialCommit string `json:"initial_commit"`
- NormalizedOrigin string `json:"normalized_origin"`
- ConfigSHA256 string `json:"config_sha256"`
- ConfigAuthority string `json:"config_authority,omitempty"`
- CreatedByVersion string `json:"created_by_version"`
- CreatedAt string `json:"created_at"`
-}
-
-// detachedRegistry is the fast index from a canonical repository path to its
-// repo_id. The per-repository binding.json is authoritative; the registry only
-// speeds the common "is this path attached?" lookup.
-type detachedRegistry struct {
- SchemaVersion int `json:"schema_version"`
- Repositories map[string]string `json:"repositories"`
-}
-
-func registryPath(stateRoot string) string { return filepath.Join(stateRoot, "registry.json") }
-
-func repositoryControlRoot(stateRoot, repoID string) string {
- return filepath.Join(stateRoot, "repositories", repoID)
-}
-
-func bindingPath(stateRoot, repoID string) string {
- return filepath.Join(repositoryControlRoot(stateRoot, repoID), "binding.json")
-}
-
-func loadRegistry(stateRoot string) (detachedRegistry, error) {
- registry := detachedRegistry{SchemaVersion: detachedRegistrySchemaVersion, Repositories: map[string]string{}}
- raw, err := os.ReadFile(registryPath(stateRoot))
- if err != nil {
- if os.IsNotExist(err) {
- return registry, nil
- }
- return registry, err
- }
- if err := DecodeJSON("load detached registry", registryPath(stateRoot), raw, ®istry); err != nil {
- return detachedRegistry{}, err
- }
- if registry.Repositories == nil {
- registry.Repositories = map[string]string{}
- }
- return registry, nil
-}
-
-func saveRegistry(stateRoot string, registry detachedRegistry) error {
- registry.SchemaVersion = detachedRegistrySchemaVersion
- raw, err := MarshalJSON(registry)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(stateRoot, 0o755); err != nil {
- return err
- }
- return os.WriteFile(registryPath(stateRoot), raw, 0o644)
-}
-
-// registerDetachedWorkspaceAlias binds another worktree path of the same Git
-// repository to the existing detached controller. It never creates new
-// authority: origin, initial history, repository id, and Git common directory
-// must match the already-verified source binding.
-func registerDetachedWorkspaceAlias(sourceRepo, destinationRepo string) (bool, error) {
- source, ok, err := detachedContextFor(sourceRepo)
- if err != nil || !ok {
- if err == nil {
- err = fmt.Errorf("source repository is not attached in detached mode")
- }
- return false, err
- }
- destination, err := repoIdentity(destinationRepo)
- if err != nil {
- return false, err
- }
- if destination.RepoID != source.RepoID {
- return false, fmt.Errorf("destination repository identity does not match the detached controller")
- }
- binding, err := loadBinding(filepath.Dir(filepath.Dir(source.controlRoot)), source.RepoID)
- if err != nil {
- // controlRoot is /repositories/; resolve the state root
- // directly when a non-standard layout makes the derivation ambiguous.
- stateRoot, rootErr := detachedStateRoot()
- if rootErr != nil {
- return false, rootErr
- }
- binding, err = loadBinding(stateRoot, source.RepoID)
- }
- if err != nil || !bindingMatchesIdentity(binding, destination) || binding.GitCommonIdentity != destination.GitCommonIdentity {
- return false, fmt.Errorf("destination worktree does not match the detached binding")
- }
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return false, err
- }
- registry, err := loadRegistry(stateRoot)
- if err != nil {
- return false, err
- }
- root := destination.CanonicalRepoPath
- if existing, found := registry.Repositories[root]; found {
- if existing != source.RepoID {
- return false, fmt.Errorf("destination worktree is already bound to another controller")
- }
- return false, nil
- }
- registry.Repositories[root] = source.RepoID
- if err := saveRegistry(stateRoot, registry); err != nil {
- return false, err
- }
- invalidateWorkspaceCache()
- return true, nil
-}
-
-func unregisterDetachedWorkspaceAlias(repo string) error {
- root, err := ResolveRepository(repo)
- if err != nil {
- return err
- }
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return err
- }
- registry, err := loadRegistry(stateRoot)
- if err != nil {
- return err
- }
- if _, found := registry.Repositories[root]; !found {
- return nil
- }
- delete(registry.Repositories, root)
- if err := saveRegistry(stateRoot, registry); err != nil {
- return err
- }
- invalidateWorkspaceCache()
- return nil
-}
-
-func loadBinding(stateRoot, repoID string) (DetachedBinding, error) {
- var binding DetachedBinding
- raw, err := os.ReadFile(bindingPath(stateRoot, repoID))
- if err != nil {
- return DetachedBinding{}, err
- }
- if err := DecodeJSON("load detached binding", bindingPath(stateRoot, repoID), raw, &binding); err != nil {
- return DetachedBinding{}, err
- }
- return binding, nil
-}
-
-// bindingMatchesIdentity reports whether a stored binding still describes the
-// repository now on disk. It compares the strong identity components; the
-// canonical path may legitimately change (a moved checkout) so it is not required
-// to match as long as origin+history do.
-func bindingMatchesIdentity(binding DetachedBinding, identity RepoIdentity) bool {
- if binding.RepoID != identity.RepoID {
- return false
- }
- if binding.NormalizedOrigin != identity.NormalizedOrigin {
- return false
- }
- if binding.InitialCommit != identity.InitialCommit {
- return false
- }
- return true
-}
-
-type detachedGeneratedLock struct {
- ConfigSHA256 string `json:"config_sha256"`
- Files map[string]string `json:"files"`
-}
-
-// verifyDetachedConfiguration proves that the authoritative source copy, its
-// generated snapshot, and the generated runtime configuration still describe
-// the exact bytes accepted at attachment.
-// control-law: detached-config-digest-gates-resume
-func verifyDetachedConfiguration(ctx WorkspaceContext, binding DetachedBinding) error {
- if binding.SchemaVersion < detachedSchemaVersionWithConfigDigest || binding.SchemaVersion > detachedSchemaVersion {
- return fmt.Errorf("detached binding schema_version %d is unsupported; reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", binding.SchemaVersion, ctx.RepoRoot)
- }
- if strings.TrimSpace(binding.ConfigSHA256) == "" {
- return fmt.Errorf("detached binding is missing config_sha256; reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", ctx.RepoRoot)
- }
- sourceSHA, err := SHA256File(ctx.SourceConfigPath())
- if err != nil {
- return fmt.Errorf("detached project configuration is missing or unreadable: %w", err)
- }
- if sourceSHA != binding.ConfigSHA256 {
- return fmt.Errorf("detached project configuration drifted from bound SHA-256 %s; restore the exact attached bytes or reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", binding.ConfigSHA256, ctx.RepoRoot)
- }
- lockPath := filepath.Join(ctx.GeneratedRoot(), "generated.lock.json")
- lockRaw, err := os.ReadFile(lockPath)
- if err != nil {
- return fmt.Errorf("detached generated configuration snapshot is missing or unreadable: %w", err)
- }
- var lock detachedGeneratedLock
- if err := DecodeJSON("verify detached generated configuration snapshot", lockPath, lockRaw, &lock); err != nil {
- return err
- }
- if lock.ConfigSHA256 != binding.ConfigSHA256 {
- return fmt.Errorf("detached generated configuration snapshot does not match bound SHA-256 %s", binding.ConfigSHA256)
- }
- expectedProjectSHA := lock.Files[productLoopDirName+"/project.json"]
- if expectedProjectSHA == "" {
- return fmt.Errorf("detached generated configuration snapshot does not bind %s/project.json", productLoopDirName)
- }
- projectSHA, err := SHA256File(ctx.ProjectConfigPath())
- if err != nil {
- return fmt.Errorf("detached generated project configuration is missing or unreadable: %w", err)
- }
- if projectSHA != expectedProjectSHA {
- return fmt.Errorf("detached generated project configuration drifted from its snapshot")
- }
- return nil
-}
-
-const (
- ConfigAuthorityRepository = "REPOSITORY"
- ConfigAuthorityExternalSnapshot = "EXTERNAL_SNAPSHOT"
- ConfigAuthoritySynthesized = "SYNTHESIZED"
- ConfigAuthorityLegacyUnknown = "LEGACY_UNKNOWN"
-
- ConfigRelationMatch = "MATCH"
- ConfigRelationDiverged = "DIVERGED"
- ConfigRelationIndependent = "INDEPENDENT"
- ConfigRelationRepositoryAbsent = "REPOSITORY_ABSENT"
-)
-
-func normalizedConfigAuthority(binding DetachedBinding) string {
- if binding.SchemaVersion < detachedSchemaVersion || strings.TrimSpace(binding.ConfigAuthority) == "" {
- return ConfigAuthorityLegacyUnknown
- }
- switch binding.ConfigAuthority {
- case ConfigAuthorityRepository, ConfigAuthorityExternalSnapshot, ConfigAuthoritySynthesized:
- return binding.ConfigAuthority
- default:
- return ConfigAuthorityLegacyUnknown
- }
-}
-
-// detachedContextFor returns the detached WorkspaceContext for repo when the
-// repository is attached and its binding verifies. ok is false for an unattached
-// repository (the caller should use the embedded layout). err is non-nil only for
-// an attached-but-unverifiable repository — the fail-closed case.
-func detachedContextFor(repo string) (ctx WorkspaceContext, ok bool, err error) {
- stateRoot, rootErr := detachedStateRoot()
- if rootErr != nil {
- return WorkspaceContext{}, false, nil // no external root resolvable → treat as embedded
- }
- registry, regErr := loadRegistry(stateRoot)
- if regErr != nil {
- return WorkspaceContext{}, false, regErr
- }
- if len(registry.Repositories) == 0 {
- return WorkspaceContext{}, false, nil // nothing attached anywhere → embedded, no git calls
- }
- repoID, listed := registry.Repositories[repo]
- if !listed {
- // The caller may have passed a non-canonical path; resolve the root once
- // (only reached when a detached registry actually has entries).
- root, resolveErr := ResolveRepository(repo)
- if resolveErr != nil {
- return WorkspaceContext{}, false, nil
- }
- if repoID, listed = registry.Repositories[root]; !listed {
- return WorkspaceContext{}, false, nil
- }
- repo = root
- }
- identity, idErr := repoIdentity(repo)
- if idErr != nil {
- return WorkspaceContext{}, true, fmt.Errorf("detached binding cannot be verified: %w", idErr)
- }
- binding, bindErr := loadBinding(stateRoot, repoID)
- ctx = detachedContextFromIdentity(stateRoot, identity)
- if bindErr != nil {
- return ctx, true, fmt.Errorf("detached binding for %s is missing or unreadable: %w", repo, bindErr)
- }
- if !bindingMatchesIdentity(binding, identity) {
- return ctx, true, fmt.Errorf("detached binding does not match this repository's identity; reattach with `boatstack-helper attach` or migrate the binding")
- }
- if configErr := verifyDetachedConfiguration(ctx, binding); configErr != nil {
- return ctx, true, configErr
- }
- return ctx, true, nil
-}
-
-// detachedContextFromIdentity builds the detached WorkspaceContext for a resolved
-// identity under a state root. Runtimes are shared across all detached repos
-// (stateRoot), per-repository generated/config state lives under the repo control
-// root, and mutable per-worktree state lives under a worktree subdirectory.
-func detachedContextFromIdentity(stateRoot string, identity RepoIdentity) WorkspaceContext {
- control := repositoryControlRoot(stateRoot, identity.RepoID)
- return WorkspaceContext{
- Mode: SupervisionDetached,
- RepoRoot: identity.CanonicalRepoPath,
- RepoID: identity.RepoID,
- WorktreeID: identity.WorktreeID,
- controlRoot: control,
- sharedControlRoot: stateRoot,
- worktreeControlRoot: filepath.Join(control, "worktrees", identity.WorktreeID),
- }
-}
-
-func nowRFC3339() string { return operationNow().UTC().Truncate(time.Second).Format(time.RFC3339) }
-
-// RepositoryIsManaged reports whether Boatstack supervises a repository — either
-// through a detached attachment (verified or not: an attached-but-broken repo is
-// still managed and must fail closed) or an embedded in-repo install. It is the
-// gate a developer-level guard uses to leave unmanaged repositories uncontrolled.
-func RepositoryIsManaged(repo string) bool {
- if _, ok, _ := detachedContextFor(repo); ok {
- return true
- }
- return fileExists(filepath.Join(repo, productLoopDirName, "project.json"))
-}
diff --git a/boatstack/detached_command_admission_conformance_test.go b/boatstack/detached_command_admission_conformance_test.go
deleted file mode 100644
index c3421dc..0000000
--- a/boatstack/detached_command_admission_conformance_test.go
+++ /dev/null
@@ -1,204 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func detachedPolicyReadyFixture(t *testing.T) (string, WorkspaceContext, FlowNext) {
- t.Helper()
- repo := detachedTestRepo(t, "https://github.com/acme/detached-command-admission.git")
- // Exercise the real macOS failure shape: the trusted helper path contains a
- // space and therefore must survive rendering and literal parsing unchanged.
- t.Setenv(stateRootEnv, filepath.Join(t.TempDir(), "Application Support"))
- invalidateWorkspaceCache()
- embeddedFeatureForDetach(t, repo, "feature-one", "")
- result, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach detached fixture: %+v %v", result, err)
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil || workspace.Mode != SupervisionDetached {
- t.Fatalf("resolve detached workspace: %+v %v", workspace, err)
- }
- next, err := NextControl(repo, "feature-one")
- if err != nil || next.Prescribed == nil || next.Prescribed.Verb != "activate-plan" {
- t.Fatalf("policy-ready fixture did not prescribe activation: %+v %v", next, err)
- }
- return repo, workspace, next
-}
-
-// Positive and relation conformance for control-law:
-// guard-never-denies-an-owned-transition.
-func TestDetachedPrescribedActivationPassesEveryHostGuard(t *testing.T) {
- repo, _, next := detachedPolicyReadyFixture(t)
- command := next.Prescribed.CommandLine()
- words, complete := literalCommandWords(strings.TrimPrefix(command, "& "))
- if !complete || len(words) < 2 || words[0] != next.Prescribed.Program || !strings.Contains(words[0], "Application Support") {
- t.Fatalf("fixture lost the path-with-spaces witness: %q", command)
- }
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Fatalf("guard denied its exact detached activation prescription: %+v\n%s", findings, command)
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- t.Run(host, func(t *testing.T) {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)}); denied {
- t.Fatalf("%s denied the exact owned transition: %s", host, output)
- }
- })
- }
-}
-
-// Negative, bypass, and failure-state conformance for control-law:
-// guard-never-denies-an-owned-transition. Only the exact bound helper and its
-// same-workspace paths receive semantic admission; lexical lookalikes retain the
-// controller-state tamper denial.
-func TestDetachedCommandAdmissionRejectsUnownedAndWrongStageForms(t *testing.T) {
- repo, workspace, next := detachedPolicyReadyFixture(t)
- valid := next.Prescribed.CommandLine()
- otherRoot := filepath.Join(filepath.Dir(workspace.controlRoot), "ffffffffffffffff")
- otherHelper := filepath.Join(otherRoot, productLoopDirName, "bin", helperName())
- otherOutput := filepath.Join(otherRoot, productLoopDirName, "features", "feature-one", "plan.lock.json")
- otherRepo := t.TempDir()
- wrongStage := PrescribedCommand{
- Program: workspace.HelperPath(), Verb: "record-approval",
- Args: []string{"--plan", filepath.Join(workspace.FeatureDir("feature-one"), "plan.md")},
- }.CommandLine()
-
- tests := []struct {
- name string
- command string
- category string
- }{
- {"sibling helper", strings.Replace(valid, posixPlanningWord(workspace.HelperPath()), posixPlanningWord(otherHelper), 1), "workflow-state-tamper"},
- {"mixed controller roots", strings.Replace(valid, posixPlanningWord(filepath.Join(workspace.FeatureDir("feature-one"), "plan.lock.json")), posixPlanningWord(otherOutput), 1), "workflow-state-tamper"},
- {"conflicting repository flags", valid + " --repo " + posixPlanningWord(otherRepo), "workflow-state-tamper"},
- {"conflicting feature flags", valid + " --feature feature-two", "workflow-state-tamper"},
- {"compound command", valid + " ; echo bypass", "workflow-state-tamper"},
- {"wrong stage", wrongStage, "workflow-phase-bypass"},
- {"raw controller deletion", "rm -rf " + posixPlanningWord(workspace.controlRoot), "workflow-state-tamper"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- findings := ClassifyCommand(repo, test.command)
- if len(findings) == 0 || findings[0].Category != test.category {
- t.Fatalf("%s escaped the boundary: %+v\n%s", test.name, findings, test.command)
- }
- })
- }
- if status, err := ResolveNext(repo, "feature-one"); err != nil || status.ObservedStage != "POLICY_READY" {
- t.Fatalf("denials changed protected workflow state: %+v %v", status, err)
- }
-}
-
-// Relation conformance for control-law: active-delivery-effects-are-supervised.
-// Boatstack's own read-only detached observations are not external effects and
-// must never create a competing generic operation receipt.
-func TestDetachedReadOnlyHelperIsReceiptFreeDuringActiveDelivery(t *testing.T) {
- repo, workspace, _ := detachedPolicyReadyFixture(t)
- directory := workspace.FeatureDir("feature-one")
- lockPath := filepath.Join(directory, "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "feature-one", PlanLockHash: lockHash,
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "BUILD", BaseBranch: "main", HeadBranch: "main"}},
- }); err != nil {
- t.Fatal(err)
- }
- command := PrescribedCommand{
- Program: workspace.HelperPath(), Verb: "next-status",
- Args: []string{"--repo", repo, "--feature", "feature-one"},
- }.CommandLine()
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)}); denied {
- t.Fatalf("%s denied a detached read-only observation: %s", host, output)
- }
- status, statusErr := ResolveOperationStatus(repo, "")
- if statusErr != nil || status.Operation != nil {
- t.Fatalf("%s created a generic operation receipt: %+v %v", host, status, statusErr)
- }
- }
-}
-
-// Positive, negative, and relation conformance for control-law:
-// guard-never-denies-an-owned-transition. The opt-in driver is an owned
-// coordinator only when its feature scope is explicit; the helper retains the
-// inner AutoDerivable and transition-allowlist gates.
-func TestDetachedFlowExecuteCoordinatorRequiresExplicitFeature(t *testing.T) {
- repo, workspace, _ := detachedPolicyReadyFixture(t)
- command := PrescribedCommand{
- Program: workspace.HelperPath(), Verb: "flow",
- Args: []string{"next", "--repo", repo, "--feature", "feature-one", "--execute"},
- }.CommandLine()
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Fatalf("guard denied the exact feature-scoped flow coordinator: %+v", findings)
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)}); denied {
- t.Fatalf("%s denied the exact feature-scoped flow coordinator: %s", host, output)
- }
- }
-
- unscoped := strings.Replace(command, " --feature feature-one", "", 1)
- if findings := ClassifyCommand(repo, unscoped); len(findings) == 0 || findings[0].Category != "workflow-state-tamper" {
- t.Fatalf("unscoped execute coordinator crossed the protected boundary: %+v", findings)
- }
- malformed := command + " --unknown"
- if findings := ClassifyCommand(repo, malformed); len(findings) == 0 {
- t.Fatalf("malformed execute coordinator crossed the protected boundary: %+v", findings)
- }
-}
-
-// Relation conformance for control-law: guard-never-denies-an-owned-transition.
-// Feed the real detached solution set back through the full classifier at each
-// live delivery stage so helper-path protection and the workflow oracle cannot
-// drift independently again.
-func TestDetachedDeliverySolutionSetPassesFullClassifier(t *testing.T) {
- for _, stage := range []string{"BUILD", "TEST_PASSED", "REVIEW_PASSED"} {
- t.Run(stage, func(t *testing.T) {
- repo, workspace, _ := detachedPolicyReadyFixture(t)
- lockPath := filepath.Join(workspace.FeatureDir("feature-one"), "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "feature-one", PlanLockHash: lockHash,
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: stage, BaseBranch: "main", HeadBranch: "main"}},
- }); err != nil {
- t.Fatal(err)
- }
- next, err := NextControl(repo, "feature-one")
- if err != nil {
- t.Fatal(err)
- }
- options := append([]PrescribedCommand{}, next.Alternatives...)
- if next.Prescribed != nil {
- options = append(options, *next.Prescribed)
- }
- if len(options) == 0 {
- t.Fatalf("%s exposed no legal solution", stage)
- }
- for _, option := range options {
- if option.Program == "gh" {
- continue
- }
- command := substituteOwedFlags(option.CommandLine())
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Errorf("%s guard denied its prescribed %q: %+v", stage, command, findings)
- }
- }
- })
- }
-}
diff --git a/boatstack/detached_external_config_conformance_test.go b/boatstack/detached_external_config_conformance_test.go
deleted file mode 100644
index aea2954..0000000
--- a/boatstack/detached_external_config_conformance_test.go
+++ /dev/null
@@ -1,335 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "testing"
-)
-
-func externalConfigFixture(t *testing.T, name, command string) (string, []byte) {
- t.Helper()
- directory := t.TempDir()
- path := filepath.Join(directory, "project.json")
- raw := []byte(`{"schema_version":1,"project":{"name":"` + name + `","commands":{"test":"` + command + `"}}}` + "\n")
- if err := os.WriteFile(path, raw, 0o644); err != nil {
- t.Fatal(err)
- }
- return path, raw
-}
-
-func filesystemSnapshot(t *testing.T, root string) string {
- t.Helper()
- entries := []string{}
- err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
- relative, relativeErr := filepath.Rel(root, path)
- if relativeErr != nil {
- return relativeErr
- }
- // git maintenance creates and removes this advisory lock independently of
- // the operation under test. It is not repository content and may disappear
- // between WalkDir reading the directory and lstatting the entry on macOS.
- if filepath.ToSlash(relative) == ".git/objects/maintenance.lock" {
- return nil
- }
- if walkErr != nil {
- return walkErr
- }
- info, err := entry.Info()
- if err != nil {
- return err
- }
- line := filepath.ToSlash(relative) + " " + info.Mode().String()
- if info.Mode().IsRegular() {
- digest, err := SHA256File(path)
- if err != nil {
- return err
- }
- line += " " + digest
- } else if info.Mode()&os.ModeSymlink != 0 {
- target, err := os.Readlink(path)
- if err != nil {
- return err
- }
- line += " " + target
- }
- entries = append(entries, line)
- return nil
- })
- if err != nil {
- t.Fatal(err)
- }
- sort.Strings(entries)
- return strings.Join(entries, "\n")
-}
-
-// control-law: detached-config-digest-gates-resume
-func TestDetachedAttachAcceptsExternalConfigWithoutPlantWrites(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/external-config.git")
- configPath, raw := externalConfigFixture(t, "works-yield", "pnpm --filter @works/yield-web test")
- before := filesystemSnapshot(t, repo)
-
- result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- if after := filesystemSnapshot(t, repo); after != before {
- t.Fatal("external configuration attachment changed repository or Git bytes")
- }
- wantSHA := SHA256Bytes(raw)
- if result.SchemaVersion != detachedSchemaVersion || result.ConfigSHA256 != wantSHA {
- t.Fatalf("attach digest = %q schema=%d, want %q schema=%d", result.ConfigSHA256, result.SchemaVersion, wantSHA, detachedSchemaVersion)
- }
- ctx := WorkspaceFor(repo)
- copied, err := os.ReadFile(ctx.SourceConfigPath())
- if err != nil || string(copied) != string(raw) {
- t.Fatalf("external source copy = %q, %v", copied, err)
- }
- generated, _, err := LoadConfig(ctx.ProjectConfigPath())
- if err != nil || generated.Project.Name != "works-yield" || generated.Project.Commands["test"] != "pnpm --filter @works/yield-web test" {
- t.Fatalf("generated config did not preserve supplied values: %+v %v", generated, err)
- }
- stateRoot, _ := detachedStateRoot()
- binding, err := loadBinding(stateRoot, result.RepoID)
- if err != nil || binding.ConfigSHA256 != wantSHA || binding.SchemaVersion != detachedSchemaVersion {
- t.Fatalf("binding did not capture config digest: %+v %v", binding, err)
- }
- lockRaw, err := os.ReadFile(filepath.Join(ctx.GeneratedRoot(), "generated.lock.json"))
- if err != nil {
- t.Fatal(err)
- }
- var lock detachedGeneratedLock
- if err := json.Unmarshal(lockRaw, &lock); err != nil || lock.ConfigSHA256 != wantSHA {
- t.Fatalf("generated lock did not capture config digest: %+v %v", lock, err)
- }
- status, _ := DetachedStatus(repo)
- if !status.Verified || status.ConfigSHA256 != wantSHA || status.SchemaVersion != detachedSchemaVersion {
- t.Fatalf("detached status did not bind config digest: %+v", status)
- }
-
- // Attachment is copy-based. Later changes to the input path are not live
- // configuration changes and cannot alter the bound detached snapshot.
- if err := os.WriteFile(configPath, []byte(`{"schema_version":2}`), 0o644); err != nil {
- t.Fatal(err)
- }
- status, _ = DetachedStatus(repo)
- if !status.Verified || status.ConfigSHA256 != wantSHA {
- t.Fatalf("changing original input changed detached attachment: %+v", status)
- }
-}
-
-// control-law: detached-config-input-stays-outside-plant
-func TestDetachedAttachRejectsInvalidOrNonExternalConfigBeforeWrites(t *testing.T) {
- tests := []struct {
- name string
- build func(*testing.T, string) string
- want string
- }{
- {name: "missing", build: func(t *testing.T, _ string) string { return filepath.Join(t.TempDir(), "missing.json") }, want: "missing or unreadable"},
- {name: "malformed", build: func(t *testing.T, _ string) string {
- p := filepath.Join(t.TempDir(), "project.json")
- _ = os.WriteFile(p, []byte("{\n"), 0o644)
- return p
- }, want: "parse JSON"},
- {name: "newer schema", build: func(t *testing.T, _ string) string {
- p := filepath.Join(t.TempDir(), "project.json")
- _ = os.WriteFile(p, []byte(`{"schema_version":2,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644)
- return p
- }, want: "newer Boatstack"},
- {name: "repository local", build: func(t *testing.T, repo string) string {
- p := filepath.Join(repo, "project.json")
- _ = os.WriteFile(p, []byte(`{"schema_version":1,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644)
- return p
- }, want: "outside the repository"},
- {name: "git local", build: func(t *testing.T, repo string) string {
- gitDir, _ := gitCommonDir(repo)
- p := filepath.Join(gitDir, "project.json")
- _ = os.WriteFile(p, []byte(`{"schema_version":1,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644)
- return p
- }, want: "outside the repository"},
- {name: "symlink", build: func(t *testing.T, _ string) string {
- real, _ := externalConfigFixture(t, "x", "true")
- link := filepath.Join(t.TempDir(), "project.json")
- _ = os.Symlink(real, link)
- return link
- }, want: "non-symlink"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/reject-"+strings.ReplaceAll(test.name, " ", "-")+".git")
- path := test.build(t, repo)
- result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: path})
- if err != nil || result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, test.want) {
- t.Fatalf("result = %+v, err=%v, want %q", result, err, test.want)
- }
- stateRoot, _ := detachedStateRoot()
- if _, statErr := os.Stat(stateRoot); !os.IsNotExist(statErr) {
- t.Fatalf("rejected config wrote detached state: %v", statErr)
- }
- })
- }
-}
-
-func assertDetachedDriftBlocked(t *testing.T, repo, want string) {
- t.Helper()
- status, err := DetachedStatus(repo)
- if err != nil || !status.Attached || status.Verified || status.ConfigSHA256 == "" || !strings.Contains(status.Reason, want) {
- t.Fatalf("status did not report %q drift: %+v %v", want, status, err)
- }
- next, err := ResolveNext(repo, "")
- if err != nil || next.VerificationStatus != "BLOCKED" || next.NextOperation != "attach" {
- t.Fatalf("next-status did not fail closed: %+v %v", next, err)
- }
- recovery, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "fix", SourceStage: "ci"})
- if err != nil || recovery.VerificationStatus != "BLOCKED" {
- t.Fatalf("recovery-status did not fail closed: %+v %v", recovery, err)
- }
- preflight := CheckRunPreflight(repo, "")
- if preflight.VerificationStatus != "BLOCKED" || preflight.Relation != "DETACHED_CONFIG_DRIFT" {
- t.Fatalf("run-preflight did not fail closed: %+v", preflight)
- }
- if _, err := LoadDeliveryState(repo, "missing-feature"); err == nil || !strings.Contains(err.Error(), want) {
- t.Fatalf("delivery state bypassed detached verification: %v", err)
- }
-}
-
-// control-law: detached-config-digest-gates-resume
-func TestDetachedConfigDriftBlocksResumeAndRestoresExactly(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/drift.git")
- configPath, _ := externalConfigFixture(t, "drift", "true")
- result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- ctx := WorkspaceFor(repo)
-
- sourcePath := ctx.SourceConfigPath()
- source, _ := os.ReadFile(sourcePath)
- if err := os.WriteFile(sourcePath, append(source, ' '), 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- assertDetachedDriftBlocked(t, repo, "drifted from bound SHA-256")
- if WorkspaceFor(repo).ProjectConfigPath() != ctx.ProjectConfigPath() {
- t.Fatal("unverified attachment redirected controller paths into repository")
- }
- if err := os.WriteFile(sourcePath, source, 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- if status, _ := DetachedStatus(repo); !status.Verified {
- t.Fatalf("exact source restoration did not recover verification: %+v", status)
- }
-
- projectPath := ctx.ProjectConfigPath()
- project, _ := os.ReadFile(projectPath)
- if err := os.WriteFile(projectPath, append(project, ' '), 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- assertDetachedDriftBlocked(t, repo, "generated project configuration drifted")
- if err := os.WriteFile(projectPath, project, 0o644); err != nil {
- t.Fatal(err)
- }
-
- lockPath := filepath.Join(ctx.GeneratedRoot(), "generated.lock.json")
- lock, _ := os.ReadFile(lockPath)
- var changed map[string]any
- if err := json.Unmarshal(lock, &changed); err != nil {
- t.Fatal(err)
- }
- changed["config_sha256"] = strings.Repeat("0", 64)
- changedRaw, _ := MarshalJSON(changed)
- if err := os.WriteFile(lockPath, changedRaw, 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- assertDetachedDriftBlocked(t, repo, "snapshot does not match")
- if err := os.WriteFile(lockPath, lock, 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- if status, _ := DetachedStatus(repo); !status.Verified {
- t.Fatalf("exact generated-state restoration did not recover verification: %+v", status)
- }
-}
-
-// control-law: detached-config-digest-gates-resume
-func TestDetachedConfigDriftBlocksMutationAndPublicationBypasses(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/drift-bypass.git")
- embeddedFeatureForDetach(t, repo, "feature-one", "")
- configPath, _ := externalConfigFixture(t, "drift-bypass", "true")
- result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- ctx := WorkspaceFor(repo)
- source, _ := os.ReadFile(ctx.SourceConfigPath())
- if err := os.WriteFile(ctx.SourceConfigPath(), append(source, ' '), 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- before := filesystemSnapshot(t, ctx.controlRoot)
- planPath := filepath.Join(ctx.FeatureDir("feature-one"), "plan.md")
-
- checks := []struct {
- name string
- run func() error
- }{
- {name: "activation", run: func() error {
- return ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: planPath, OutDir: filepath.Join(ctx.FeatureDir("feature-one"), "compiled"),
- OutputPath: filepath.Join(ctx.FeatureDir("feature-one"), "plan.lock.json"), SourceCommit: "test",
- })
- }},
- {name: "repair", run: func() error {
- _, _, err := RecordChangeObservation(ChangeObservationOptions{Repo: repo, Feature: "feature-one", Classification: "implementation_repair", Message: "fix", SourceStage: "ci"})
- return err
- }},
- {name: "gate", run: func() error {
- _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: "feature-one", SliceID: "delivery", Gate: "test", Status: "PASS"})
- return err
- }},
- {name: "pr-context", run: func() error {
- _, err := PreparePRContext(PRContextOptions{Repo: repo})
- return err
- }},
- {name: "publish", run: func() error {
- _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: "missing.md", ExpectedFingerprint: "missing", Action: "open"})
- return err
- }},
- {name: "operation", run: func() error {
- _, err := PrepareOperation(OperationPrepareOptions{Repo: repo, Kind: "test", Target: "target", PackageFingerprint: "package", ExpectedPostcondition: "done", RetryClass: "ATOMIC_LOCAL"})
- return err
- }},
- }
- for _, check := range checks {
- t.Run(check.name, func(t *testing.T) {
- if err := check.run(); err == nil || !strings.Contains(err.Error(), "drifted from bound SHA-256") {
- t.Fatalf("entry point did not reach detached config boundary: %v", err)
- }
- })
- }
- if after := filesystemSnapshot(t, ctx.controlRoot); after != before {
- t.Fatal("blocked resume path mutated detached controller state")
- }
-}
-
-// control-law: detached-config-rebinding-requires-explicit-force
-func TestDetachedForceReattachRebindsExternalConfig(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/rebind.git")
- firstPath, firstRaw := externalConfigFixture(t, "first", "true")
- first, _ := AttachDetached(AttachOptions{Repo: repo, ConfigPath: firstPath})
- secondPath, secondRaw := externalConfigFixture(t, "second", "pnpm test")
- blocked, _ := AttachDetached(AttachOptions{Repo: repo, ConfigPath: secondPath})
- if blocked.VerificationStatus != "BLOCKED" {
- t.Fatalf("reattach without force succeeded: %+v", blocked)
- }
- second, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: secondPath, Force: true})
- if err != nil || second.VerificationStatus != "VERIFIED" || second.ConfigSHA256 != SHA256Bytes(secondRaw) || second.ConfigSHA256 == SHA256Bytes(firstRaw) || second.ConfigSHA256 == first.ConfigSHA256 {
- t.Fatalf("forced reattach did not rebind config: first=%+v second=%+v err=%v", first, second, err)
- }
-}
diff --git a/boatstack/detached_migration.go b/boatstack/detached_migration.go
deleted file mode 100644
index 39a6795..0000000
--- a/boatstack/detached_migration.go
+++ /dev/null
@@ -1,268 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strings"
-)
-
-// DetachedFeatureMigration reports one embedded feature package considered by
-// explicit attachment repair. Status is IMPORTED, UNCHANGED, CONFLICTING, or
-// REJECTED; the vocabulary is stable for host adapters.
-type DetachedFeatureMigration struct {
- Feature string `json:"feature"`
- Status string `json:"status"`
- Reason string `json:"reason"`
-}
-
-type detachedFeatureImport struct {
- feature string
- source string
- target string
-}
-
-var detachedImportBeforeRename func(source, temporary, target string) error
-
-func directoryFingerprint(root string) (string, error) {
- info, err := os.Lstat(root)
- if err != nil {
- return "", err
- }
- if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
- return "", fmt.Errorf("feature package root is not a real directory: %s", root)
- }
- parts := []string{}
- err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if path == root {
- return nil
- }
- relative, err := filepath.Rel(root, path)
- if err != nil {
- return err
- }
- if entry.Type()&os.ModeSymlink != 0 {
- return fmt.Errorf("feature package contains a symlink: %s", relative)
- }
- if entry.IsDir() {
- parts = append(parts, filepath.ToSlash(relative)+"/")
- return nil
- }
- if !entry.Type().IsRegular() {
- return fmt.Errorf("feature package contains a non-regular file: %s", relative)
- }
- hash, err := SHA256File(path)
- if err != nil {
- return err
- }
- parts = append(parts, filepath.ToSlash(relative)+"\x00"+hash)
- return nil
- })
- if err != nil {
- return "", err
- }
- sort.Strings(parts)
- return SHA256Bytes([]byte(joinNUL(parts))), nil
-}
-
-func joinNUL(values []string) string {
- result := ""
- for index, value := range values {
- if index > 0 {
- result += "\x00"
- }
- result += value
- }
- return result
-}
-
-func validateEmbeddedFeaturePackage(repo, directory, feature string) error {
- check, err := CheckPlan(filepath.Join(directory, "plan.md"))
- if err != nil {
- return err
- }
- if stringValue(check.Plan["feature_id"]) != feature {
- return fmt.Errorf("plan feature_id does not match directory")
- }
- if path := filepath.Join(directory, "approval.md"); fileExists(path) {
- receipt, loadErr := LoadApprovalReceipt(path)
- if loadErr != nil || receipt.Fingerprint != check.Fingerprint {
- return fmt.Errorf("approval receipt fingerprint is invalid or stale")
- }
- }
- if path := filepath.Join(directory, "autonomy.md"); fileExists(path) {
- value, loadErr := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true)
- if loadErr != nil {
- return loadErr
- }
- data, marshalErr := MarshalJSON(value)
- if marshalErr != nil {
- return marshalErr
- }
- var receipt AutonomyReceipt
- if decodeErr := DecodeJSON("autonomy receipt", path, data, &receipt); decodeErr != nil {
- return decodeErr
- }
- fingerprint, fingerprintErr := autonomyFingerprint(receipt)
- if fingerprintErr != nil || fingerprint != receipt.Fingerprint || receipt.Feature != feature || receipt.PlanFingerprint != check.Fingerprint {
- return fmt.Errorf("autonomy receipt fingerprint is invalid or stale")
- }
- }
- return nil
-}
-
-func planDetachedFeatureImports(repo string, ctx WorkspaceContext) ([]detachedFeatureImport, []DetachedFeatureMigration, error) {
- sourceRoot := filepath.Join(repo, productLoopDirName, "features")
- entries, err := os.ReadDir(sourceRoot)
- if os.IsNotExist(err) {
- return nil, nil, nil
- }
- if err != nil {
- return nil, nil, err
- }
- candidates := []string{}
- for _, entry := range entries {
- if entry.IsDir() && featureSlugPattern.MatchString(entry.Name()) && fileExists(filepath.Join(sourceRoot, entry.Name(), "plan.md")) {
- candidates = append(candidates, entry.Name())
- }
- }
- selected := detachedOpenFeatureCandidates(repo, candidates)
- imports := []detachedFeatureImport{}
- results := []DetachedFeatureMigration{}
- blocked := false
- for _, entry := range entries {
- feature := entry.Name()
- if !selected[feature] {
- continue
- }
- source := filepath.Join(sourceRoot, feature)
- target := ctx.FeatureDir(feature)
- if err := validateEmbeddedFeaturePackage(repo, source, feature); err != nil {
- results = append(results, DetachedFeatureMigration{Feature: feature, Status: "REJECTED", Reason: err.Error()})
- blocked = true
- continue
- }
- sourceHash, err := directoryFingerprint(source)
- if err != nil {
- return nil, results, err
- }
- if pathExists(target) {
- targetHash, targetErr := directoryFingerprint(target)
- if targetErr != nil {
- return nil, results, targetErr
- }
- if sourceHash == targetHash {
- results = append(results, DetachedFeatureMigration{Feature: feature, Status: "UNCHANGED", Reason: "Embedded and detached packages are byte-identical."})
- continue
- }
- results = append(results, DetachedFeatureMigration{Feature: feature, Status: "CONFLICTING", Reason: "Embedded and detached packages differ; Boatstack will not choose by recency."})
- blocked = true
- continue
- }
- imports = append(imports, detachedFeatureImport{feature: feature, source: source, target: target})
- }
- if blocked {
- return nil, results, fmt.Errorf("embedded feature migration requires conflict or receipt repair")
- }
- return imports, results, nil
-}
-
-// detachedOpenFeatureCandidates excludes historical packages. The current
-// feature branch is authoritative when it names an embedded package; otherwise
-// one active delivery or the sole package can be recovered without ambiguity.
-func detachedOpenFeatureCandidates(repo string, candidates []string) map[string]bool {
- selected := map[string]bool{}
- branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current"))
- for _, prefix := range []string{"feat/", "fix/", "chore/", "ci/"} {
- feature := strings.TrimPrefix(branch, prefix)
- if feature == branch {
- continue
- }
- for _, candidate := range candidates {
- if candidate == feature {
- selected[candidate] = true
- return selected
- }
- }
- }
- active, _, err := scanManagedDeliveries(repo)
- if err == nil && len(active) == 1 {
- for _, candidate := range candidates {
- if candidate == active[0] {
- selected[candidate] = true
- return selected
- }
- }
- }
- if len(candidates) == 1 {
- selected[candidates[0]] = true
- }
- return selected
-}
-
-func pathExists(path string) bool {
- _, err := os.Lstat(path)
- return err == nil
-}
-
-func copyDirectoryAtomic(source, target string) error {
- if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
- return err
- }
- temporary, err := os.MkdirTemp(filepath.Dir(target), ".boatstack-feature-import-*")
- if err != nil {
- return err
- }
- defer os.RemoveAll(temporary)
- err = filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- relative, err := filepath.Rel(source, path)
- if err != nil || relative == "." {
- return err
- }
- destination := filepath.Join(temporary, relative)
- if entry.IsDir() {
- return os.MkdirAll(destination, 0o755)
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- return atomicWriteMode(destination, value, 0o644)
- })
- if err != nil {
- return err
- }
- before, err := directoryFingerprint(source)
- if err != nil {
- return err
- }
- after, err := directoryFingerprint(temporary)
- if err != nil || before != after {
- return fmt.Errorf("copied feature package failed fingerprint verification")
- }
- if detachedImportBeforeRename != nil {
- if err := detachedImportBeforeRename(source, temporary, target); err != nil {
- return err
- }
- }
- return os.Rename(temporary, target)
-}
-
-func applyDetachedFeatureImports(imports []detachedFeatureImport, results []DetachedFeatureMigration) ([]DetachedFeatureMigration, error) {
- for _, planned := range imports {
- if err := copyDirectoryAtomic(planned.source, planned.target); err != nil {
- return results, err
- }
- results = append(results, DetachedFeatureMigration{Feature: planned.feature, Status: "IMPORTED", Reason: "Validated embedded package was atomically imported into detached controller state."})
- }
- sort.Slice(results, func(i, j int) bool { return results[i].Feature < results[j].Feature })
- return results, nil
-}
diff --git a/boatstack/detached_ownership_conformance_test.go b/boatstack/detached_ownership_conformance_test.go
deleted file mode 100644
index a19eba5..0000000
--- a/boatstack/detached_ownership_conformance_test.go
+++ /dev/null
@@ -1,173 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func embeddedFeatureForDetach(t *testing.T, repo, feature string, approvalFingerprint string) string {
- t.Helper()
- config := testConfig()
- config.Workflow.HumanPlanApproval = false
- // Host activation files remain repository-owned. They are orthogonal to the
- // detached generated-state invariant exercised by these fixtures.
- config.Adapters = nil
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, sourceConfigName), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, productLoopDirName), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "project.json"), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, "plans"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "plans", "source.md"), []byte("# Durable source plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- directory := filepath.Join(repo, productLoopDirName, "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = feature
- plan["source_plan_path"] = "../../../plans/source.md"
- writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true)
- if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Accepted specification\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if approvalFingerprint != "" {
- writeApprovalReceipt(t, filepath.Join(directory, "approval.md"), approvalFingerprint)
- }
- return directory
-}
-
-func TestDetachedOpenFeatureCandidatesIgnoreHistoricalPackagesOnMain(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/history.git")
- selected := detachedOpenFeatureCandidates(repo, []string{"old-one", "old-two"})
- if len(selected) != 0 {
- t.Fatalf("historical packages were selected on main: %v", selected)
- }
-}
-
-// control-law: detached-generated-state-has-one-resolved-owner
-func TestDetachedAttachImportsFeatureAndIgnoresEmbeddedDrift(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/import.git")
- source := embeddedFeatureForDetach(t, repo, "feature-one", "")
- result, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v %v", result, err)
- }
- if len(result.FeatureMigrations) != 1 || result.FeatureMigrations[0].Status != "IMPORTED" {
- t.Fatalf("migration result: %+v", result.FeatureMigrations)
- }
- target := WorkspaceFor(repo).FeatureDir("feature-one")
- if strings.HasPrefix(target, repo+string(filepath.Separator)) || !fileExists(filepath.Join(target, "plan.md")) {
- t.Fatalf("feature was not imported outside the repository: %s", target)
- }
- if err := os.WriteFile(filepath.Join(source, "embedded-only.md"), []byte("ignored\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "project.json"), []byte("{}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err != nil {
- t.Fatalf("doctor did not validate the complete detached controller without consulting embedded drift: %v", err)
- }
- status, err := ResolveNext(repo, "")
- if err != nil || status.Feature != "feature-one" || status.ObservedStage != "POLICY_READY" {
- t.Fatalf("next did not use detached feature package: %+v %v", status, err)
- }
-}
-
-// control-law: detached-import-never-chooses-by-recency
-func TestDetachedReattachBlocksConflictingFeaturePackages(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/conflict.git")
- source := embeddedFeatureForDetach(t, repo, "feature-one", "")
- first, _ := AttachDetached(AttachOptions{Repo: repo})
- if first.VerificationStatus != "VERIFIED" {
- t.Fatalf("first attach: %+v", first)
- }
- identical, identicalErr := AttachDetached(AttachOptions{Repo: repo, Force: true})
- if identicalErr != nil || identical.VerificationStatus != "VERIFIED" || len(identical.FeatureMigrations) != 1 || identical.FeatureMigrations[0].Status != "UNCHANGED" {
- t.Fatalf("identical packages were not preserved: %+v %v", identical, identicalErr)
- }
- if err := os.WriteFile(filepath.Join(source, "questions.md"), []byte("# changed later\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- second, err := AttachDetached(AttachOptions{Repo: repo, Force: true})
- if err != nil || second.VerificationStatus != "BLOCKED" || len(second.FeatureMigrations) != 1 || second.FeatureMigrations[0].Status != "CONFLICTING" {
- t.Fatalf("conflict was not fail-closed: %+v %v", second, err)
- }
-}
-
-// control-law: detached-import-requires-current-receipt-fingerprints
-func TestDetachedAttachRejectsStaleApprovalReceipt(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/stale.git")
- embeddedFeatureForDetach(t, repo, "feature-one", "wrong")
- result, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || result.VerificationStatus != "BLOCKED" || len(result.FeatureMigrations) != 1 || result.FeatureMigrations[0].Status != "REJECTED" {
- t.Fatalf("stale receipt was imported: %+v %v", result, err)
- }
-}
-
-// control-law: detached-import-is-atomic-before-directory-promotion
-func TestDetachedImportInterruptionLeavesNoPartialTarget(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/interrupted.git")
- embeddedFeatureForDetach(t, repo, "feature-one", "")
- old := detachedImportBeforeRename
- detachedImportBeforeRename = func(_, _, _ string) error { return fmt.Errorf("injected interruption") }
- t.Cleanup(func() { detachedImportBeforeRename = old })
- result, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || result.VerificationStatus != "BLOCKED" {
- t.Fatalf("interrupted attach: %+v %v", result, err)
- }
- identity, _ := repoIdentity(repo)
- ctx := detachedContextFromIdentity(filepath.Join(os.Getenv(stateRootEnv), "boatstack"), identity)
- if fileExists(ctx.FeatureDir("feature-one")) {
- t.Fatal("interrupted import exposed a partial feature directory")
- }
-}
-
-// control-law: detached-activation-writes-and-verifies-one-feature-root
-func TestDetachedActivationUsesCanonicalFeatureDirectory(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/activate.git")
- embeddedFeatureForDetach(t, repo, "feature-one", "")
- result, _ := AttachDetached(AttachOptions{Repo: repo})
- if result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v", result)
- }
- directory := WorkspaceFor(repo).FeatureDir("feature-one")
- if resolved, resolveErr := ResolveControllerRepository(directory); resolveErr != nil || canonicalizeExistingAncestor(resolved) != canonicalizeExistingAncestor(repo) {
- t.Fatalf("detached feature owner mismatch: directory=%s resolved=%s repo=%s err=%v", directory, resolved, repo, resolveErr)
- }
- resolved, _ := ResolveControllerRepository(directory)
- if ctx, ctxErr := ResolveWorkspaceContext(resolved); ctxErr != nil || ctx.Mode != SupervisionDetached {
- t.Fatalf("resolved owner lost detached context: resolved=%s ctx=%+v err=%v", resolved, ctx, ctxErr)
- }
- err := ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: filepath.Join(directory, "plan.md"), OutDir: filepath.Join(directory, "compiled"),
- OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: "test",
- })
- if err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{"compiled/tasks.json", "compiled/journey-oracles.json", "plan.lock.json"} {
- if !fileExists(filepath.Join(directory, filepath.FromSlash(path))) {
- t.Errorf("missing detached activation artifact %s", path)
- }
- if fileExists(filepath.Join(repo, productLoopDirName, "features", "feature-one", filepath.FromSlash(path))) {
- t.Errorf("activation artifact leaked into embedded package: %s", path)
- }
- }
-}
diff --git a/boatstack/detached_test.go b/boatstack/detached_test.go
deleted file mode 100644
index 9390907..0000000
--- a/boatstack/detached_test.go
+++ /dev/null
@@ -1,543 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
-)
-
-// Conformance tests for Detached Supervision. Each names the control law it proves.
-//
-// control-law: detached-control-state-never-enters-the-plant
-// control-law: detached-state-controls-only-its-bound-repository
-// control-law: unattached-repositories-are-not-controlled
-// control-law: attached-but-unverifiable-fails-closed
-// control-law: supervisor-semantics-are-mode-invariant
-
-// detachedTestRepo builds a minimal real git repository with one commit and an
-// origin remote (so identity derivation is stable), and points the external state
-// root at a temp dir so no real home directory is touched.
-func detachedTestRepo(t *testing.T, origin string) string {
- t.Helper()
- invalidateWorkspaceCache()
- t.Setenv(stateRootEnv, t.TempDir())
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if origin != "" {
- runGit(t, repo, "remote", "add", "origin", origin)
- }
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# app\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- // A go.mod gives detectTestCommand a concrete command so config synthesis is
- // valid without an interactive prompt.
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module app\n\ngo 1.22\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "initial")
- return repo
-}
-
-func gitPorcelain(t *testing.T, repo string) string {
- t.Helper()
- out, err := exec.Command("git", "-C", repo, "status", "--porcelain=v1", "--untracked-files=all").CombinedOutput()
- if err != nil {
- t.Fatalf("git status: %v: %s", err, out)
- }
- return strings.TrimSpace(string(out))
-}
-
-// control-law: detached-control-state-never-enters-the-plant
-func TestAttachLeavesRepositoryUnchanged(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- before := gitPorcelain(t, repo)
-
- result, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach did not verify: %+v", result)
- }
- if after := gitPorcelain(t, repo); after != before {
- t.Fatalf("attach changed the working tree: before=%q after=%q", before, after)
- }
- for _, forbidden := range []string{".product-loop", ".boatstack-project.json", ".claude", ".cursor", ".codex", ".gemini", ".agents"} {
- if _, err := os.Stat(filepath.Join(repo, forbidden)); !os.IsNotExist(err) {
- t.Fatalf("attach created %s inside the repository", forbidden)
- }
- }
- // Controller state exists under the external control root.
- if _, err := os.Stat(WorkspaceFor(repo).ProjectConfigPath()); err != nil {
- t.Fatalf("external project.json missing: %v", err)
- }
- if !strings.Contains(result.ControlRoot, "boatstack") || strings.HasPrefix(result.ControlRoot, repo) {
- t.Fatalf("control root should be external, got %q", result.ControlRoot)
- }
-}
-
-// control-law: unattached-repositories-are-not-controlled
-func TestUnattachedRepositoryResolvesEmbedded(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- ctx := WorkspaceFor(repo)
- if ctx.Mode != SupervisionEmbedded {
- t.Fatalf("unattached repo must resolve embedded, got %s", ctx.Mode)
- }
- if ctx.ProjectConfigPath() != filepath.Join(repo, ".product-loop", "project.json") {
- t.Fatalf("embedded config path drifted: %s", ctx.ProjectConfigPath())
- }
-}
-
-// control-law: detached-control-state-never-enters-the-plant
-func TestAttachRoutesWorkspaceToExternalControlRoot(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- ctx := WorkspaceFor(repo)
- if ctx.Mode != SupervisionDetached {
- t.Fatalf("attached repo must resolve detached, got %s", ctx.Mode)
- }
- for _, p := range []string{ctx.ProjectConfigPath()} {
- if strings.HasPrefix(p, repo+string(filepath.Separator)) {
- t.Fatalf("detached path points inside the repo: %s", p)
- }
- }
- delivery, err := ctx.DeliveryDir()
- if err != nil {
- t.Fatal(err)
- }
- if strings.HasPrefix(delivery, repo+string(filepath.Separator)) {
- t.Fatalf("detached delivery dir points inside the repo: %s", delivery)
- }
-}
-
-// control-law: detached-state-controls-only-its-bound-repository
-func TestDetachedStateIsPerRepositoryAndPerWorktree(t *testing.T) {
- repoA := detachedTestRepo(t, "https://github.com/acme/app.git")
- // A second repo shares the same state root (set by the first helper call is
- // overwritten; set it explicitly to the same dir for both).
- stateRoot := os.Getenv(stateRootEnv)
- repoB := t.TempDir()
- runGit(t, repoB, "init", "-b", "main")
- runGit(t, repoB, "config", "user.name", "T")
- runGit(t, repoB, "config", "user.email", "t@example.invalid")
- runGit(t, repoB, "remote", "add", "origin", "https://github.com/acme/other.git")
- if err := os.WriteFile(filepath.Join(repoB, "README.md"), []byte("b\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repoB, "add", ".")
- runGit(t, repoB, "commit", "-m", "b")
-
- if _, err := AttachDetached(AttachOptions{Repo: repoA}); err != nil {
- t.Fatal(err)
- }
- _ = stateRoot
- // repoB is not attached → embedded, unaffected by repoA's attachment.
- if WorkspaceFor(repoB).Mode != SupervisionEmbedded {
- t.Fatal("attaching repoA must not control repoB")
- }
- // The two repositories have distinct identities.
- idA, _ := repoIdentity(repoA)
- idB, _ := repoIdentity(repoB)
- if idA.RepoID == idB.RepoID {
- t.Fatal("distinct repositories must have distinct repo_ids")
- }
-}
-
-// control-law: attached-but-unverifiable-fails-closed
-func TestAttachedButCorruptBindingFailsClosed(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- stateRoot := os.Getenv(stateRootEnv)
- boatstackRoot := filepath.Join(stateRoot, "boatstack")
- identity, _ := repoIdentity(repo)
- // Corrupt the binding so identity can no longer be verified.
- if err := os.WriteFile(bindingPath(boatstackRoot, identity.RepoID), []byte("{not json"), 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- if _, _, err := detachedContextFor(repo); err == nil {
- t.Fatal("a corrupt binding for an attached repo must fail closed")
- }
- status, _ := DetachedStatus(repo)
- if !status.Attached || status.Verified {
- t.Fatalf("status must report attached-but-unverified: %+v", status)
- }
-}
-
-// control-law: detached-state-controls-only-its-bound-repository
-func TestMovedRepositoryWithMismatchedIdentityFailsClosed(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- stateRoot := os.Getenv(stateRootEnv)
- boatstackRoot := filepath.Join(stateRoot, "boatstack")
- identity, _ := repoIdentity(repo)
- // Rewrite the binding to claim a different origin/initial-commit identity while
- // keeping the same repo_id index entry: the strong-identity check must reject it.
- binding, err := loadBinding(boatstackRoot, identity.RepoID)
- if err != nil {
- t.Fatal(err)
- }
- binding.NormalizedOrigin = "github.com/acme/somethingelse"
- binding.InitialCommit = "0000000000000000000000000000000000000000"
- raw, _ := MarshalJSON(binding)
- if err := os.WriteFile(bindingPath(boatstackRoot, identity.RepoID), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- if _, _, err := detachedContextFor(repo); err == nil {
- t.Fatal("an identity mismatch must fail closed, not silently rebind")
- }
-}
-
-// control-law: supervisor-semantics-are-mode-invariant
-// The delivery-state layer round-trips identically whether stored embedded (in the
-// Git dir) or detached (in the external control root); detached storage lands
-// outside the repository.
-func TestDeliveryStateRoundTripsIdenticallyAcrossLayouts(t *testing.T) {
- state := DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion,
- Feature: "sample-feature",
- ActiveIndex: 1,
- Slices: []DeliverySlice{
- {ID: "delivery", Title: "Delivery", Status: "PUBLISHED", HeadBranch: "feat/sample-feature"},
- },
- }
-
- // Embedded: no attachment; state lives in the Git directory.
- embedded := detachedTestRepo(t, "https://github.com/acme/embedded.git")
- if err := saveDeliveryState(embedded, state); err != nil {
- t.Fatal(err)
- }
- gotEmbedded, err := LoadDeliveryState(embedded, state.Feature)
- if err != nil {
- t.Fatal(err)
- }
-
- // Detached: attached; state lives under the external control root.
- detached := detachedTestRepo(t, "https://github.com/acme/detached.git")
- if _, err := AttachDetached(AttachOptions{Repo: detached}); err != nil {
- t.Fatal(err)
- }
- if err := saveDeliveryState(detached, state); err != nil {
- t.Fatal(err)
- }
- gotDetached, err := LoadDeliveryState(detached, state.Feature)
- if err != nil {
- t.Fatal(err)
- }
-
- dir, err := WorkspaceFor(detached).DeliveryDir()
- if err != nil {
- t.Fatal(err)
- }
- if strings.HasPrefix(dir, detached+string(filepath.Separator)) {
- t.Fatalf("detached delivery state must live outside the repo, got %s", dir)
- }
-
- if gotEmbedded.Feature != gotDetached.Feature ||
- gotEmbedded.ActiveIndex != gotDetached.ActiveIndex ||
- len(gotEmbedded.Slices) != len(gotDetached.Slices) ||
- gotEmbedded.Slices[0].Status != gotDetached.Slices[0].Status ||
- gotEmbedded.Slices[0].HeadBranch != gotDetached.Slices[0].HeadBranch {
- t.Fatalf("delivery state differs across layouts:\nembedded=%+v\ndetached=%+v", gotEmbedded, gotDetached)
- }
-}
-
-// control-law: detached-control-state-never-enters-the-plant
-// The safety guard denies direct model mutation of the detached external control
-// root exactly as it does the embedded runtime state.
-func TestSafetyProtectsExternalControlRoot(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- for _, command := range []string{
- "rm -rf /Users/dev/.local/state/boatstack/repositories/abc123",
- "echo tampered > ~/.local/state/boatstack/registry.json",
- "cp evil ~/Library/Application Support/boatstack/runtimes/x/y/z/boatstack-helper",
- } {
- findings := ClassifyCommand(repo, command)
- if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" {
- t.Fatalf("external control-root mutation was not denied as tamper: %q -> %#v", command, findings)
- }
- }
-}
-
-// control-law: supervisor-semantics-are-mode-invariant
-func TestContextProjectionReportsMode(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
-
- embedded, err := ProjectOperatorContext(repo, "build", "claude")
- if err != nil {
- t.Fatal(err)
- }
- if embedded.Mode != string(SupervisionEmbedded) || embedded.Attached {
- t.Fatalf("unattached context should be embedded/unattached: %+v", embedded)
- }
-
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- detached, err := ProjectOperatorContext(repo, "build", "claude")
- if err != nil {
- t.Fatal(err)
- }
- if detached.Mode != string(SupervisionDetached) || !detached.Attached || detached.ControlRoot == "" {
- t.Fatalf("attached context should be detached with a control root: %+v", detached)
- }
- if strings.HasPrefix(detached.ControlRoot, repo+string(filepath.Separator)) {
- t.Fatalf("context control root must be external: %s", detached.ControlRoot)
- }
-}
-
-// control-law: unattached-repositories-are-not-controlled
-// Attachment and repository presence remain inert. Only a verified active
-// delivery engages the developer-level probe.
-func TestEngagementProbeRequiresActiveDelivery(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- event := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"}}`)
-
- // Unmanaged: the engagement probe must not control this repository.
- output, denied := EngagementProbeDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event})
- if denied {
- t.Fatalf("engagement probe controlled an unattached repository: %s", output)
- }
-
- // Attachment alone still carries no policy authority.
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- output, denied = EngagementProbeDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event})
- if denied {
- t.Fatalf("engagement probe controlled an attached dormant repository: %s", output)
- }
- engageHookFixture(t, repo)
- output, denied = EngagementProbeDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event})
- if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) {
- t.Fatalf("engagement probe did not enforce policy on an active delivery: %s", output)
- }
-}
-
-// control-law: unattached-repositories-are-not-controlled
-// Activation instructions are host-neutral (every agent), point at developer-level
-// config outside the repo, and install the engagement probe that no-ops unattached
-// repositories. An unattached repository has nothing to activate.
-func TestActivationPlanIsHostNeutralAndExternal(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- t.Setenv("BOATSTACK_USER_CONFIG_ROOT", t.TempDir())
-
- unattached, err := DetachedActivationPlan(repo, nil)
- if err != nil {
- t.Fatal(err)
- }
- if unattached.Attached || len(unattached.Hosts) != 0 {
- t.Fatalf("unattached repo should have no activation plan: %+v", unattached)
- }
-
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- plan, err := DetachedActivationPlan(repo, nil)
- if err != nil {
- t.Fatal(err)
- }
- if !plan.Attached || len(plan.Hosts) != 4 {
- t.Fatalf("expected activation for all four agents: %+v", plan)
- }
- userRoot := os.Getenv("BOATSTACK_USER_CONFIG_ROOT")
- for _, host := range plan.Hosts {
- if !strings.HasPrefix(host.ConfigPath, userRoot) {
- t.Fatalf("%s config path must be developer-level, got %s", host.Host, host.ConfigPath)
- }
- if strings.HasPrefix(host.ConfigPath, repo+string(filepath.Separator)) {
- t.Fatalf("%s activation must not point inside the repo: %s", host.Host, host.ConfigPath)
- }
- if !strings.Contains(host.Snippet, "engagement-probe") || !strings.Contains(host.Snippet, "--host "+host.Host) {
- t.Fatalf("%s snippet missing engagement probe command: %s", host.Host, host.Snippet)
- }
- if !strings.Contains(host.Snippet, "engagement.json") || !strings.Contains(host.Snippet, `"commandWindows"`) {
- t.Fatalf("%s snippet can load the helper before its cross-shell lease probe: %s", host.Host, host.Snippet)
- }
- var decoded any
- if err := json.Unmarshal([]byte(host.Snippet), &decoded); err != nil {
- t.Fatalf("%s snippet is not valid JSON: %v", host.Host, err)
- }
- }
-}
-
-func TestDetachedShellProbeDoesNotLoadHelperUntilEngaged(t *testing.T) {
- if _, err := exec.LookPath("bash"); err != nil {
- t.Skip("bash unavailable")
- }
- repo := safetyTestRepo(t)
- marker := filepath.Join(t.TempDir(), "helper-ran")
- helper := filepath.Join(t.TempDir(), "boatstack-helper")
- body := []byte("#!/usr/bin/env bash\ntouch " + strconv.Quote(marker) + "\n")
- if err := os.WriteFile(helper, body, 0o755); err != nil {
- t.Fatal(err)
- }
- run := func() {
- command := exec.Command("bash", "-c", engagementProbeCommand("codex", helper))
- command.Dir = repo
- if output, err := command.CombinedOutput(); err != nil {
- t.Fatalf("engagement probe failed: %v: %s", err, output)
- }
- }
- run()
- if _, err := os.Stat(marker); !os.IsNotExist(err) {
- t.Fatalf("dormant detached probe loaded its helper: %v", err)
- }
- engageHookFixture(t, repo)
- run()
- if _, err := os.Stat(marker); err != nil {
- t.Fatalf("active detached probe did not load its helper: %v", err)
- }
-}
-
-// control-law: detached-control-state-never-enters-the-plant
-// Attaching populates the external shared-runtime slot so the engagement probe has a
-// helper to invoke, without writing into the repository.
-func TestAttachPopulatesExternalRuntimeSlot(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- for _, p := range []string{binaryPath, manifestPath} {
- if !fileExists(p) {
- t.Fatalf("external runtime slot missing %s", p)
- }
- if strings.HasPrefix(p, repo+string(filepath.Separator)) {
- t.Fatalf("runtime slot must be external, got %s", p)
- }
- }
- manifest, loadedPath, err := loadSharedRuntime(repo)
- if err != nil {
- t.Fatalf("detached runtime must load through its external ownership boundary: %v", err)
- }
- if loadedPath != binaryPath || manifest.BoatstackVersion != Version {
- t.Fatalf("loaded detached runtime drifted: path=%s manifest=%+v", loadedPath, manifest)
- }
-}
-
-// control-law: activation-preserves-existing-host-config
-// Installing the engagement probe adds only a Boatstack-owned entry, preserves the
-// developer's existing hooks, and is idempotent.
-func TestActivateInstallsEngagementProbePreservingUserHooks(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- userRoot := t.TempDir()
- t.Setenv("BOATSTACK_USER_CONFIG_ROOT", userRoot)
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
-
- // Seed a developer's own Claude hook that Boatstack must never touch.
- claudeCfg := filepath.Join(userRoot, ".claude", "settings.json")
- if err := os.MkdirAll(filepath.Dir(claudeCfg), 0o755); err != nil {
- t.Fatal(err)
- }
- seed := `{"theme":"dark","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"my-own-check.sh"}]}]}}`
- if err := os.WriteFile(claudeCfg, []byte(seed), 0o644); err != nil {
- t.Fatal(err)
- }
-
- result, err := InstallEngagementProbes(repo, []string{"claude"})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || len(result.Hosts) != 1 || result.Hosts[0].Action != "installed" {
- t.Fatalf("unexpected install result: %+v", result)
- }
- body, err := os.ReadFile(claudeCfg)
- if err != nil {
- t.Fatal(err)
- }
- text := string(body)
- if !strings.Contains(text, "my-own-check.sh") {
- t.Fatalf("install clobbered the developer's own hook: %s", text)
- }
- if !strings.Contains(text, "engagement-probe") {
- t.Fatalf("install did not add the engagement probe: %s", text)
- }
- if !strings.Contains(text, `"theme"`) {
- t.Fatalf("install dropped unrelated user settings: %s", text)
- }
-
- // Idempotent: a second install changes nothing.
- again, err := InstallEngagementProbes(repo, []string{"claude"})
- if err != nil {
- t.Fatal(err)
- }
- if again.Hosts[0].Action != "unchanged" {
- t.Fatalf("second install was not idempotent: %+v", again)
- }
-
- // Deactivate removes only the engagement probe, preserving the developer's hook.
- removed, err := RemoveEngagementProbes(repo, []string{"claude"})
- if err != nil {
- t.Fatal(err)
- }
- if removed.Hosts[0].Action != "removed" {
- t.Fatalf("deactivate did not remove the engagement probe: %+v", removed)
- }
- after, err := os.ReadFile(claudeCfg)
- if err != nil {
- t.Fatal(err)
- }
- if strings.Contains(string(after), "engagement-probe") {
- t.Fatalf("deactivate left the engagement probe behind: %s", after)
- }
- if !strings.Contains(string(after), "my-own-check.sh") {
- t.Fatalf("deactivate removed the developer's own hook: %s", after)
- }
-}
-
-// control-law: unattached-repositories-are-not-controlled
-// Installing the engagement probe requires an attachment; an unattached repo is refused.
-func TestActivateRefusesUnattachedRepository(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- t.Setenv("BOATSTACK_USER_CONFIG_ROOT", t.TempDir())
- result, err := InstallEngagementProbes(repo, []string{"claude"})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "BLOCKED" {
- t.Fatalf("activation of an unattached repo must be blocked: %+v", result)
- }
-}
-
-// control-law: detached-control-state-never-enters-the-plant
-func TestDetachRemovesAttachmentAndState(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/app.git")
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- control := WorkspaceFor(repo).controlRoot
- result, err := DetachDetached(DetachOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || !result.StateRemoved {
- t.Fatalf("detach did not remove state: %+v", result)
- }
- if _, err := os.Stat(control); !os.IsNotExist(err) {
- t.Fatalf("detach left external control state behind: %s", control)
- }
- if WorkspaceFor(repo).Mode != SupervisionEmbedded {
- t.Fatal("after detach the repo must resolve embedded again")
- }
-}
diff --git a/boatstack/engagement.go b/boatstack/engagement.go
deleted file mode 100644
index 602db98..0000000
--- a/boatstack/engagement.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
-)
-
-// EngagementMode is the single authority projection used by host hooks and
-// command adapters. Repository presence and historical workflow evidence are
-// deliberately absent from this vocabulary.
-type EngagementMode string
-
-const (
- EngagementDormant EngagementMode = "DORMANT"
- EngagementCommand EngagementMode = "COMMAND"
- EngagementActive EngagementMode = "ACTIVE"
-)
-
-const engagementLeaseSchemaVersion = 1
-
-// EngagementRequest declares whether the current operation is an explicit
-// Boatstack command. Command engagement is ephemeral and is never persisted.
-type EngagementRequest struct {
- ExplicitCommand bool
-}
-
-// EngagementStatus is the canonical answer to whether Boatstack owns the
-// current operation. ACTIVE is valid only when the worktree-local lease agrees
-// with valid delivery state and the current branch.
-type EngagementStatus struct {
- SchemaVersion int `json:"schema_version"`
- Mode EngagementMode `json:"mode"`
- RepoRoot string `json:"repo_root,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- Branch string `json:"branch,omitempty"`
- Feature string `json:"feature,omitempty"`
- Slice string `json:"slice,omitempty"`
- PlanLockHash string `json:"plan_lock_sha256,omitempty"`
- Reason string `json:"reason"`
-}
-
-type engagementLease struct {
- SchemaVersion int `json:"schema_version"`
- RepoRoot string `json:"repo_root"`
- GitDir string `json:"git_dir"`
- WorktreeID string `json:"worktree_id,omitempty"`
- Branch string `json:"branch"`
- Feature string `json:"feature"`
- Slice string `json:"slice"`
- PlanLockHash string `json:"plan_lock_sha256"`
-}
-
-// engagementLeasePath is intentionally rooted in the per-worktree Git dir for
-// both embedded and detached installations. Generated hook shims can therefore
-// prove that no engagement exists before they load or hydrate any runtime.
-func engagementLeasePath(repo string) (string, error) {
- gitDir, err := worktreeGitDir(repo)
- if err != nil {
- return "", err
- }
- return filepath.Join(gitDir, controlDirName, "engagement.json"), nil
-}
-
-func dormantEngagement(reason string) EngagementStatus {
- return EngagementStatus{SchemaVersion: engagementLeaseSchemaVersion, Mode: EngagementDormant, Reason: reason}
-}
-
-// ResolveEngagement is the sole authority resolver for ambient Boatstack
-// behavior. Invalid, stale, terminal, cross-branch, and cross-worktree evidence
-// is inert here; explicit Boatstack commands validate and diagnose that evidence
-// through their own fail-closed boundaries.
-func ResolveEngagement(repoPath string, request EngagementRequest) EngagementStatus {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return dormantEngagement("repository identity is unavailable")
- }
- workspace := WorkspaceFor(repo)
- if request.ExplicitCommand {
- return EngagementStatus{
- SchemaVersion: engagementLeaseSchemaVersion, Mode: EngagementCommand,
- RepoRoot: repo, WorktreeID: workspace.WorktreeID,
- Branch: strings.TrimSpace(gitOutput(repo, "branch", "--show-current")),
- Reason: "an explicit Boatstack command owns only this operation",
- }
- }
- path, err := engagementLeasePath(repo)
- if err != nil {
- return dormantEngagement("no worktree engagement lease exists")
- }
- info, err := os.Lstat(path)
- if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
- return dormantEngagement("no valid worktree engagement lease exists")
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return dormantEngagement("the worktree engagement lease is unreadable")
- }
- var lease engagementLease
- if err := DecodeJSON("load engagement lease", path, value, &lease); err != nil ||
- lease.SchemaVersion != engagementLeaseSchemaVersion ||
- !featureSlugPattern.MatchString(lease.Feature) ||
- !featureSlugPattern.MatchString(lease.Slice) || strings.TrimSpace(lease.PlanLockHash) == "" {
- return dormantEngagement("the worktree engagement lease is invalid")
- }
- gitDir, err := worktreeGitDir(repo)
- if err != nil || canonicalizeExistingAncestor(lease.RepoRoot) != canonicalizeExistingAncestor(repo) ||
- canonicalizeExistingAncestor(lease.GitDir) != canonicalizeExistingAncestor(gitDir) ||
- lease.WorktreeID != workspace.WorktreeID {
- return dormantEngagement("the engagement lease belongs to another worktree")
- }
- branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current"))
- if branch == "" || lease.Branch != branch {
- return dormantEngagement("the engagement lease belongs to another branch")
- }
- state, err := LoadDeliveryState(repo, lease.Feature)
- if err != nil || state.PlanLockHash != lease.PlanLockHash || state.ActiveIndex >= len(state.Slices) {
- return dormantEngagement("the engagement lease has no current active delivery")
- }
- if err := checkDeliveryPlanLock(repo, lease.Feature, state); err != nil {
- return dormantEngagement("the engagement lease has no valid plan lock")
- }
- slice := state.Slices[state.ActiveIndex]
- if slice.ID != lease.Slice || slice.Status == StatusPublished || !stateMatchesBranch(state, branch) {
- return dormantEngagement("the engagement lease does not match the current delivery")
- }
- return EngagementStatus{
- SchemaVersion: engagementLeaseSchemaVersion, Mode: EngagementActive,
- RepoRoot: repo, WorktreeID: workspace.WorktreeID, Branch: branch,
- Feature: lease.Feature, Slice: lease.Slice, PlanLockHash: lease.PlanLockHash,
- Reason: "a verified delivery is active in this worktree and branch",
- }
-}
-
-func engagementLeaseForState(repo string, state DeliveryState) (engagementLease, bool, error) {
- if state.ActiveIndex < 0 || state.ActiveIndex >= len(state.Slices) {
- return engagementLease{}, false, nil
- }
- slice := state.Slices[state.ActiveIndex]
- if slice.Status == StatusPublished {
- return engagementLease{}, false, nil
- }
- root, err := ResolveRepository(repo)
- if err != nil {
- return engagementLease{}, false, err
- }
- branch := strings.TrimSpace(gitOutput(root, "branch", "--show-current"))
- if branch == "" || !stateMatchesBranch(state, branch) {
- return engagementLease{}, false, nil
- }
- gitDir, err := worktreeGitDir(root)
- if err != nil {
- return engagementLease{}, false, err
- }
- return engagementLease{
- SchemaVersion: engagementLeaseSchemaVersion, RepoRoot: root, GitDir: gitDir,
- WorktreeID: WorkspaceFor(root).WorktreeID, Branch: branch,
- Feature: state.Feature, Slice: slice.ID, PlanLockHash: state.PlanLockHash,
- }, true, nil
-}
-
-// syncEngagementLease makes activation and publication the only durable lease
-// transitions. Historical delivery files remain intact after release.
-func syncEngagementLease(repo string, state DeliveryState) error {
- path, err := engagementLeasePath(repo)
- if err != nil {
- return err
- }
- lease, active, err := engagementLeaseForState(repo, state)
- if err != nil {
- return err
- }
- if !active {
- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
- return err
- }
- return nil
- }
- value, err := MarshalJSON(lease)
- if err != nil {
- return err
- }
- return atomicWriteMode(path, value, 0o600)
-}
-
-func clearEngagementLease(repo string) error {
- path, err := engagementLeasePath(repo)
- if err != nil {
- return err
- }
- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
- return err
- }
- return nil
-}
diff --git a/boatstack/engagement_surface_registry_test.go b/boatstack/engagement_surface_registry_test.go
deleted file mode 100644
index 3f63bb2..0000000
--- a/boatstack/engagement_surface_registry_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package boatstack
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "testing"
-)
-
-// engagementSurfaceClasses inventories every production call that can decide,
-// persist, expose, or route engagement. The reviewed digest makes an added,
-// removed, or bypassing call site a deliberate contract change in CI.
-var engagementSurfaceClasses = map[string]string{
- "ResolveEngagement": "resolver",
- "syncEngagementLease": "lease-writer",
- "clearEngagementLease": "lease-writer",
- "HookDecision": "policy-entry",
- "EngagementProbeDecision": "host-entry",
- "activeManagedOperationScope": "operation-entry",
- "initializeDeliveryState": "activation-writer",
- "MarkDeliveryPublished": "release-writer",
- "DiscardDelivery": "release-writer",
- "guardShellScript": "shell-renderer",
- "guardPowerShellScript": "shell-renderer",
- "desiredHostHookForEvent": "host-renderer",
- "engagementProbeCommand": "host-renderer",
- "engagementProbePowerShellCommand": "host-renderer",
-}
-
-func engagementCalledName(call *ast.CallExpr) string {
- switch function := call.Fun.(type) {
- case *ast.Ident:
- return function.Name
- case *ast.SelectorExpr:
- return function.Sel.Name
- default:
- return ""
- }
-}
-
-func TestEngagementSurfaceRegistryIsComplete(t *testing.T) {
- entries := []string{}
- set := token.NewFileSet()
- files, err := filepath.Glob("*.go")
- if err != nil {
- t.Fatal(err)
- }
- commandFiles, err := filepath.Glob(filepath.Join("cmd", "boatstack-helper", "*.go"))
- if err != nil {
- t.Fatal(err)
- }
- files = append(files, commandFiles...)
- for _, path := range files {
- if strings.HasSuffix(path, "_test.go") {
- continue
- }
- parsed, err := parser.ParseFile(set, path, nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- for _, declaration := range parsed.Decls {
- function, ok := declaration.(*ast.FuncDecl)
- if !ok || function.Body == nil {
- continue
- }
- counts := map[string]int{}
- ast.Inspect(function.Body, func(node ast.Node) bool {
- call, ok := node.(*ast.CallExpr)
- if !ok {
- return true
- }
- name := engagementCalledName(call)
- class, tracked := engagementSurfaceClasses[name]
- if !tracked {
- return true
- }
- counts[name]++
- entries = append(entries, filepath.ToSlash(path)+":"+function.Name.Name+":"+name+":"+class+":"+strconv.Itoa(counts[name]))
- return true
- })
- }
- }
- sort.Strings(entries)
- digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
- const expected = "965433e25f06d745b46c752e553beeec82ebe24f508fcf3422da6d273291c3b4"
- if digest != expected {
- t.Fatalf("engagement surface registry changed: got %s; classify the new or removed site and update the reviewed digest\n%s", digest, strings.Join(entries, "\n"))
- }
-}
diff --git a/boatstack/engagement_test.go b/boatstack/engagement_test.go
deleted file mode 100644
index 2aa5265..0000000
--- a/boatstack/engagement_test.go
+++ /dev/null
@@ -1,229 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
-)
-
-func destructiveHookEvent(host string) []byte {
- switch host {
- case "cursor":
- return []byte(`{"hook_event_name":"beforeShellExecution","command":"git reset --hard HEAD~1"}`)
- case "gemini":
- return []byte(`{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git reset --hard HEAD~1"}}`)
- default:
- return []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"}}`)
- }
-}
-
-func activeEngagementFixture(t *testing.T, repo, feature string) DeliveryState {
- t.Helper()
- branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current"))
- lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.lock.json")
- if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(lockPath, []byte("active engagement lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- state := DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash,
- ActiveIndex: 0, Mode: "NORMAL", RepairCounters: map[string]int{},
- Slices: []DeliverySlice{{ID: "delivery", Status: StatusBuild, BaseBranch: "main", HeadBranch: branch}},
- }
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- if err := syncEngagementLease(repo, state); err != nil {
- t.Fatal(err)
- }
- return state
-}
-
-func TestDormantRepositoryHasZeroBoatstackPolicyEffects(t *testing.T) {
- repo := safetyTestRepo(t)
- draftPlan := writeValidSavedFeaturePlan(t, repo, "saved-draft")
- draftDir := filepath.Dir(draftPlan)
- if err := os.WriteFile(filepath.Join(draftDir, "plan.approval.json"), []byte(`{"approved":true}`), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(draftDir, "plan.lock.json"), []byte(`{"schema_version":2}`), 0o644); err != nil {
- t.Fatal(err)
- }
- writeInvalidDelivery(t, repo, "stale-delivery")
- writePublishedDelivery(t, repo, "published-work", "OPEN")
-
- status := ResolveEngagement(repo, EngagementRequest{})
- if status.Mode != EngagementDormant {
- t.Fatalf("repository evidence activated Boatstack: %+v", status)
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: destructiveHookEvent(host)})
- if denied {
- t.Fatalf("%s dormant hook denied repository administration: %s", host, output)
- }
- if len(output) != 0 {
- t.Fatalf("%s dormant hook emitted output: %s", host, output)
- }
- }
- write := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":".git/boatstack/deliveries/demo/state.json","content":"{}"}}`)
- if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: write}); denied {
- t.Fatalf("dormant raw managed-state write was controlled: %s", output)
- }
- for _, command := range []string{
- "git clean -fd", "git branch -D old-work", "git worktree remove /tmp/old-worktree",
- "git reset --hard HEAD", "gcloud sql instances delete fixture",
- } {
- event := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":` + strconv.Quote(command) + `}}`)
- if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: event}); denied {
- t.Fatalf("dormant operation %q was controlled: %s", command, output)
- }
- }
- for host, event := range map[string][]byte{
- "cursor": []byte(`{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__db__execute_sql","tool_input":{"query":"DROP TABLE fixture"}}`),
- "claude": []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__database__query","tool_input":{"query":"DELETE FROM fixture"}}`),
- "codex": []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__database__query","tool_input":{"query":"DELETE FROM fixture"}}`),
- "gemini": []byte(`{"hook_event_name":"BeforeTool","tool_name":"mcp__database__query","tool_input":{"query":"DELETE FROM fixture"}}`),
- } {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: event}); denied {
- t.Fatalf("%s dormant MCP mutation was controlled: %s", host, output)
- }
- }
-}
-
-func TestExplicitCommandIsEphemeral(t *testing.T) {
- repo := safetyTestRepo(t)
- command := ResolveEngagement(repo, EngagementRequest{ExplicitCommand: true})
- if command.Mode != EngagementCommand {
- t.Fatalf("explicit command did not receive command scope: %+v", command)
- }
- if ambient := ResolveEngagement(repo, EngagementRequest{}); ambient.Mode != EngagementDormant {
- t.Fatalf("command scope leaked into later ambient work: %+v", ambient)
- }
-}
-
-func TestActiveLeaseSurvivesResolverRestartAndScopesEveryHost(t *testing.T) {
- repo := safetyTestRepo(t)
- activeEngagementFixture(t, repo, "active-feature")
- for attempt := 0; attempt < 2; attempt++ {
- status := ResolveEngagement(repo, EngagementRequest{})
- if status.Mode != EngagementActive || status.Feature != "active-feature" || status.Slice != "delivery" {
- t.Fatalf("active engagement was not durable: %+v", status)
- }
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: destructiveHookEvent(host)}); !denied {
- t.Fatalf("%s active hook allowed destructive operation: %s", host, output)
- }
- }
-}
-
-func TestWrongBranchAndPublishedDeliveryAreDormant(t *testing.T) {
- repo := safetyTestRepo(t)
- state := activeEngagementFixture(t, repo, "scoped-feature")
- runGit(t, repo, "switch", "-c", "unrelated")
- if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant {
- t.Fatalf("cross-branch delivery remained engaged: %+v", status)
- }
- runGit(t, repo, "switch", "main")
- state.Slices[0].Status = StatusPublished
- state.Slices[0].PRState = "OPEN"
- state.ActiveIndex = 1
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- if err := syncEngagementLease(repo, state); err != nil {
- t.Fatal(err)
- }
- if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant {
- t.Fatalf("published delivery retained ambient authority: %+v", status)
- }
- if path, err := engagementLeasePath(repo); err != nil {
- t.Fatal(err)
- } else if _, err := os.Stat(path); !os.IsNotExist(err) {
- t.Fatalf("publication did not release the engagement lease: %v", err)
- }
-}
-
-func TestActiveLeaseDoesNotCrossWorktrees(t *testing.T) {
- repo := safetyTestRepo(t)
- activeEngagementFixture(t, repo, "worktree-feature")
- linked := filepath.Join(t.TempDir(), "linked")
- runGit(t, repo, "worktree", "add", "-b", "unrelated-worktree", linked)
- if status := ResolveEngagement(linked, EngagementRequest{}); status.Mode != EngagementDormant {
- t.Fatalf("active engagement crossed into another worktree: %+v", status)
- }
- if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: linked, Input: destructiveHookEvent("codex")}); denied {
- t.Fatalf("unrelated worktree was controlled: %s", output)
- }
-}
-
-func TestMalformedOrSymlinkedLeaseIsDormant(t *testing.T) {
- repo := safetyTestRepo(t)
- path, err := engagementLeasePath(repo)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil {
- t.Fatal(err)
- }
- if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant {
- t.Fatalf("malformed lease acquired authority: %+v", status)
- }
- if err := os.Remove(path); err != nil {
- t.Fatal(err)
- }
- target := filepath.Join(t.TempDir(), "lease.json")
- if err := os.WriteFile(target, []byte(`{}`), 0o600); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(target, path); err != nil {
- t.Fatal(err)
- }
- if status := ResolveEngagement(repo, EngagementRequest{}); status.Mode != EngagementDormant {
- t.Fatalf("symlinked lease acquired authority: %+v", status)
- }
-}
-
-func TestDormantGuardExitsBeforeHydration(t *testing.T) {
- repo := safetyTestRepo(t)
- guard := filepath.Join(repo, "guard.sh")
- if err := os.WriteFile(guard, guardShellScript(), 0o755); err != nil {
- t.Fatal(err)
- }
- sentinel := filepath.Join(t.TempDir(), "hydrated")
- command := exec.Command("bash", guard, "codex")
- command.Dir = repo
- command.Env = append(os.Environ(), "BOATSTACK_HYDRATE_COMMAND=touch "+sentinel)
- command.Stdin = strings.NewReader(string(destructiveHookEvent("codex")) + "\n")
- output, err := command.CombinedOutput()
- if err != nil || len(output) != 0 {
- t.Fatalf("dormant guard was not silent: err=%v output=%s", err, output)
- }
- if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
- t.Fatal("dormant guard attempted runtime hydration")
- }
-}
-
-func TestEngagementStatusJSONShape(t *testing.T) {
- repo := safetyTestRepo(t)
- value, err := json.Marshal(ResolveEngagement(repo, EngagementRequest{}))
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(value), `"mode":"DORMANT"`) {
- t.Fatalf("engagement status omitted mode: %s", value)
- }
-}
diff --git a/boatstack/evidence.go b/boatstack/evidence.go
deleted file mode 100644
index 0cb0f89..0000000
--- a/boatstack/evidence.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-// EvidenceRecord represents a host-generated observation of the repository state.
-type EvidenceRecord struct {
- ID string `json:"id"`
- Operation string `json:"operation"`
- Path string `json:"path,omitempty"`
- Query string `json:"query,omitempty"`
- Matches []string `json:"matches,omitempty"`
- Anchors []string `json:"anchors,omitempty"`
- RepositoryRevision string `json:"repository_revision"`
- CreatedBy string `json:"created_by"`
-}
-
-// LoadEvidenceLedger loads the evidence records from the specified path.
-func LoadEvidenceLedger(path string) (map[string]EvidenceRecord, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- if os.IsNotExist(err) {
- return make(map[string]EvidenceRecord), nil
- }
- return nil, fmt.Errorf("failed to read evidence ledger: %w", err)
- }
-
- var records []EvidenceRecord
- if err := json.Unmarshal(value, &records); err != nil {
- return nil, fmt.Errorf("failed to parse evidence ledger: %w", err)
- }
-
- ledger := make(map[string]EvidenceRecord)
- for _, record := range records {
- ledger[record.ID] = record
- }
- return ledger, nil
-}
-
-// ValidateEvidencePath ensures an evidence path is repository-relative and safe.
-func ValidateEvidencePath(repoRoot string, evPath string) error {
- repoAbsolute, err := filepath.Abs(repoRoot)
- if err != nil {
- return fmt.Errorf("failed to resolve repository root: %w", err)
- }
-
- if filepath.IsAbs(evPath) {
- return fmt.Errorf("evidence path must be relative, got absolute: %s", evPath)
- }
-
- cleanPath := filepath.Clean(evPath)
- if strings.HasPrefix(cleanPath, "..") || cleanPath == "." {
- return fmt.Errorf("evidence path cannot traverse outside repository: %s", evPath)
- }
-
- targetPath := filepath.Join(repoAbsolute, cleanPath)
-
- // Check if path resolves outside repo (e.g. through symlinks)
- evalPath, err := filepath.EvalSymlinks(targetPath)
- if err != nil {
- if os.IsNotExist(err) {
- return fmt.Errorf("evidence path does not exist: %s", evPath)
- }
- return fmt.Errorf("failed to evaluate evidence path: %w", err)
- }
-
- if !strings.HasPrefix(evalPath, repoAbsolute) {
- return fmt.Errorf("evidence path resolves outside repository: %s", evPath)
- }
-
- info, err := os.Stat(evalPath)
- if err != nil {
- return fmt.Errorf("failed to stat evidence path: %w", err)
- }
-
- if !info.Mode().IsRegular() {
- return fmt.Errorf("evidence path is not a regular file: %s", evPath)
- }
-
- return nil
-}
-
-// CheckFileAnchors verifies that all expected anchors are present in the file content.
-func CheckFileAnchors(filePath string, anchors []string) error {
- if len(anchors) == 0 {
- return nil
- }
-
- content, err := os.ReadFile(filePath)
- if err != nil {
- return fmt.Errorf("failed to read file for anchor check: %w", err)
- }
- contentStr := string(content)
-
- var missing []string
- for _, anchor := range anchors {
- if !strings.Contains(contentStr, anchor) {
- missing = append(missing, anchor)
- }
- }
-
- if len(missing) > 0 {
- return fmt.Errorf("file missing expected anchors: %v", missing)
- }
-
- return nil
-}
diff --git a/boatstack/export.go b/boatstack/export.go
deleted file mode 100644
index 497bf81..0000000
--- a/boatstack/export.go
+++ /dev/null
@@ -1,791 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "runtime"
- "sort"
- "strings"
-)
-
-var adapterNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
-
-// embeddedConfigBytes removes only Git's platform line-ending transport from
-// the editable embedded config. Detached supervision keeps its exact-byte
-// digest and never calls this helper.
-func embeddedConfigBytes(value []byte) []byte {
- return []byte(strings.ReplaceAll(string(value), "\r\n", "\n"))
-}
-
-var allowedAdapters = map[string]bool{
- "cursor": true,
- "claude": true,
- "codex": true,
- "gemini": true,
- "github": true,
-}
-
-const executionModeDX = "\n\n**Execution Mode Notice:** Boatstack requires active tool use (such as shell execution) to run its commands and inspect the workspace. If you are currently operating in a Plan Mode, Read-Only Mode, or a state with restricted tool access, you MUST immediately inform the user that Boatstack requires full execution capabilities and ask them to approve exiting that mode or grant execution permissions before you can proceed. Do not attempt to mock or bypass the operation."
-
-const repositoryAdministrationBoundary = `Repository administration is outside Boatstack engagement. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never activate Boatstack or route to auto-plan, repair, or workspace-sync. Use ordinary repository tools unless the user explicitly asks Boatstack to manage an operation during an active delivery.`
-
-var (
- readCanonical = ReadCanonical
- readCanonicalDir = ReadCanonicalDir
-)
-
-type ExportBundle struct {
- Files map[string][]byte
- Config ProjectConfig
-}
-
-type claudeSkillSpec struct {
- Name string
- Description string
- ArgumentHint string
-}
-
-var claudeVisibleSkills = []claudeSkillSpec{
- {
- Name: "boatstack-next",
- Description: "Report the verified Boatstack stage and exactly one next action without changing state.",
- },
- {
- Name: "boatstack-run",
- Description: "Drive the verified Boatstack feature through every delivery slice and PR publication, pausing only at required human boundaries.",
- },
- {
- Name: "insight-capture",
- Description: "Project one exact message into a Value Map and preview an independent tracked insight artifact for human confirmation.",
- ArgumentHint: "[message]",
- },
- {
- Name: "insight-frontier",
- Description: "Show independent insight captures that need classification, delivery, evidence, or human completion without changing state.",
- },
- {
- Name: "root-cause",
- Description: "Read-only failure-mode-elimination diagnosis of a bug — a cited root-cause chain, the named failure class, and a class-eliminating source plan to hand to auto-plan.",
- ArgumentHint: "[symptom-or-log]",
- },
- {
- Name: "auto-plan",
- Description: "Refine one saved Plan-mode proposal into a reviewable Boatstack feature plan.",
- ArgumentHint: "[plan-file]",
- },
- {
- Name: "plan-gate",
- Description: "Review and explicitly approve a Boatstack feature plan before implementation.",
- },
- {
- Name: "build",
- Description: "Implement the currently approved Boatstack delivery slice.",
- },
- {
- Name: "repair",
- Description: "Classify and route a free-form change to an active Boatstack delivery without losing evidence.",
- },
- {
- Name: "test-gate",
- Description: "Validate the active Boatstack delivery slice and record current test evidence.",
- },
- {
- Name: "review-gate",
- Description: "Review the active Boatstack delivery slice against approved intent and evidence.",
- },
- {
- Name: "ship-gate",
- Description: "Prepare and, after confirmation, publish the active Boatstack delivery slice as a pull request.",
- },
- {
- Name: "boatstack-update",
- Description: "Prepare a separate reviewed update of Boatstack's repository infrastructure.",
- },
-}
-
-func LoadConfig(path string) (ProjectConfig, []byte, error) {
- raw, err := os.ReadFile(path)
- if err != nil {
- return ProjectConfig{}, nil, err
- }
- var config ProjectConfig
- if err := DecodeJSON("load project configuration", path, raw, &config); err != nil {
- return ProjectConfig{}, nil, err
- }
- if err := ValidateConfig(config); err != nil {
- return ProjectConfig{}, nil, err
- }
- return config, raw, nil
-}
-
-func ValidateConfig(config ProjectConfig) error {
- if config.SchemaVersion < 1 {
- return fmt.Errorf("project config schema_version must be >= 1")
- }
- if config.SchemaVersion > currentSchemaVersion() {
- return fmt.Errorf("config was written by a newer Boatstack; update Boatstack")
- }
- if config.SchemaVersion < currentSchemaVersion() {
- return fmt.Errorf("config schema is behind; run /boatstack-update")
- }
- if strings.TrimSpace(config.Project.Name) == "" {
- return fmt.Errorf("project.name is required")
- }
- if strings.TrimSpace(config.Project.Commands["test"]) == "" {
- return fmt.Errorf("project.commands.test is required; Boatstack will not invent it")
- }
- for _, adapter := range normalizedAdapters(config.Adapters) {
- if !allowedAdapters[adapter] {
- return fmt.Errorf("unsupported adapter: %s", adapter)
- }
- }
- if err := validateWorkspaceConfig(config.Workspace); err != nil {
- return err
- }
- if err := validateDeliveryConfig(config.Delivery); err != nil {
- return err
- }
- if err := validateInsightConfig(config.Insights); err != nil {
- return err
- }
- if policy := strings.TrimSpace(config.Workflow.PRVisualEvidence); policy != "" && policy != "off" && policy != "suggest" && policy != "require" {
- return fmt.Errorf("workflow.pr_visual_evidence must be \"off\", \"suggest\", or \"require\"")
- }
- if err := validateVisualEvidencePublish(config.Workflow.VisualEvidencePublish); err != nil {
- return err
- }
- if err := validateExternalAuthorityPolicy(config.Workflow.ExternalAuthority); err != nil {
- return err
- }
- for _, surface := range config.Project.VisualSurfaces {
- if !surfaceSlugPattern.MatchString(surface.ID) || len(surface.Paths) == 0 {
- return fmt.Errorf("project.visual_surfaces require a lowercase-kebab id and at least one path")
- }
- for _, pattern := range surface.Paths {
- if strings.TrimSpace(pattern) == "" || filepath.IsAbs(pattern) || strings.Contains(filepath.ToSlash(pattern), "../") {
- return fmt.Errorf("project.visual_surfaces paths must be non-empty repository-relative patterns")
- }
- }
- }
- return nil
-}
-
-func validateInsightConfig(policy *InsightPolicy) error {
- if policy == nil || !policy.Enabled {
- return nil
- }
- if mode := strings.TrimSpace(policy.CaptureMode); mode != "" && mode != "manual" {
- return fmt.Errorf("insights.capture_mode must be \"manual\"")
- }
- if mode := strings.TrimSpace(policy.ValueMap); mode != "" && mode != "required" {
- return fmt.Errorf("insights.value_map must be \"required\"")
- }
- if mode := strings.TrimSpace(policy.CompletionMode); mode != "" && mode != "human_confirmed" {
- return fmt.Errorf("insights.completion_mode must be \"human_confirmed\"")
- }
- return nil
-}
-
-// validateDeliveryConfig rejects only explicit invalid enum values. A nil
-// block or empty terminal resolves to the published default at use.
-func validateDeliveryConfig(delivery *DeliveryPolicy) error {
- if delivery == nil {
- return nil
- }
- if terminal := delivery.Terminal; terminal != "" && terminal != "published" && terminal != "merged" {
- return fmt.Errorf("delivery.terminal must be \"published\" or \"merged\"")
- }
- return nil
-}
-
-// validateVisualEvidencePublish rejects only explicit invalid enum values. A nil
-// block or empty fields are legal and resolve to defaults at use, so configs written
-// before this block existed remain valid.
-func validateVisualEvidencePublish(publish *VisualEvidencePublish) error {
- if publish == nil {
- return nil
- }
- if mode := publish.Mode; mode != "" && mode != "external-host" {
- return fmt.Errorf("workflow.visual_evidence_publish.mode must be \"external-host\" when set")
- }
- if host := publish.Host; host != "" && host != "litterbox" && host != "catbox" {
- return fmt.Errorf("workflow.visual_evidence_publish.host must be \"litterbox\" or \"catbox\"")
- }
- switch publish.Expiry {
- case "", "1h", "12h", "24h", "72h":
- default:
- return fmt.Errorf("workflow.visual_evidence_publish.expiry must be one of \"1h\", \"12h\", \"24h\", \"72h\"")
- }
- return nil
-}
-
-// validateWorkspaceConfig rejects only explicit invalid enum values. Empty
-// values are legal and resolve to defaults at use, so configs written before the
-// workspace block existed remain valid.
-func validateWorkspaceConfig(workspace Workspace) error {
- if mode := workspace.Mode; mode != "" && mode != "worktree" && mode != "branch" {
- return fmt.Errorf("workspace.mode must be \"worktree\" or \"branch\"")
- }
- if cleanup := workspace.Cleanup; cleanup != "" && cleanup != "confirm" && cleanup != "auto" && cleanup != "off" {
- return fmt.Errorf("workspace.cleanup must be \"confirm\", \"auto\", or \"off\"")
- }
- if after := workspace.CleanupAfter; after != "" && after != "merge" && after != "ship" {
- return fmt.Errorf("workspace.cleanup_after must be \"merge\" or \"ship\"")
- }
- if reap := workspace.Reap; reap != "" && reap != "confirm" && reap != "auto" && reap != "off" {
- return fmt.Errorf("workspace.reap must be \"confirm\", \"auto\", or \"off\"")
- }
- return nil
-}
-
-func normalizedAdapters(adapters []string) []string {
- if len(adapters) == 0 {
- return []string{"claude", "codex", "cursor", "gemini", "github"}
- }
- seen := map[string]bool{}
- for _, adapter := range adapters {
- adapter = strings.TrimSpace(adapter)
- if adapter != "" {
- seen[adapter] = true
- }
- }
- result := make([]string, 0, len(seen))
- for adapter := range seen {
- result = append(result, adapter)
- }
- sort.Strings(result)
- return result
-}
-
-func commandBody(operation, extra string) string {
- preflight := ""
- if operation == "auto-plan" {
- preflight = `Before reading repository context or drafting artifacts, identify the durable in-repo path of the plan produced in the active host/system conversation — the user supplies it as ` + "`/auto-plan `" + `. Boatstack does not scan directories for plans. If no path is available, stop and ask for it; do not create or guess a substitute. For every planning artifact, send its complete Markdown to the read-only ` + "`flow bootstrap --repo . --feature --source-plan --artifact --shell posix|powershell`" + ` oracle through the current Boatstack operation entrypoint. Execute only the returned ` + "`planning_envelope`" + `, unchanged. Never construct a helper path or planning-write command from documentation. Re-resolve after every workspace cut from its ` + "`destination_repository`" + `.`
- }
- return fmt.Sprintf(`# %s
-
-Run the %s operation from @.product-loop/workflow.md.
-
-%s
-
-Read @.product-loop/project.json, @.product-loop/artifacts.md, @.product-loop/verified-boundaries.md (if it exists), and only the minimal repository context relevant to the current feature. Do not propose or write code that violates established repository boundaries. %s
-
-Use the gate semantics in the canonical workflow. Do not redefine them in this adapter. Auto-plan and plan-gate may create or update Markdown only. Classify authoritative repository facts as DISCOVERED, agent suggestions as PROPOSED, explicit human responses as ANSWERED, and eligible autonomous choices as RESOLVED_BY_POLICY. Every material proposal remains in blocking_questions. For 1-3 finite questions, use compact keys such as 1a/1b and 2a/2b, suffix exactly one choice per question with (Recommended), and offer r to accept all displayed recommendations. Treat r as explicit human acceptance only after you echo the selected mapping. Never silently choose a default outside an explicit goal-driven run. Inside $boatstack run --to plan|verified|pr, choose only when the typed autonomy envelope proves the question non-material, within-spec, reversible, free of protected impacts, evidence-backed, and independently verifiable; otherwise ask. Boatstack leaves implementation tactics open, but completion, authorization, and shipping claims require current evidence. During managed delivery, read the active delivery slice and require slice-scoped test and review receipts before ship-gate. A successful publication activates the next declared slice; plan authorization never skips its gates.
-
-Follow the User-facing response contract in @.product-loop/workflow.md. Lead with its mapped plain-language outcome, show only decision-relevant content, end with exactly one `+"`### Next step`"+`, and put machine status, helper output, fingerprints, artifact paths, receipts, and locks inside collapsed `+"`Technical details`"+`. Treat helper names in this command as internal control machinery; do not expose them in the primary response. Write your response in Simplified Technical English: short sentences, the active voice, the present tense, one idea per sentence, the condition first, and the simple common word.
-`, operation, operation, preflight, extra) + executionModeDX
-}
-
-func claudeOperationSkill(spec claudeSkillSpec, operationBody string) string {
- argumentHint := ""
- arguments := ""
- if spec.ArgumentHint != "" {
- argumentHint = fmt.Sprintf("argument-hint: %q\n", spec.ArgumentHint)
- arguments = "\n\nUser arguments: $ARGUMENTS"
- }
- return fmt.Sprintf(`---
-name: %s
-description: %s
-%sdisable-model-invocation: true
----
-
-%s%s
-`, spec.Name, spec.Description, argumentHint, strings.TrimSpace(operationBody), arguments)
-}
-
-func codexOperationSkill(spec claudeSkillSpec, operationBody string) string {
- return fmt.Sprintf(`---
-name: %s
-description: %s
----
-
-%s
-`, spec.Name, spec.Description, strings.TrimSpace(operationBody))
-}
-
-func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte, adapterName string) (ExportBundle, error) {
- if !adapterNamePattern.MatchString(adapterName) {
- return ExportBundle{}, fmt.Errorf("adapter name must be a lowercase kebab-case slug")
- }
- if err := ValidateConfig(config); err != nil {
- return ExportBundle{}, err
- }
- adapters := normalizedAdapters(config.Adapters)
- config.Adapters = adapters
- files := map[string][]byte{}
-
- projectJSON, err := GeneratedJSON(config)
- if err != nil {
- return ExportBundle{}, err
- }
- files[".product-loop/project.json"] = projectJSON
- files[".product-loop/.gitignore"] = []byte("bin/\nworktrees/\n")
- files[".product-loop/boatstack"] = launcherShellScript()
- files[".product-loop/boatstack.ps1"] = launcherPowerShellScript()
- files[".product-loop/hooks/guard.sh"] = guardShellScript()
- files[".product-loop/hooks/guard.ps1"] = guardPowerShellScript()
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if contains(adapters, host) {
- fragment, fragmentErr := hookFragmentJSON(host)
- if fragmentErr != nil {
- return ExportBundle{}, fragmentErr
- }
- files[".product-loop/hooks/"+host+".fragment.json"] = fragment
- }
- }
-
- for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md", "irreversible-operation-boundary.md", "host-hook-contracts.md", "config-schema.md"} {
- value, err := readCanonical("references/" + name)
- if err != nil {
- return ExportBundle{}, err
- }
- files[".product-loop/"+name] = GeneratedMarkdown(string(value))
- }
-
- entries, err := readCanonicalDir("assets/templates")
- if err != nil {
- return ExportBundle{}, err
- }
- for _, entry := range entries {
- if entry.IsDir() {
- continue
- }
- value, err := readCanonical("assets/templates/" + entry.Name())
- if err != nil {
- return ExportBundle{}, err
- }
- path := ".product-loop/templates/" + entry.Name()
- if strings.HasSuffix(entry.Name(), ".json") {
- var decoded any
- templateName := "assets/templates/" + entry.Name()
- if err := DecodeJSON("build export bundle from JSON template", templateName, value, &decoded); err != nil {
- return ExportBundle{}, err
- }
- files[path], err = GeneratedJSON(decoded)
- if err != nil {
- return ExportBundle{}, fmt.Errorf("generate JSON output %s from %s: %w", path, templateName, err)
- }
- } else {
- files[path] = GeneratedMarkdown(string(value))
- }
- }
-
- operations := map[string]string{
- "boatstack-next": "Run the tracked .product-loop/boatstack launcher next-status --repo . --format response and present its output as the response. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub beyond the helper's bounded published-PR inspection, or advance a gate. The helper renders the canonical response contract deterministically — the outcome line and the single ### Next step block with the exact runnable command when one is prescribable; never override, re-derive, or add a second next action. The helper also types the step's actor: when the rendered step is marked \"This step is mine to do\", the step is the agent's, and the one next action is the delegation reply g. Only after the exact reply g, execute the prescribed step, re-render next-status --repo . --format response, and continue through further agent-owned steps until the next step belongs to the operator (an approval, a publish or cleanup reply, a feature choice, a product fact) or no action is required. Stop immediately when a step does not change the prescribed next step — repetition without progress is a stall; report the block and hand the turn to the operator. Never end a response by describing work the agent still has to do. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result.",
- "boatstack-run": "First run the read-only next-status --repo . --json and operation-status --repo . --json. If an operation is executing, wait and report it instead of launching it again; if reconciliation is required, verify its exact postcondition before retrying. If NOT_STARTED, respond Start a Boatstack feature and ask the user for the plan produced in the host conversation, then execute auto-plan with its path via --plan (Boatstack does not scan directories for plans) without Git preflight, pausing at its normal decision or approval boundary; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the tracked .product-loop/boatstack launcher run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage. For AMENDMENT_REQUIRED or PLAN_INVALID, obtain the amended source plan, execute only a fresh lifecycle-bound flow bootstrap prescription, pass through plan-gate, and reactivate before resuming delivery. The delivery state's durable repair_attempt is the budget; stop after three complete automated repair-and-gate cycles even across new turns, host restarts, or async notifications. Stop immediately on ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence. Durable operation receipts store execution facts and retry budgets, never autonomous workflow intent. Report the feature, active slice, stages completed, completion or pause reason, durable repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.",
- "insight-capture": "Treat the complete invocation argument as the exact untrusted source message. Require insights.enabled before continuing. Run the available Value Map skill as a read-only conversational projection and preserve its canonical lineage: user, current state, value gap, desired outcome, mechanism, smallest proof, evidence, unknowns, grade, and verdict. When insights.suggest_features is true, inspect only the minimal relevant product slice to suggest one primary feature topic and optional related topics; label suggestions PROPOSED and do not bind them to a delivery. When it is false, leave topics for explicit human classification. Serialize the full proposed capture, including the exact source bytes and SHA-256, then pipe those bytes to the tracked .product-loop/boatstack launcher insight check --repo . --json. Display the complete Value Map, suggested topics, unknowns, returned preview fingerprint, and a prominent warning that the exact source and Value Map will enter the repository and may become public through Git history. Respond Insight ready to save and make the one next action: Reply `s` to save this exact insight as a repository diff. Only an exact state-scoped s for the currently displayed fingerprint authorizes piping the unchanged draft to insight save with the same preview nonce and fingerprint. If any source byte, map field, topic, nonce, or fingerprint changes, check again and require a new s. Never save on the initial request, on r, or when Value Map is unavailable. After a successful save respond Insight saved as a repository diff and show its ID and repository path. Do not create a feature, plan, branch, commit, or PR; publication remains a separate explicit action.",
- "insight-frontier": "Run the tracked .product-loop/boatstack launcher insight frontier --repo . and present the independent captures needing classification, delivery, evidence, terminal observation, or human completion. This operation is strictly read-only: do not append events, change associations, bind deliveries, evaluate by mutation, disposition captures, or alter the authoritative delivery frontier. Respond Insight frontier ready and show one suggested pending action per capture without presenting any insight as Boatstack's single delivery next action.",
- "root-cause": "Perform failure-mode elimination on a bug, not a patch. This operation is strictly read-only: do not edit product code, create or update artifacts, advance a gate, or contact GitHub; the user supplies the symptom, stack trace, error log, or failing signal as the argument. Locate the failure below its surface symptom and classify it against the failure classes in @.product-loop/failure-moves.md; name the failure CLASS, not the one instance, and if no class fits, name the new class in that vocabulary. Investigate with read-only tools and produce a numbered root-cause chain in which every step is cited to file:line and which distinguishes the crashing frame (the victim) from the true origin (the cause); label authoritative repository facts DISCOVERED and any inference PROPOSED. State the blast radius: every other call site or path exposed to the same class. Propose the minimal STRUCTURAL elimination that makes the whole class unreachable and covers every exposed site, reusing an existing repository pattern or utility where one exists, rather than a local guard on the single line in the trace. Present this as a material product decision with the same tiered paths auto-plan uses under boundary_analysis: [1a] Symptom Patch or [1b] Programmatic Enforcement (a boundary that eliminates the class), and recommend one. Require a regression that reproduces the failure mode before the fix plus the project's own gates as the proof the class is gone, and name related latent hazards left out of scope as non-goals. Then format the result as a host Plan-mode source plan (symptom, root-cause chain, failure mode, blast radius, elimination, non-goals, verification, delivery base branch) and respond Root cause found, making the one next action: save this plan to a durable in-repo path and run auto-plan with it via --plan. Do not implement the fix; hand off to the plan gate.",
- "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Resolve every planning write through flow bootstrap with the selected feature, source plan, artifact, and shell; execute only its returned planning_envelope and resolve again after workspace-cut. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If a selected hand-authored draft cannot be verified, resolve it through repair-state, then re-author it through a fresh flow bootstrap prescription. The invalid draft never controls unrelated repository tools. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.",
- "plan-gate": "Run check-plan read-only and present its plan fingerprint, baseline product diff fingerprint, changed paths, exact baseline diff when non-empty, and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the displayed baseline fingerprint, omitting it only when the baseline is clean, so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.",
- "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. activate-plan promotes the compiled task graph, test matrix, evidence ledger, and the plan lock together through the transactional mutation boundary as one mutation, so all four land all-or-nothing with a reversible receipt and a failed or interrupted promote leaves the prior state unchanged rather than half-written. The boundary is closed under inversion: mutation-status lists the receipts and undo --mutation reverses a managed-artifact promotion (redo is undo of the undo receipt), with undo refusing to reverse an activation once a delivery gate would be stranded; this governs Boatstack-generated artifacts only, never source code. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.",
- "repair": "First run recovery-status --repo . with the user's exact free-form requested change, its observed source stage, bounded evidence when available, and --json. This resolver covers both active and current-branch published deliveries. On repair_active, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts; classify the request and invoke record-change before any product edit. When record-change returns AMENDMENT_REQUIRED or PLAN_INVALID, do not edit product code or call ordinary bootstrap: save the accepted amended intent as a durable in-repository source plan, resolve flow bootstrap for the same managed feature, execute only its returned lifecycle-bound planning envelope, run plan-gate, and reactivate the exact amended plan before resuming the returned delivery stage. On draft_corrective_child, invoke record-change on the published parent, preserve its lock, receipts, slices, and publication evidence, and automatically prepare the suggested one-slice child plan with parent_delivery, exact correction, inherited intent, observed failure, returned existing_diff_sha256 and existing_changed_paths, verification requirements, and the resolved PR destination. Lead with The PR needs a corrective delivery. I prepared it for your approval. Then pause at the normal fingerprinted plan approval boundary; never reuse the parent's approval. An open PR reuses its verified head branch and is updated after fresh gates and publication confirmation. A merged or closed PR uses a fresh branch and PR; when a fingerprinted correction diff already exists, leave the original worktree untouched and transfer that exact reviewed diff into the fresh child only after approval. PUBLISHED_UNKNOWN may be drafted but its destination remains blocking at publication. Stop on BLOCKED and ask one targeted feature question using the returned blockers. If no managed target exists, continue ordinary conversation. Never discard pre-existing correction edits, edit runtime state directly, or bypass test, review, and ship gates. Never ask the user to repeat a denied push or PR mutation. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.",
- "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. If the active slice contains a systemic_boundary task, the evidence must prove the verification_oracle actively blocked or normalized a violation attempt (negative test). External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. For relevant PR visual scenarios, use repository-owned capture first, then the host browser against the existing development server, one supplied launch instruction, or an approved machine-only runtime. Do not edit repository dependencies or configuration for capture. Review the exact PNGs for secrets and private data and import their temporary manifest with record-pr-visual-evidence. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.",
- "review-gate": "Read delivery-status and review the active slice's actual diff against authorized intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact. When workflow.independent_review_for_high_risk is true and changed paths match project.high_risk_paths, use a human peer or separate agent and pass --reviewer-identity plus --review-method human_peer or separate_agent. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject changed or untested diffs, disallowed gaps, missing reviewer provenance, and malformed required changelog evidence. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.",
- "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Generate a clear, product-focused PR title that describes the user value or system outcome rather than listing technical components (do not use sequence prefixes like 'PR 1'). Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. When PR visual evidence is relevant, pr-context runs the registered repository capture command itself whenever evidence is missing or stale, so capturing is not your step: review the exact fingerprinted local PNGs for secrets and private data, show the external-host privacy warning, render the structural Visual evidence section, and treat o or u as authorization for the exact PR package plus one Boatstack-owned evidence comment. Fall back to manual capture (host browser, capture-evidence, record-pr-visual-evidence) only when the context reports the capture capability unavailable or names a harness failure in pr_visual_evidence_capture_detail. Require human privacy review, then let Boatstack upload through external hosting (Litterbox for 72 hours by default), verify every returned URL, and place only hosted Markdown image links in the existing evidence comment; never attach PNG files directly or commit them to an evidence branch. Suggest records a visible gap; require blocks completed publication. Preserve an opened PR and retry the same fingerprint and comment from visual_pending after upload, URL-verification, or comment failure. Add security/privacy, migration, or operations sections only when relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.",
- "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. Force check-update with the current helper when available, but treat it and doctor as diagnostics rather than repair authority. If the installed helper is unavailable, resolve the latest stable tag from the official GitHub release endpoint and continue with the checksum-verified target installer; never require the broken helper to fix itself. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current default branch whose HEAD equals origin/ and no product or user-owned edits; otherwise respond Update postponed and give one recovery action. Ensure no update PR or branch already exists and create chore/update-boatstack-v. Fetch the installer from that exact release tag: it must checksum-verify the target helper before consulting installed state. Run it with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Exact installed hook and generated-state migrations are automatic. If the verified target helper reports REPAIR_AVAILABLE, show repair-status, state that the repair remains in this update PR, and ask whether the user wants the exact update rerun with BOATSTACK_REPAIR=1; do not infer that authority from the update request. Never offer repair for user-owned, mixed, malformed, symlinked, product, network, or authentication failures. Downgrades additionally require separately requested BOATSTACK_ALLOW_DOWNGRADE=1. The verified update preserves configuration, adapters, integrations, and unrelated host settings, writes any repair backup to Git-common state, runs doctor, and touches only Boatstack infrastructure. After installation use prepare-update-pr --repo . --version --json. Show version and repair provenance, release notes and link, integration state, title, body, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and render the one next action as: Reply `o` to open update PR. Continue accepting the full reply open update PR for compatibility without advertising it. Only the matching state-scoped shortcut or compatible full reply authorizes publish-update-pr --repo . --preview --preview-fingerprint . The deterministic publisher stages only previewed paths, reuses an existing update commit, pushes normally, reconciles the exact branch and PR after an interrupted response, and opens at most one reviewer-ready PR. Never stage, commit, push, or open the update PR through free-form terminal calls. If GitHub auth is unavailable, preserve the branch and give one manual publication action. If operation-status reports EXECUTING, wait; if it reports RECONCILE_REQUIRED, reconcile instead of repeating publication. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.",
- "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.",
- "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require the state-scoped reply o to open or u to update the PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.",
- "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.",
- "workspace-cut": "Move a validated planning package into its final feature workspace before approval or autonomy is recorded. Surfaced by boatstack-next after check-plan passes when workspace.enabled; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-cut --repo . --feature . It fetches origin and creates, adopts, or reuses only an unowned exact-base destination with the same plan fingerprint and controller identity. Diverged, dirty, owned, or conflicting destinations fail closed without moving plan authority. Report destination_repository, branch, base_commit, plan_fingerprint, controller_mode, and outcome, then continue every later command from destination_repository.",
- "workspace-cleanup": "Reclaim a published feature's managed workspace once its work has landed. This operation is surfaced by boatstack-next after publication; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-status --repo . --branch to report whether the pull request is merged, using the GitHub CLI with a local-ancestry fallback. When workspace.cleanup_after is merge, offer removal only once the PR is confirmed merged; if it is still open, report that and offer to keep waiting or, only on an explicit human override request, proceed. Never remove a workspace with uncommitted or unmerged work without an explicit forced override, and never delete a remote branch or merge anything; cleanup reclaims only the local worktree and branch. In confirm mode respond Workspace ready to clean up and render the one next action as: Reply `c` to clean up, or `k` to keep. Only after the exact reply c run workspace-cleanup --repo . --branch with --confirm (add --force only for an explicit override); on k respond Workspace kept with no action required. In auto mode reclaim a merged workspace without a prompt; in off mode do not offer cleanup. After removal, report whether the worktree and branch were reclaimed.",
- "workspace-reap": "Sweep every terminal managed workspace at the safe post-merge checkpoint, reclaiming the accumulated backlog in one prompt. This operation is surfaced by boatstack-next when a delivery's PR is confirmed merged; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-reap --repo . to inspect all Boatstack worktrees and branches — those created under .product-loop/worktrees/ — and identify which are reclaimable: confirmed merged (GitHub CLI with a local-ancestry fallback) or explicitly abandoned (their feature slug is in workflow.ignored_deliveries). Never reap an unmerged workspace with an open or unknown-state PR, a non-Boatstack worktree, the base branch, the current worktree, or a workspace with uncommitted or unmerged work without an explicit forced override; and never delete a remote branch or merge anything. In confirm mode the helper returns NEEDS_CONFIRMATION with the reclaimable count: respond N Boatstack worktrees/branches are merged or abandoned and reclaimable and render the one next action as: Reply `c` to reap, or `k` to keep. Only after the exact reply c run workspace-reap --repo . --confirm (add --force only for an explicit override); on k respond Workspaces kept with no action required. In auto mode reclaim them without a prompt; in off mode do not offer reaping. After removal, report how many worktrees and branches were reclaimed.",
- }
- operations["boatstack-run"] = "Resolve an explicit target and feature slug from --to plan|verified|pr, the user's wording, or the supplied plan; when the target is absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run operation-status first and reconcile in-flight work. For a new feature with a supplied source plan, resolve flow bootstrap before calling feature-scoped next-status; an absent candidate is creation intent, not stale delivery state. Execute its returned planning_envelope, then carry the feature through every later status and planning call so unrelated saved drafts cannot redirect the run. For an existing saved or active feature, run next-status --repo . --feature --json normally. Enter auto-plan only with the supplied durable in-repo source plan. Before delivery mutation, run run-preflight --repo . --json; it may fetch origin and must stop on freshness failure. During planning, route every question through the shared decision boundary: only a non-material, within-spec, reversible choice with one recommendation, cited repository evidence, no protected impact, and a runnable independent oracle may be recorded as RESOLVED_BY_POLICY; every failed or unknown condition requires the human. After check-plan passes, run workspace-cut when prescribed and continue from its destination_repository. Discard every earlier bootstrap prescription and resolve again there. Only then run record-autonomy with the selected target, so the receipt binds the final feature branch. Target plan stops at the valid reviewable plan. Targets verified and pr pass autonomy.md to activate-plan, drive the canonical build, test, journey, and review operations, and stop on any stale evidence, new product decision, unsafe capability, branch mismatch, unsupported recovery, or exhausted three complete automated repair-and-gate cycles. After each successful canonical operation, automatically continue the run from freshly resolved repository state. Target verified stops after current test and review receipts pass. Target pr prepares and revalidates the exact PR preview, then passes autonomy.md to publish-pr for the single recorded open or update action without asking for o or u. Changed plan, repository, branch, target, PR action, preview, or receipt invalidates publication. Runs without autonomy.md preserve human plan approval and o/u publication confirmation. Never force-push, discard changes, or execute foreign programs; never merge or deploy. When the selected goal is already reached, respond Feature complete. Report the selected target, policy decisions, current stage, stop reason, and one next action."
- operations["boatstack-run"] += " If status is NOT_STARTED, route to auto-plan, but first run run-preflight --repo . --health-only --json before auto-plan writes any feature artifact; planning and plan-gate do not require delivery preflight beyond this pure health check. Stop without writing when installation or generated state is unhealthy. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action."
- operations["boatstack-run"] += " If lifecycle_state is AMENDMENT_REQUIRED, AMENDMENT_DRAFTED, AMENDMENT_APPROVED, or PLAN_INVALID, treat that state as authoritative over the active slice status. Use the owned amend-plan, plan-gate, and activation transitions; never route directly back to test or publication."
- operations["auto-plan"] += " Use plan schema v3. Record journey_evidence as relevant with typed runnable oracles mapped to acceptance criteria, or not_relevant with a reason."
- operations["plan-gate"] += " check-plan must return current READINESS_FINGERPRINT before approval is displayed. When workspace-cut is prescribed, complete it and continue from destination_repository before recording approval or autonomy. Stop on any branch, worktree, origin, base, upstream, or journey-capability block."
- operations["build"] = strings.Replace(operations["build"], "compiled task graph, test matrix, evidence ledger, and the plan lock", "compiled task graph, test matrix, evidence ledger, journey-oracle manifest, and the plan lock", 1)
- operations["build"] = strings.Replace(operations["build"], "so all four land", "so all five land", 1)
- operations["build"] += " Activation repeats readiness atomically and compiles journey-oracles.json into the same immutable authority boundary."
- operations["build"] += " For a goal-driven run targeting verified or pr, require the current autonomy.md and pass it to activate-plan with --autonomy; this scoped receipt selects policy authorization even when the project normally requires human plan approval."
- operations["repair"] += " Every repair classification requires --mechanism. Implementation, verification, and review repairs each have an independent three-attempt budget; identical class/evidence/mechanism retries are friction and amendments or readiness recovery consume no attempts."
- operations["test-gate"] += " For relevant journey evidence, run every compiled oracle and import current typed results with record-journey-results before the gate. An automated privacy_status clean capture is not human-reviewed: after importing it, require review-pr-visual-evidence with the exact manifest fingerprint and PNGs before PR checking or publication."
- operations["review-gate"] += " Reject missing, failed, manifest-mismatched, head-mismatched, or diff-stale required journey results."
- operations["ship-gate"] += " A relevant automated capture must have a current review-pr-visual-evidence receipt binding the exact manifest fingerprint and PNG hashes before check-pr; changed pixels invalidate it. When a current autonomy.md has target pr, its repository, branch, plan, and open/update action match, and the exact preview fingerprint still passes check-pr, flow next may invoke publish-pr once with --autonomy without asking for o or u. Any mismatch invalidates autonomous publication and returns to the operator; runs without that receipt retain the normal confirmation."
-
- if contains(adapters, "cursor") {
- rule := fmt.Sprintf(`---
-description: Use Boatstack for evidence-engineered planning, delivery repair, explicit approval, open implementation, evidence gates, and PR preparation.
-globs:
-alwaysApply: true
----
-
-The source of truth is @.product-loop/workflow.md and @.product-loop/project.json.
-Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for improvement experiments.
-Ordinary product intent starts in the host's Plan mode. Pass the resulting plan to auto-plan explicitly with --plan ; Boatstack never scans directories for plans, and must not invent a substitute. Keep that plan file present and current through build.
-Do not start build work until the plan gate is ready and build activation has produced a valid plan lock. Require approval.md only when workflow.human_plan_approval is true; otherwise the lock must record policy activation.
-Before modifying product code for an active delivery, resolve complete Boatstack state. Installation, attachment, saved drafts, approvals, policy-ready plans, ambiguity, and invalid candidates are observations, not ambient workflow authority. They never control ordinary repository tools. Managed workflow control begins only with a current plan lock bound to this worktree and branch. Async task completion and conversation state never grant authority. When active or published work receives a product behavior, implementation, test, review, delivery-evidence, CI, or publication problem or modification, route through the Boatstack repair operation before editing. Never ask the user to manually repeat a denied push or PR mutation. If no current-worktree managed delivery exists, continue ordinary conversation unless the user explicitly invokes Boatstack.
-%s
-Implementation methods are open. Claims of completion, approval, review, and shipping require evidence.
-Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority.
-When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation.
-When the user asks to update Boatstack itself, use /boatstack-update. Release discovery is read-only and cached; repository mutation begins only from a clean current default branch and is isolated in a versioned chore/update-boatstack branch. Preview the exact infrastructure diff before requiring open update PR. Never mix a Boatstack update into product work or merge it automatically.
-Do not branch behavior on model name, provider, or price; branch on observed work state and evidence.
-Boatstack's repository hooks deny high-confidence irreversible operations across every agent call. There is no in-session bypass. Preserve failed external state, use read-only diagnosis and fix-forward recovery, and leave intentional destructive recovery to an operator-owned surface outside Boatstack.
-`, repositoryAdministrationBoundary)
- files[fmt.Sprintf(".cursor/rules/%s.mdc", adapterName)], err = GeneratedFrontmatter(rule + executionModeDX + "\n" + ExecutionBoundaryDX)
- if err != nil {
- return ExportBundle{}, err
- }
- for operation, extra := range operations {
- files[fmt.Sprintf(".cursor/commands/%s.md", operation)] = GeneratedMarkdown(commandBody(operation, extra))
- }
- }
-
- adapterSkill := fmt.Sprintf(`---
-name: %s
-description: Use only when the user explicitly asks for Boatstack or when work targets a verified active pre-publication Boatstack delivery in the current worktree and branch. Do not activate from installation, attachment, saved or approved drafts, published work, repository presence, or repository administration.
----
-
-# Boatstack adapter
-
- Read .product-loop/project.json and .product-loop/workflow.md only after explicit invocation or verified active engagement. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, insight-capture, insight-frontier, root-cause, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, workspace-cleanup, and workspace-reap. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route requests to preserve a vague idea or customer message to insight-capture, and requests for pending ideas to the read-only insight-frontier operation. Route bug diagnosis such as a stack trace or "why did this crash" to the read-only root-cause operation. Boatstack begins only on an explicit user request or a verified active pre-publication delivery bound to the current worktree and branch. Installation, attachment, drafts, approvals, ambiguity, invalid candidates, published work, and repository administration never control ordinary repository tools. After explicit invocation, carry the selected feature slug through status and planning calls. Automatically use repair only while that verified pre-publication delivery remains active. Once publication succeeds, later CI repair, PR update, cleanup, or corrective work requires another explicit Boatstack request.
-
-%s
-
-Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. Write every response in Simplified Technical English: short sentences, the active voice, the present tense, one idea per sentence, the condition first, and the simple common word.
-
-Ordinary product intent must first be explored in the host's Plan mode and saved as a file. The host passes that plan to auto-plan explicitly with --plan , which auto-plan validates and records as source_plan_path. Boatstack never scans directories for plans, so --plan is required. The plan must live inside the repository so it stays committed and hash-current through build. Auto-plan and plan-gate write Markdown only: plan.md remains canonical, approval.md records explicit human acceptance, and autonomy.md records invocation-scoped policy authority for $boatstack run --to plan|verified|pr. Repository facts are DISCOVERED, agent suggestions are PROPOSED, human responses are ANSWERED, and eligible autonomous choices are RESOLVED_BY_POLICY. Every material proposal remains blocking. When workspace management is enabled, move the validated package with workspace-cut and continue from destination_repository before recording approval or autonomy. At build, confirm the host can edit product code before activation. A rejected mode transition creates no machine artifacts or lock. Activation compiles machine artifacts and binds either human approval or the exact autonomy receipt before the first product-code edit. The source plan remains required and hash-current through build. Test, review, and ship gates operate from the authorization lock, diff, and evidence after build.
-
-Internal phases are ordinary tasks inside one delivery slice. Multiple PRs require explicit ordered delivery_slices with every task assigned exactly once. After activation, read delivery-status and work only on the active slice. Test-gate and review-gate must record slice-scoped receipts bound to the current branches, commit, diff, and evidence. Direct push, PR mutation, and ad-hoc PR routing are denied while managed delivery is active. Successful confirmed publication advances exactly one slice; plan approval never authorizes later slices.
-
-Use one global, state-scoped reply grammar for finite input: a approves the pending plan, o opens the currently previewed feature/ad-hoc/update PR, u updates the currently previewed existing PR, s saves the currently previewed fingerprint-bound insight, and r accepts every recommendation displayed in the current finite-question response. Trim surrounding whitespace and match the complete reply case-insensitively. Bracketed forms such as [o], embedded letters, and shortcuts from another state are ordinary text. Continue accepting approve, open PR, update PR, and open update PR for compatibility, but do not advertise them in user-facing responses. The s shortcut never approves a plan, binds a delivery, completes an insight, or grants PR authority.
-
-Shortcuts never bypass preview fingerprints, committed-diff checks, evidence, authentication, or manual commit/push prerequisites. Never interpret r as plan approval, PR publication, identity, secret input, permission escalation, policy bypass, destructive recovery authorization, or another exceptional safety decision. Free-text and operation-command prompts remain explicit. End the pending approval response with Reply `+"`a`"+` to approve. Use an explicit supplied approval identity first; otherwise use the authenticated GitHub login when the repository is on GitHub and it is available. Ask once for a name or handle only when no trustworthy identity can be resolved. Never invent a placeholder name (e.g., Sam, Eve) and never infer the approver from a filesystem username, commit history, or the coding agent. If identity is unavailable after approval, preserve the current approval intent, create no receipt, and ask only for identity; do not require approval again when the unchanged plan and identity are available.
-
-For each finite product question, show 2-3 choices with compact keys such as 1a/1b/1c and 2a/2b/2c and suffix exactly one label per question with (Recommended). End with one hint naming the keys or r for all recommendations. A standalone r is valid only when every displayed question has exactly one recommendation. Echo the selected question-to-answer mapping before recording each answer as ANSWERED with explicit human provenance; otherwise ask again without choosing.
-
-Use .product-loop/artifacts.md for document boundaries and .product-loop/failure-moves.md for improvement experiments. If a structured question tool is unavailable, ask 1-3 plain-text questions and return WAITING_FOR_INPUT; never select defaults on the user's behalf. Do not implement from an unapproved or stale plan. Implementation tactics are open; completion, approval, and shipping claims require current evidence. Do not branch on model identity; use observable state and gate evidence.
-
-During explicit Boatstack commands and verified active deliveries, repository hooks enforce Boatstack's immutable deny policy. Outside engagement they emit nothing, block nothing, and do not load or hydrate Boatstack. Never request an in-session bypass while engaged. After an engaged external-write failure, preserve state, run only read-only diagnosis, and prefer transactional rollback or fix-forward recovery.
-
-At ship, prove whether a failing check is pre-existing by checking the base branch. Keep unrelated repairs in a separate PR; do not modify unrelated code under the approved feature lock. A repository-policy bypass requires explicit human authorization and recorded evidence.
-
-When the user asks to update Boatstack, run the boatstack-update operation. Never prepare it on a feature branch or dirty worktree. A successful update is a separate versioned infrastructure branch whose exact diff is shown before requiring state-scoped o to publish the update PR. Preserve current adapters, integrations, and project configuration; never merge the update automatically. After successful feature PR publication, surface UPDATE_AVAILABLE only as a collapsed informational notice while Review the PR remains the sole next action.
-
-For a managed ship, use the internal pr-context operation with --feature to project the feature spec, accepted decisions, actual committed diff, evidence ledger, review findings, gaps, rollout, and rollback into the required pr.md artifact. Inspect the returned changed files, diff stat, high-risk matches, and the actual diff before writing claims; commits alone are not authoritative. Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. Add UI evidence, security/privacy, migration, or operations sections only when relevant. For a natural-language request to improve an existing or ad-hoc PR, run pr-context without --feature and use the same reviewer-first format from observed branch facts, but mark unavailable approval or gate evidence as NOT_VERIFIED. Never create or advertise a /pr-brief command. Validate with check-pr and always show the exact title and rendered body before publication. Ask for state-scoped o to open or u to update the PR. Only after the matching shortcut or compatible full reply, commit only pr.md, revalidate the unchanged preview fingerprint, and invoke the internal publish-pr operation with the selected action. It may perform a normal push but never force-push. Any intervening product diff or evidence change invalidates the preview. Keep model attribution inside collapsed provenance. Internal helper names and hashes stay out of the primary response.
-
-If gstack is enabled, use only its namespaced /gstack-* specialist lenses inside Boatstack operations. If Spec Kit is enabled, use it to generate or cross-check artifacts; never invoke speckit.implement to bypass Boatstack's plan approval and build gate.
- `, adapterName, repositoryAdministrationBoundary) + executionModeDX
- if contains(adapters, "claude") {
- claudeAdapterSkill := strings.Replace(
- adapterSkill,
- "\n---\n\n# Boatstack adapter",
- "\nuser-invocable: false\n---\n\n# Boatstack adapter",
- 1,
- )
- files[fmt.Sprintf(".claude/skills/%s/SKILL.md", adapterName)], err = GeneratedFrontmatter(claudeAdapterSkill)
- if err != nil {
- return ExportBundle{}, err
- }
- for _, spec := range claudeVisibleSkills {
- extra, ok := operations[spec.Name]
- if !ok {
- return ExportBundle{}, fmt.Errorf("missing operation instructions for Claude skill %s", spec.Name)
- }
- path := fmt.Sprintf(".claude/skills/%s/SKILL.md", spec.Name)
- files[path], err = GeneratedFrontmatter(
- claudeOperationSkill(spec, commandBody(spec.Name, extra)),
- )
- if err != nil {
- return ExportBundle{}, err
- }
- }
- }
- if contains(adapters, "gemini") {
- geminiAdapterSkill := strings.Replace(
- adapterSkill,
- "\n---\n\n# Boatstack adapter",
- "\nuser-invocable: false\n---\n\n# Boatstack adapter",
- 1,
- )
- files[fmt.Sprintf(".gemini/skills/%s/SKILL.md", adapterName)], err = GeneratedFrontmatter(geminiAdapterSkill)
- if err != nil {
- return ExportBundle{}, err
- }
- for _, spec := range claudeVisibleSkills {
- extra, ok := operations[spec.Name]
- if !ok {
- return ExportBundle{}, fmt.Errorf("missing operation instructions for Gemini skill %s", spec.Name)
- }
- path := fmt.Sprintf(".gemini/skills/%s/SKILL.md", spec.Name)
- files[path], err = GeneratedFrontmatter(
- claudeOperationSkill(spec, commandBody(spec.Name, extra)),
- )
- if err != nil {
- return ExportBundle{}, err
- }
- }
- }
- if contains(adapters, "codex") {
- codexAdapterSkill := strings.Replace(
- adapterSkill,
- "move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details.",
- "move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks under a plain `### Technical details` Markdown heading. Codex must never emit raw `` or `` tags; preserve the same content without collapse.",
- 1,
- )
- codexAdapterSkill = strings.Replace(
- codexAdapterSkill,
- "keep Review the PR as the one next action and append a collapsed informational notice",
- "keep Review the PR as the one next action and append the informational notice under the plain Technical details heading",
- 1,
- )
- files[fmt.Sprintf(".agents/skills/%s/SKILL.md", adapterName)], err = GeneratedFrontmatter(codexAdapterSkill)
- if err != nil {
- return ExportBundle{}, err
- }
- for _, spec := range claudeVisibleSkills {
- extra, ok := operations[spec.Name]
- if !ok {
- return ExportBundle{}, fmt.Errorf("missing operation instructions for Codex skill %s", spec.Name)
- }
- path := fmt.Sprintf(".agents/skills/%s/SKILL.md", spec.Name)
- files[path], err = GeneratedFrontmatter(
- codexOperationSkill(spec, commandBody(spec.Name, extra)),
- )
- if err != nil {
- return ExportBundle{}, err
- }
- }
- }
- if contains(adapters, "github") {
- files[fmt.Sprintf(".github/PULL_REQUEST_TEMPLATE/%s.md", adapterName)] = GeneratedMarkdown(`# Reviewer-ready change
-
-## Why this change
-
-Explain the user or engineering outcome, not merely the files edited.
-
-## What changed
-
-| Area | Before | After | Reviewer focus |
-|---|---|---|---|
-| | | | |
-
-## Review order
-
-1. Start with the contract, trust boundary, or user-visible behavior.
-
-## Evidence
-
-| Claim | Evidence | Result | Source |
-|---|---|---|---|
-| | | NOT_VERIFIED | |
-
-## Operational safety
-
-State the operational-diff safety result and keep destructive recovery operator-only.
-
-## Known gaps and risks
-
-List explicit gaps with impact and revisit trigger, or state that no material gaps are known.
-
-## Rollout and rollback
-
-- Rollout:
-- Observability:
-- Smallest safe rollback:
-
-
-Boatstack provenance
-
-- Mode: managed or evidence-limited ad-hoc
-- Approval and gate evidence:
-- Coding-host attribution:
-
-
-`)
- }
- if err := validateGeneratedSkills(files); err != nil {
- return ExportBundle{}, err
- }
-
- lineEndingAttributes := []byte("# " + Marker + "\n* text eol=lf\n")
- for prefix, attributesPath := range map[string]string{
- ".agents/skills/": ".agents/skills/.gitattributes",
- ".claude/skills/": ".claude/skills/.gitattributes",
- ".cursor/": ".cursor/.gitattributes",
- ".gemini/skills/": ".gemini/skills/.gitattributes",
- ".github/PULL_REQUEST_TEMPLATE/": ".github/PULL_REQUEST_TEMPLATE/.gitattributes",
- ".product-loop/": ".product-loop/.gitattributes",
- } {
- for path := range files {
- if strings.HasPrefix(path, prefix) {
- files[attributesPath] = lineEndingAttributes
- break
- }
- }
- }
-
- hashes := map[string]string{}
- for path, value := range files {
- hashes[path] = SHA256Bytes(value)
- }
- lock := map[string]any{
- "schema_version": 1,
- "generator": Generator,
- "boatstack_version": Version,
- "config_source": filepath.Base(configPath),
- "config_sha256": SHA256Bytes(rawConfig),
- "adapters": adapters,
- "integrations": config.Integrations,
- "runtime": map[string]any{
- "source_commit": SourceCommit,
- "checksums_sha256": ChecksumsSHA256,
- },
- "files": hashes,
- }
- files[".product-loop/generated.lock.json"], err = GeneratedJSON(lock)
- if err != nil {
- return ExportBundle{}, err
- }
- for _, path := range sortedKeys(files) {
- if strings.HasSuffix(path, ".json") {
- if err := ValidateJSON("validate generated export bundle", path, files[path]); err != nil {
- return ExportBundle{}, err
- }
- }
- }
- return ExportBundle{Files: files, Config: config}, nil
-}
-
-func contains(values []string, expected string) bool {
- for _, value := range values {
- if value == expected {
- return true
- }
- }
- return false
-}
-
-func owned(value []byte, path string) bool {
- text := string(value)
- if strings.Contains(text, Marker) || strings.Contains(text, "Generated by product-engineering-loop exporter.") {
- return true
- }
- if strings.HasSuffix(path, ".json") {
- decoded := map[string]any{}
- if json.Unmarshal(value, &decoded) == nil {
- generator := decoded["_generated_by"]
- return generator == Generator || generator == "product-engineering-loop-exporter"
- }
- }
- return false
-}
-
-func previousFiles(repo string) map[string]string {
- value, err := os.ReadFile(filepath.Join(repo, ".product-loop/generated.lock.json"))
- if err != nil {
- return map[string]string{}
- }
- var lock struct {
- Files map[string]string `json:"files"`
- }
- if json.Unmarshal(value, &lock) != nil || lock.Files == nil {
- return map[string]string{}
- }
- return lock.Files
-}
-
-func ExportCollisions(repo string, files map[string][]byte) []string {
- problems := []string{}
- for _, relative := range sortedKeys(files) {
- target := filepath.Join(repo, filepath.FromSlash(relative))
- current, err := os.ReadFile(target)
- if os.IsNotExist(err) || (err == nil && string(current) == string(files[relative])) {
- continue
- }
- if err != nil || !owned(current, relative) {
- problems = append(problems, relative)
- }
- }
- previous := previousFiles(repo)
- for relative, expectedHash := range previous {
- if _, stillGenerated := files[relative]; stillGenerated {
- continue
- }
- current, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative)))
- if err == nil && SHA256Bytes(current) != expectedHash {
- problems = append(problems, "stale generated path modified downstream: "+relative)
- }
- }
- sort.Strings(problems)
- return problems
-}
-
-func WriteExport(repo string, files map[string][]byte) error {
- return writeExport(repo, files, nil)
-}
-
-func WriteExportForRepair(repo string, files map[string][]byte, repairPaths map[string]bool) error {
- return writeExport(repo, files, repairPaths)
-}
-
-func writeExport(repo string, files map[string][]byte, repairPaths map[string]bool) error {
- problems := ExportCollisions(repo, files)
- if len(problems) > 0 {
- remaining := []string{}
- for _, problem := range problems {
- path := strings.TrimPrefix(problem, "stale generated path modified downstream: ")
- if !repairPaths[path] {
- remaining = append(remaining, problem)
- }
- }
- problems = remaining
- }
- if len(problems) > 0 {
- return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", "))
- }
- for relative, expectedHash := range previousFiles(repo) {
- if _, stillGenerated := files[relative]; stillGenerated {
- continue
- }
- target := filepath.Join(repo, filepath.FromSlash(relative))
- current, err := os.ReadFile(target)
- if err == nil && (SHA256Bytes(current) == expectedHash || repairPaths[relative]) {
- if err := os.Remove(target); err != nil {
- return err
- }
- }
- }
- for _, relative := range sortedKeys(files) {
- if err := writeFile(filepath.Join(repo, filepath.FromSlash(relative)), files[relative], generatedFileMode(relative)); err != nil {
- return err
- }
- }
- return nil
-}
-
-func generatedFileMode(relative string) os.FileMode {
- if relative == ".product-loop/boatstack" {
- return 0o755
- }
- return 0o644
-}
-
-func CheckExport(repo string, files map[string][]byte) error {
- problems := []string{}
- for _, relative := range sortedKeys(files) {
- path := filepath.Join(repo, filepath.FromSlash(relative))
- current, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- problems = append(problems, "missing "+relative)
- } else if err != nil {
- problems = append(problems, fmt.Sprintf("unreadable %s: %v", relative, err))
- } else if string(current) != string(files[relative]) {
- problems = append(problems, "drift "+relative)
- } else if relative == ".product-loop/boatstack" && runtime.GOOS != "windows" {
- if info, statErr := os.Stat(path); statErr != nil || info.Mode().Perm()&0o111 == 0 {
- problems = append(problems, "non-executable "+relative)
- }
- }
- }
- if len(problems) > 0 {
- return fmt.Errorf("generated output is stale: %s", strings.Join(problems, ", "))
- }
- return nil
-}
diff --git a/boatstack/export_test.go b/boatstack/export_test.go
deleted file mode 100644
index 40fcfa0..0000000
--- a/boatstack/export_test.go
+++ /dev/null
@@ -1,740 +0,0 @@
-package boatstack
-
-import (
- "io/fs"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-type fixtureDirEntry struct{ name string }
-
-func (entry fixtureDirEntry) Name() string { return entry.name }
-func (entry fixtureDirEntry) IsDir() bool { return false }
-func (entry fixtureDirEntry) Type() fs.FileMode { return 0 }
-func (entry fixtureDirEntry) Info() (fs.FileInfo, error) { return nil, nil }
-
-func TestBuildExportBundleNamesMalformedEmbeddedJSONTemplate(t *testing.T) {
- oldRead := readCanonical
- oldReadDir := readCanonicalDir
- defer func() { readCanonical, readCanonicalDir = oldRead, oldReadDir }()
- readCanonicalDir = func(path string) ([]fs.DirEntry, error) {
- entries, err := oldReadDir(path)
- return append(entries, fixtureDirEntry{name: "nul-fixture.json"}), err
- }
- readCanonical = func(path string) ([]byte, error) {
- if path == "assets/templates/nul-fixture.json" {
- return []byte{'{', 0, '}'}, nil
- }
- return oldRead(path)
- }
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- _, err = BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err == nil || !strings.Contains(err.Error(), "build export bundle from JSON template") || !strings.Contains(err.Error(), "assets/templates/nul-fixture.json") {
- t.Fatalf("malformed template error lacks operation or exact asset: %v", err)
- }
-}
-
-func TestDecodeJSONAlwaysNamesOperationAndSource(t *testing.T) {
- for _, test := range []struct {
- operation string
- source string
- }{
- {"load project configuration", "/repo/.boatstack-project.json"},
- {"validate generated export bundle", ".product-loop/generated.lock.json"},
- {"look up latest release", "GitHub releases/latest response"},
- } {
- t.Run(test.operation, func(t *testing.T) {
- var decoded any
- err := DecodeJSON(test.operation, test.source, []byte{'{', 0, '}'}, &decoded)
- if err == nil || !strings.Contains(err.Error(), "operation "+test.operation) || !strings.Contains(err.Error(), "parse JSON "+test.source) {
- t.Fatalf("JSON diagnostic lacks operation or source: %v", err)
- }
- })
- }
-}
-
-func testConfig() ProjectConfig {
- return ProjectConfig{
- SchemaVersion: 1,
- Project: Project{
- Name: "fixture", Commands: map[string]string{"test": "go test ./..."},
- },
- Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true},
- Adapters: []string{"cursor", "claude", "codex", "gemini", "github"},
- Integrations: map[string]IntegrationState{
- "gstack": {Requested: false, Version: GStackRef},
- "spec-kit": {Requested: false, Version: SpecKitVersion},
- },
- }
-}
-
-func TestExportAndDriftCheck(t *testing.T) {
- repo := t.TempDir()
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- if err := CheckExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- if err := InstallHostHooks(repo, config.Adapters); err != nil {
- t.Fatal(err)
- }
- if err := CheckHostHooks(repo, config.Adapters); err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{
- ".cursor/commands/boatstack-next.md",
- ".cursor/commands/boatstack-update.md",
- ".cursor/commands/repair.md",
- ".cursor/commands/plan-gate.md",
- ".cursor/commands/review.md",
- ".claude/skills/boatstack/SKILL.md",
- ".claude/skills/auto-plan/SKILL.md",
- ".claude/skills/boatstack-update/SKILL.md",
- ".gemini/skills/boatstack/SKILL.md",
- ".gemini/skills/auto-plan/SKILL.md",
- ".gemini/skills/boatstack-update/SKILL.md",
- ".agents/skills/boatstack/SKILL.md",
- ".agents/skills/boatstack-run/SKILL.md",
- ".agents/skills/auto-plan/SKILL.md",
- ".agents/skills/.gitattributes",
- ".claude/skills/.gitattributes",
- ".cursor/.gitattributes",
- ".gemini/skills/.gitattributes",
- ".github/PULL_REQUEST_TEMPLATE/.gitattributes",
- ".product-loop/.gitattributes",
- ".product-loop/.gitignore",
- ".product-loop/boatstack",
- ".product-loop/boatstack.ps1",
- ".product-loop/templates/plan.md",
- ".product-loop/templates/approval.md",
- ".product-loop/hooks/guard.sh",
- ".product-loop/hooks/guard.ps1",
- ".product-loop/hooks/cursor.fragment.json",
- } {
- if !fileExists(filepath.Join(repo, filepath.FromSlash(path))) {
- t.Fatalf("expected generated file %s", path)
- }
- }
- launcherPath := filepath.Join(repo, ".product-loop", "boatstack")
- launcherInfo, err := os.Stat(launcherPath)
- if err != nil || (runtime.GOOS != "windows" && launcherInfo.Mode().Perm()&0o111 == 0) {
- t.Fatalf("POSIX launcher does not have its generated mode: %v", err)
- }
- if runtime.GOOS != "windows" {
- if err := os.Chmod(launcherPath, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CheckExport(repo, bundle.Files); err == nil || !strings.Contains(err.Error(), "non-executable .product-loop/boatstack") {
- t.Fatalf("export check accepted a non-executable launcher: %v", err)
- }
- if err := os.Chmod(launcherPath, 0o755); err != nil {
- t.Fatal(err)
- }
- }
- for path, content := range bundle.Files {
- if path == ".product-loop/boatstack" || path == ".product-loop/boatstack.ps1" {
- continue
- }
- if strings.Contains(string(content), ".product-loop/bin/boatstack-helper") {
- t.Fatalf("generated surface %s invokes the internal runtime directly", path)
- }
- }
- claudeSkillPaths := map[string]bool{}
- for path := range bundle.Files {
- if strings.HasPrefix(path, ".claude/skills/") && strings.HasSuffix(path, "/SKILL.md") {
- claudeSkillPaths[path] = true
- }
- }
- if len(claudeSkillPaths) != len(claudeVisibleSkills)+1 {
- t.Fatalf("generated %d Claude skills, want %d: %#v", len(claudeSkillPaths), len(claudeVisibleSkills)+1, claudeSkillPaths)
- }
- for _, spec := range claudeVisibleSkills {
- path := ".claude/skills/" + spec.Name + "/SKILL.md"
- skill := string(bundle.Files[path])
- for _, expected := range []string{
- "name: " + spec.Name,
- "description: " + spec.Description,
- "disable-model-invocation: true",
- "Run the " + spec.Name + " operation",
- ".product-loop/workflow.md",
- "User-facing response contract",
- } {
- if !strings.Contains(skill, expected) {
- t.Fatalf("%s is missing %q", path, expected)
- }
- }
- }
- claudeAutoPlan := string(bundle.Files[".claude/skills/auto-plan/SKILL.md"])
- for _, expected := range []string{`argument-hint: "[plan-file]"`, "$ARGUMENTS", "/auto-plan "} {
- if !strings.Contains(claudeAutoPlan, expected) {
- t.Fatalf("Claude auto-plan skill is missing argument behavior %q", expected)
- }
- }
- claudeRouter := string(bundle.Files[".claude/skills/boatstack/SKILL.md"])
- if !strings.Contains(claudeRouter, "user-invocable: false") || strings.Contains(claudeRouter, "disable-model-invocation: true") {
- t.Fatal("Claude Boatstack router must be hidden from users but available to the model")
- }
- for _, operation := range []string{"retro", "review", "ship"} {
- path := ".claude/skills/" + operation + "/SKILL.md"
- if claudeSkillPaths[path] {
- t.Fatalf("internal or alias operation must not be a visible Claude skill: %s", path)
- }
- }
-
- codexSkillPaths := map[string]bool{}
- for path := range bundle.Files {
- if strings.HasPrefix(path, ".agents/skills/") && strings.HasSuffix(path, "/SKILL.md") {
- codexSkillPaths[path] = true
- }
- }
- if len(codexSkillPaths) != len(claudeVisibleSkills)+1 {
- t.Fatalf("generated %d Codex skills, want %d: %#v", len(codexSkillPaths), len(claudeVisibleSkills)+1, codexSkillPaths)
- }
- for _, spec := range claudeVisibleSkills {
- path := ".agents/skills/" + spec.Name + "/SKILL.md"
- skill := string(bundle.Files[path])
- for _, expected := range []string{
- "name: " + spec.Name,
- "description: " + spec.Description,
- "Run the " + spec.Name + " operation",
- ".product-loop/workflow.md",
- "User-facing response contract",
- } {
- if !strings.Contains(skill, expected) {
- t.Fatalf("%s is missing %q", path, expected)
- }
- }
- }
- codexRun := string(bundle.Files[".agents/skills/boatstack-run/SKILL.md"])
- for _, expected := range []string{
- "If status is NOT_STARTED, route to auto-plan",
- "planning and plan-gate do not require delivery preflight",
- } {
- if !strings.Contains(codexRun, expected) {
- t.Fatalf("Codex boatstack-run skill is missing operation boundary %q", expected)
- }
- }
-
- geminiSkillPaths := map[string]bool{}
- for path := range bundle.Files {
- if strings.HasPrefix(path, ".gemini/skills/") && strings.HasSuffix(path, "/SKILL.md") {
- geminiSkillPaths[path] = true
- }
- }
- if len(geminiSkillPaths) != len(claudeVisibleSkills)+1 {
- t.Fatalf("generated %d Gemini skills, want %d: %#v", len(geminiSkillPaths), len(claudeVisibleSkills)+1, geminiSkillPaths)
- }
- for _, spec := range claudeVisibleSkills {
- path := ".gemini/skills/" + spec.Name + "/SKILL.md"
- skill := string(bundle.Files[path])
- for _, expected := range []string{
- "name: " + spec.Name,
- "description: " + spec.Description,
- "disable-model-invocation: true",
- "Run the " + spec.Name + " operation",
- ".product-loop/workflow.md",
- "User-facing response contract",
- } {
- if !strings.Contains(skill, expected) {
- t.Fatalf("%s is missing %q", path, expected)
- }
- }
- }
- geminiAutoPlan := string(bundle.Files[".gemini/skills/auto-plan/SKILL.md"])
- for _, expected := range []string{`argument-hint: "[plan-file]"`, "$ARGUMENTS", "/auto-plan "} {
- if !strings.Contains(geminiAutoPlan, expected) {
- t.Fatalf("Gemini auto-plan skill is missing argument behavior %q", expected)
- }
- }
- geminiRouter := string(bundle.Files[".gemini/skills/boatstack/SKILL.md"])
- if !strings.Contains(geminiRouter, "user-invocable: false") || strings.Contains(geminiRouter, "disable-model-invocation: true") {
- t.Fatal("Gemini Boatstack router must be hidden from users but available to the model")
- }
- for _, operation := range []string{"retro", "review", "ship"} {
- path := ".gemini/skills/" + operation + "/SKILL.md"
- if geminiSkillPaths[path] {
- t.Fatalf("internal or alias operation must not be a visible Gemini skill: %s", path)
- }
- }
- if _, exists := bundle.Files[".product-loop/tools/approve_plan.py"]; exists {
- t.Fatal("public export must not contain Python runtime tools")
- }
- if _, exists := bundle.Files[".product-loop/templates/plan.json"]; exists {
- t.Fatal("Markdown-native planning must not export plan.json")
- }
- autoPlan := string(bundle.Files[".cursor/commands/auto-plan.md"])
- planGate := string(bundle.Files[".cursor/commands/plan-gate.md"])
- build := string(bundle.Files[".cursor/commands/build.md"])
- responseOutcomes := map[string][]string{
- "boatstack-run": {"Start a Boatstack feature", "Feature complete"},
- "insight-capture": {"Insight ready to save", "Insight saved"},
- "insight-frontier": {"Insight frontier ready"},
- "root-cause": {"Root cause found"},
- "auto-plan": {"Plan ready", "I need your input"},
- "plan-gate": {"Ready for your approval", "Approved — ready to build"},
- "build": {"Build complete", "Build needs a decision"},
- "repair": {},
- "test-gate": {"Tests passed", "Testing found a problem"},
- "review-gate": {"Review passed", "Changes required"},
- "review": {"Review passed", "Changes required"},
- "ship-gate": {"PR ready", "PR opened"},
- "ship": {"PR ready", "PR opened"},
- "retro": {"Improvement proposed"},
- "boatstack-update": {"Boatstack is current", "Update postponed", "Boatstack update ready", "Update PR opened", "Update needs attention"},
- }
- for operation, outcomes := range responseOutcomes {
- command := string(bundle.Files[".cursor/commands/"+operation+".md"])
- for _, expected := range []string{"User-facing response contract", "### Next step", "Technical details", "do not expose them in the primary response"} {
- if !strings.Contains(command, expected) {
- t.Fatalf("%s adapter is missing response-DX rule %q", operation, expected)
- }
- }
- for _, outcome := range outcomes {
- if !strings.Contains(command, outcome) {
- t.Fatalf("%s adapter is missing outcome %q", operation, outcome)
- }
- }
- }
- if !strings.Contains(autoPlan, "Markdown-only") || !strings.Contains(autoPlan, "Never silently choose a default") || !strings.Contains(autoPlan, "flow bootstrap") || !strings.Contains(autoPlan, "planning_envelope") || !strings.Contains(autoPlan, "PROPOSED") {
- t.Fatal("auto-plan adapter does not enforce the Markdown and question boundaries")
- }
- // Conformance: no ambient plan context. The exported auto-plan adapter must
- // require an explicit --plan and must never reference the removed intake
- // staging directory, and the bundle must not scaffold it.
- if !strings.Contains(autoPlan, "--plan") {
- t.Fatal("auto-plan adapter must instruct passing the plan explicitly with --plan")
- }
- if strings.Contains(autoPlan, ".product-loop/intake") {
- t.Fatal("auto-plan adapter must not reference the removed .product-loop/intake staging directory")
- }
- for path := range bundle.Files {
- if strings.HasPrefix(path, ".product-loop/intake") {
- t.Fatalf("export bundle must not scaffold intake staging, found %q", path)
- }
- }
- for _, expected := range []string{"compact keys such as 1a/1b", "exactly one choice per question with (Recommended)", "offer r to accept all displayed recommendations", "echo the selected mapping"} {
- if !strings.Contains(autoPlan, expected) {
- t.Fatalf("auto-plan adapter is missing finite-question shortcut rule %q", expected)
- }
- }
- if !strings.Contains(planGate, "approval.md") || !strings.Contains(planGate, "Remain in Plan mode") || !strings.Contains(planGate, "record-approval") {
- t.Fatal("plan-gate adapter does not keep approval in Plan mode")
- }
- for _, expected := range []string{
- "normal user action is the exact standalone reply a",
- "Trim surrounding whitespace and match a case-insensitively",
- "do not treat [a] or an a embedded in other text as approval",
- "Continue accepting the full reply approve for compatibility",
- "do not advertise it in the user-facing response",
- "Reply `a` to approve.",
- "authenticated GitHub login",
- "never infer it from a filesystem username",
- } {
- if !strings.Contains(planGate, expected) {
- t.Fatalf("plan-gate adapter is missing approval identity rule %q", expected)
- }
- }
- if !strings.Contains(build, "activate-plan") || !strings.Contains(build, "READY_FOR_BUILD") || !strings.Contains(build, "without activating") || strings.Contains(build, "compile-plan") {
- t.Fatal("build adapter must activate the Markdown plan exactly once")
- }
- for _, expected := range []string{"## [Unreleased] - YYYY-MM-DD", "do not add empty category headings", "without rewriting its history or layout"} {
- if !strings.Contains(build, expected) {
- t.Fatalf("build adapter is missing changelog authoring rule %q", expected)
- }
- }
- if !strings.Contains(build, "delivery-status") || !strings.Contains(build, "push and PR mutation are never build tactics") {
- t.Fatal("build adapter does not confine work to the active delivery slice")
- }
- if !strings.Contains(string(bundle.Files[".cursor/commands/test-gate.md"]), "record-delivery-gate") || !strings.Contains(string(bundle.Files[".cursor/commands/review-gate.md"]), "record-delivery-gate") {
- t.Fatal("test and review adapters do not record slice-scoped gate receipts")
- }
- ship := string(bundle.Files[".cursor/commands/ship-gate.md"])
- for _, expected := range []string{"separate repair PR", "Never edit unrelated code", "exact title", "Reply `o` to open PR.", "Reply `u` to update PR.", "full replies open PR and update PR for compatibility", "preview fingerprint"} {
- if !strings.Contains(ship, expected) {
- t.Fatalf("ship adapter is missing reviewer-ready PR rule %q", expected)
- }
- }
- if _, exists := bundle.Files[".cursor/commands/pr-brief.md"]; exists {
- t.Fatal("PR brief must remain natural-language behavior, not a public command")
- }
- run := string(bundle.Files[".cursor/commands/boatstack-run.md"])
- for _, expected := range []string{"run-preflight --repo . --json", "fetch", "next-status --repo . --feature --json", "three complete automated repair-and-gate cycles", "automatically continue the run", "never merge or deploy"} {
- if !strings.Contains(run, expected) {
- t.Fatalf("boatstack-run adapter is missing %q", expected)
- }
- }
- update := string(bundle.Files[".cursor/commands/boatstack-update.md"])
- for _, expected := range []string{"check-update", "prepare-update-pr", "publish-update-pr", "operation-status", "chore/update-boatstack-v", "BOATSTACK_MODE=update", "BOATSTACK_REPAIR=1", "repair-status", "checksum-verify the target helper", "Reply `o` to open update PR.", "full reply open update PR for compatibility", "Never stage, commit, push, or open the update PR through free-form terminal calls", "Never merge"} {
- if !strings.Contains(update, expected) {
- t.Fatalf("update adapter is missing %q", expected)
- }
- }
- for _, operation := range []string{"auto-plan", "plan-gate", "build", "test-gate", "review-gate", "review", "retro"} {
- if strings.Contains(string(bundle.Files[".cursor/commands/"+operation+".md"]), "check-update") {
- t.Fatalf("%s must not check for Boatstack releases", operation)
- }
- }
- if strings.Contains(ship, "check-update") {
- t.Fatal("ship preview must not initiate a release check")
- }
- for _, expected := range []string{"UPDATE_AVAILABLE", "collapsed update notice", "Review the PR"} {
- if !strings.Contains(ship, expected) {
- t.Fatalf("ship adapter is missing post-publication update behavior %q", expected)
- }
- }
- cursorRule := string(bundle.Files[".cursor/rules/boatstack.mdc"])
- for _, expected := range []string{"alwaysApply: true", "Before modifying product code", "active managed delivery", "repair operation", "product behavior"} {
- if !strings.Contains(cursorRule, expected) {
- t.Fatalf("Cursor rule is missing conversational repair routing %q", expected)
- }
- }
- for _, expected := range []string{"delivery_slices", "active slice", "Direct push and PR mutation", "plan approval is never publication authority"} {
- if !strings.Contains(cursorRule, expected) {
- t.Fatalf("Cursor rule is missing phase-scoped delivery rule %q", expected)
- }
- }
- for _, expected := range []string{"naturally asks Boatstack", "evidence-limited ad-hoc PR brief", "not a /pr-brief command", "NOT_VERIFIED"} {
- if !strings.Contains(cursorRule, expected) {
- t.Fatalf("Cursor rule is missing ad-hoc PR behavior %q", expected)
- }
- }
- repair := string(bundle.Files[".cursor/commands/repair.md"])
- for _, expected := range []string{"recovery-status", "repair_active", "draft_corrective_child", "record-change", "parent_delivery", "The PR needs a corrective delivery", "never reuse the parent's approval", "Never ask the user to repeat a denied push", "MainThreadShellExec not initialized", "Developer: Reload Window"} {
- if !strings.Contains(repair, expected) {
- t.Fatalf("repair adapter is missing %q", expected)
- }
- }
- runCommand := string(bundle.Files[".cursor/commands/boatstack-run.md"])
- for _, expected := range []string{"NOT_STARTED", "auto-plan", "planning and plan-gate do not require", "MainThreadShellExec not initialized", "Developer: Reload Window"} {
- if !strings.Contains(runCommand, expected) {
- t.Fatalf("run adapter is missing startup recovery rule %q", expected)
- }
- }
- if strings.Contains(runCommand, "SOURCE_PLAN_READY") {
- t.Fatal("run adapter must no longer emit the retired SOURCE_PLAN_READY stage")
- }
- for _, path := range []string{".claude/skills/boatstack/SKILL.md", ".gemini/skills/boatstack/SKILL.md", ".agents/skills/boatstack/SKILL.md"} {
- router := string(bundle.Files[path])
- if !strings.Contains(router, "verified active pre-publication") || !strings.Contains(router, "published work") || !strings.Contains(router, "requires another explicit Boatstack request") {
- t.Fatalf("%s does not enforce explicit engagement", path)
- }
- }
- codexRouter := string(bundle.Files[".agents/skills/boatstack/SKILL.md"])
- for _, expected := range []string{
- "plain `### Technical details` Markdown heading",
- "Codex must never emit raw `` or `` tags",
- "preserve the same content without collapse",
- } {
- if !strings.Contains(codexRouter, expected) {
- t.Fatalf("Codex router is missing portable technical-details rule %q", expected)
- }
- }
- if strings.Contains(codexRouter, "into collapsed Technical details") {
- t.Fatal("Codex router still requires collapsed user-facing technical details")
- }
- claudeRouter = string(bundle.Files[".claude/skills/boatstack/SKILL.md"])
- if !strings.Contains(claudeRouter, "into collapsed Technical details") {
- t.Fatal("Claude router must retain collapsed technical details")
- }
- if !strings.Contains(string(bundle.Files[".cursor/commands/auto-plan.md"]), "inside collapsed `Technical details`") {
- t.Fatal("Cursor commands must retain collapsed technical details")
- }
- prTemplate := string(bundle.Files[".github/PULL_REQUEST_TEMPLATE/boatstack.md"])
- for _, expected := range []string{"## Why this change", "## What changed", "## Review order", "## Evidence", "## Operational safety", "## Known gaps and risks", "## Rollout and rollback", "Boatstack provenance
"} {
- if !strings.Contains(prTemplate, expected) {
- t.Fatalf("generated PR template is missing %q", expected)
- }
- }
- if !strings.Contains(ship, "separate repair PR") || !strings.Contains(ship, "Never edit unrelated code") {
- t.Fatal("ship adapter permits unrelated scope expansion")
- }
- lock := string(bundle.Files[".product-loop/generated.lock.json"])
- if !strings.Contains(lock, `"source_commit"`) || !strings.Contains(lock, `"integrations"`) ||
- !strings.Contains(lock, `".product-loop/boatstack"`) || !strings.Contains(lock, `".product-loop/boatstack.ps1"`) {
- t.Fatal("generated lock must record runtime provenance, integrations, and tracked launchers")
- }
- workflow := string(bundle.Files[".product-loop/workflow.md"])
- for _, expected := range []string{
- "### Literal planning transport",
- "flow bootstrap",
- "planning_envelope",
- "Do not select, append, or rewrite an executable path yourself",
- "PLANNING_TRANSPORT_INVALID",
- "## User-facing response contract",
- "Exactly one primary action",
- "### Reply shortcuts",
- "| `a` | Reviewed plan awaiting approval",
- "| `o` | New feature, ad-hoc, or Boatstack-update PR preview",
- "| `u` | Existing PR preview",
- "| `r` | One or more finite questions",
- "match shortcuts case-insensitively against the complete reply",
- "Bracketed forms such as `[o]`, embedded letters, and shortcuts from another state",
- "Continue accepting the full replies for compatibility",
- "do not advertise them in user-facing responses",
- "Never interpret `r` as plan approval, PR publication, identity, secret input, permission escalation, policy bypass, destructive recovery authorization",
- "`1a`, `1b`, and `1c`",
- "exactly one recommendation",
- "echo the question-to-answer mapping",
- "Otherwise ask again without choosing",
- "Reply `a` to approve.",
- "gh api user --jq .login",
- "Never infer the approver",
- "Codex and any host without verified HTML disclosure support use portable Markdown instead",
- "Never emit raw `` or `` tags in Codex",
- "Unknown hosts default to the portable Markdown form",
- } {
- if !strings.Contains(workflow, expected) {
- t.Fatalf("canonical workflow is missing response contract %q", expected)
- }
- }
- for _, expected := range []string{"irreversible", "operator-only", "fix-forward", "least-privilege"} {
- if !strings.Contains(strings.ToLower(workflow), strings.ToLower(expected)) {
- t.Fatalf("canonical workflow is missing safety boundary %q", expected)
- }
- }
- for _, path := range []string{".agents/skills/boatstack/SKILL.md", ".claude/skills/boatstack/SKILL.md", ".gemini/skills/boatstack/SKILL.md"} {
- adapter := string(bundle.Files[path])
- for _, expected := range []string{"User-facing response contract", "exactly one Next step", "a approves the pending plan", "o opens the currently previewed feature/ad-hoc/update PR", "u updates the currently previewed existing PR", "r accepts every recommendation", "Bracketed forms such as [o]", "Continue accepting approve, open PR, update PR, and open update PR for compatibility", "do not advertise them in user-facing responses", "Never interpret r as plan approval, PR publication, identity, secret input, permission escalation, policy bypass, destructive recovery authorization", "1a/1b/1c and 2a/2b/2c", "exactly one recommendation", "Echo the selected question-to-answer mapping", "filesystem username", "Never create or advertise a /pr-brief command", "state-scoped o to open or u to update", "boatstack-update"} {
- if !strings.Contains(adapter, expected) {
- t.Fatalf("%s is missing response-DX rule %q", path, expected)
- }
- }
- }
- questions := string(bundle.Files[".product-loop/templates/questions.md"])
- for _, expected := range []string{"`1a`, `1b`, `1c`", "(Recommended)", "use `r` to accept all displayed recommendations", "question-to-answer mapping is echoed", "never an agent-selected default"} {
- if !strings.Contains(questions, expected) {
- t.Fatalf("question template is missing shortcut rule %q", expected)
- }
- }
-}
-
-func TestGeneratedMarkdownIsIndependentOfBuilderLineEndings(t *testing.T) {
- want := GeneratedMarkdown("# Contract\n\nStable bytes.\n")
- got := GeneratedMarkdown("# Contract\r\n\r\nStable bytes.\r\n")
- if string(got) != string(want) {
- t.Fatalf("generated Markdown retained builder line endings:\nwant %q\n got %q", want, got)
- }
-}
-
-func TestExportPreservesOptionalChangelogPolicy(t *testing.T) {
- config := testConfig()
- config.Workflow.MaintainChangelog = true
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- project := string(bundle.Files[".product-loop/project.json"])
- if !strings.Contains(project, `"maintain_changelog": true`) {
- t.Fatalf("generated project configuration lost changelog policy: %s", project)
- }
-}
-
-func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) {
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
-
- workflow := string(bundle.Files[".product-loop/workflow.md"])
- artifacts := string(bundle.Files[".product-loop/artifacts.md"])
- for _, expected := range []string{"boatstack-next", "boatstack-run", "insight-capture", "insight-frontier", "root-cause", "auto-plan", "plan-gate", "build", "test-gate", "review-gate", "ship-gate", "boatstack-update", "retro"} {
- if !strings.Contains(workflow, expected) {
- t.Fatalf("canonical portable workflow is missing %q", expected)
- }
- if _, exists := bundle.Files[".cursor/commands/"+expected+".md"]; !exists {
- t.Fatalf("Cursor does not expose portable operation %q", expected)
- }
- }
- for _, spec := range claudeVisibleSkills {
- if _, exists := bundle.Files[".claude/skills/"+spec.Name+"/SKILL.md"]; !exists {
- t.Fatalf("Claude does not expose user operation %q", spec.Name)
- }
- if _, exists := bundle.Files[".gemini/skills/"+spec.Name+"/SKILL.md"]; !exists {
- t.Fatalf("Gemini does not expose user operation %q", spec.Name)
- }
- if _, exists := bundle.Files[".agents/skills/"+spec.Name+"/SKILL.md"]; !exists {
- t.Fatalf("Codex does not expose user operation %q", spec.Name)
- }
- }
- for _, expected := range []string{"source plan", "plan.md", "approval.md", "evidence", "gaps", "review", "pr.md"} {
- if !strings.Contains(strings.ToLower(artifacts), strings.ToLower(expected)) {
- t.Fatalf("repository artifact contract is missing %q", expected)
- }
- }
-
- hostSurfaces := map[string]string{
- "cursor": string(bundle.Files[".cursor/rules/boatstack.mdc"]),
- "claude": string(bundle.Files[".claude/skills/boatstack/SKILL.md"]),
- "gemini": string(bundle.Files[".gemini/skills/boatstack/SKILL.md"]),
- "codex": string(bundle.Files[".agents/skills/boatstack/SKILL.md"]),
- }
- for host, surface := range hostSurfaces {
- for _, expected := range []string{
- ".product-loop/project.json",
- ".product-loop/workflow.md",
- "Repository administration is outside Boatstack engagement",
- "Branch synchronization, status, switching, worktree maintenance",
- "must never activate Boatstack",
- "Use ordinary repository tools",
- } {
- if !strings.Contains(surface, expected) {
- t.Fatalf("%s adapter does not reference shared repository contract %q", host, expected)
- }
- }
- }
- for host, surface := range hostSurfaces {
- if strings.Contains(surface, "automatically use repair for ordinary failure or change language") {
- t.Fatalf("%s adapter retains broad free-form repair capture", host)
- }
- }
- for _, operation := range []string{"next", "boatstack-next", "run", "boatstack-run", "insight-capture", "insight-frontier", "root-cause", "auto-plan", "plan-gate", "build", "test-gate", "review-gate", "ship-gate", "boatstack-update", "retro"} {
- if !strings.Contains(hostSurfaces["codex"], operation) {
- t.Fatalf("Codex router does not declare portable operation %q", operation)
- }
- if !strings.Contains(hostSurfaces["claude"], operation) {
- t.Fatalf("Claude natural-language router does not declare portable operation %q", operation)
- }
- if !strings.Contains(hostSurfaces["gemini"], operation) {
- t.Fatalf("Gemini natural-language router does not declare portable operation %q", operation)
- }
- }
-
- for host, surface := range hostSurfaces {
- if !strings.Contains(surface, "Execution Mode Notice:") {
- t.Fatalf("%s router surface is missing the Execution Mode Notice", host)
- }
- }
-
- for _, path := range []string{
- ".cursor/commands/auto-plan.md",
- ".claude/skills/auto-plan/SKILL.md",
- ".gemini/skills/auto-plan/SKILL.md",
- } {
- content := string(bundle.Files[path])
- if !strings.Contains(content, "Execution Mode Notice:") {
- t.Fatalf("%s is missing the Execution Mode Notice", path)
- }
- }
-}
-
-func TestExportRefusesUserOwnedCollision(t *testing.T) {
- for _, relative := range []string{
- ".cursor/rules/boatstack.mdc",
- ".claude/skills/auto-plan/SKILL.md",
- ".gemini/skills/auto-plan/SKILL.md",
- } {
- t.Run(relative, func(t *testing.T) {
- repo := t.TempDir()
- path := filepath.Join(repo, filepath.FromSlash(relative))
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("user owned\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- config := testConfig()
- raw, _ := MarshalJSON(config)
- bundle, err := BuildExportBundle("config.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- if err := WriteExport(repo, bundle.Files); err == nil || !strings.Contains(err.Error(), "user-owned") || !strings.Contains(err.Error(), relative) {
- t.Fatalf("expected named user-owned collision, got %v", err)
- }
- value, _ := os.ReadFile(path)
- if string(value) != "user owned\n" {
- t.Fatal("collision handling modified the user-owned file")
- }
- })
- }
-}
-
-func TestExportAdoptsLegacyGeneratedFiles(t *testing.T) {
- repo := t.TempDir()
- path := filepath.Join(repo, ".cursor", "rules", "boatstack.mdc")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- legacy := "\n"
- if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil {
- t.Fatal(err)
- }
- config := testConfig()
- raw, _ := MarshalJSON(config)
- bundle, err := BuildExportBundle("config.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatalf("legacy generated file should be safely replaceable: %v", err)
- }
-}
-
-func TestExportRemovesOnlyUnmodifiedStaleGeneratedPath(t *testing.T) {
- repo := t.TempDir()
- config := testConfig()
- raw, _ := MarshalJSON(config)
- bundle, _ := BuildExportBundle("config.json", config, raw, "boatstack")
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- stale := ".cursor/commands/retro.md"
- delete(bundle.Files, stale)
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(stale))); !os.IsNotExist(err) {
- t.Fatal("unmodified stale generated path was not removed")
- }
-}
-
-func TestRepairMayRemoveOnlyExplicitDriftedGeneratedPath(t *testing.T) {
- repo := t.TempDir()
- config := testConfig()
- raw, _ := MarshalJSON(config)
- bundle, _ := BuildExportBundle("config.json", config, raw, "boatstack")
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- stale := ".cursor/commands/retro.md"
- delete(bundle.Files, stale)
- path := filepath.Join(repo, filepath.FromSlash(stale))
- if err := os.WriteFile(path, []byte("locally drifted generated content\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := WriteExport(repo, bundle.Files); err == nil {
- t.Fatal("ordinary export removed drifted generated state")
- }
- if err := WriteExportForRepair(repo, bundle.Files, map[string]bool{stale: true}); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(path); !os.IsNotExist(err) {
- t.Fatal("fingerprinted repair did not remove its exact stale generated path")
- }
-}
diff --git a/boatstack/flow_coding.go b/boatstack/flow_coding.go
deleted file mode 100644
index 1ba8f4b..0000000
--- a/boatstack/flow_coding.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package boatstack
-
-import (
- "os"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// RecordCodingEffort appends a best-effort coding-effort signal to the shadow
-// log. Like the flow-transition recorder it never returns an error, never panics
-// into a caller, and writes nothing when disabled — it honors the same
-// BOATSTACK_FLOW_TRACE kill switch. Coding effort is telemetry only: it is never
-// gated, never optimized, and never added to J_flow. It is stored in its own log
-// so J_coding cannot be conflated with flow navigation cost.
-//
-// units is the coding effort to record (non-positive counts as one unit); note is
-// an optional free-text marker for what the effort was.
-func RecordCodingEffort(repo string, units int, note string) {
- // Telemetry must never take down a command.
- defer func() { _ = recover() }()
-
- if os.Getenv(flowTraceKillSwitch) == "0" {
- return
- }
- directory, err := flowLogDirectory(repo)
- if err != nil {
- return
- }
- _ = deliverycontrol.AppendCodingSignal(directory, deliverycontrol.CodingSignal{Units: units, Note: note})
-}
diff --git a/boatstack/flow_coding_test.go b/boatstack/flow_coding_test.go
deleted file mode 100644
index e886c5c..0000000
--- a/boatstack/flow_coding_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package boatstack
-
-import (
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-func TestRecordCodingEffortRoundTrips(t *testing.T) {
- repo := prTestRepo(t)
-
- RecordCodingEffort(repo, 2, "implementation_repair")
- RecordCodingEffort(repo, 0, "amend") // bare marker -> one unit
-
- dir, err := flowLogDirectory(repo)
- if err != nil {
- t.Fatal(err)
- }
- signals, err := deliverycontrol.ReadCodingSignals(dir)
- if err != nil {
- t.Fatal(err)
- }
- if got := deliverycontrol.TallyCoding(signals); got.JCoding != 3 || got.Signals != 2 {
- t.Errorf("recorded coding effort = %+v, want J_coding 3 over 2 signals", got)
- }
-}
-
-func TestRecordCodingEffortHonorsKillSwitch(t *testing.T) {
- t.Setenv(flowTraceKillSwitch, "0")
- repo := prTestRepo(t)
-
- RecordCodingEffort(repo, 5, "should-not-write")
-
- dir, err := flowLogDirectory(repo)
- if err != nil {
- t.Fatal(err)
- }
- signals, err := deliverycontrol.ReadCodingSignals(dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(signals) != 0 {
- t.Errorf("kill switch must suppress coding telemetry; got %+v", signals)
- }
-}
diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go
deleted file mode 100644
index 4189cdd..0000000
--- a/boatstack/flow_control.go
+++ /dev/null
@@ -1,899 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "path/filepath"
- "runtime"
- "strings"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// flowGoal is the accepted end state of a delivery flow — the sink the oracle
-// scores paths toward.
-const flowGoal = deliverycontrol.StatePublished
-
-// FlowCheck runs the static conformance + liveness gate over the owned delivery
-// model. It reads no repository state — it validates the single declaration and
-// its graph — so the CLI gate is deterministic and side-effect free.
-func FlowCheck() deliverycontrol.CheckResult {
- return deliverycontrol.Check()
-}
-
-// FormatFlowCheck renders a FlowCheck result as human-facing lines. A sound model
-// reports PASS; drift lists the registry issues and any deadlocked or
-// goal-unreachable states so the fault is actionable, not just a red exit code.
-func FormatFlowCheck(result deliverycontrol.CheckResult) string {
- var b strings.Builder
- if result.OK {
- fmt.Fprintf(&b, "PASS: delivery flow model is conformant and live (goal %s)\n", result.Liveness.Goal)
- } else {
- fmt.Fprintf(&b, "BLOCKED: delivery flow model failed the static check (goal %s)\n", result.Liveness.Goal)
- }
- for _, issue := range result.RegistryIssues {
- fmt.Fprintf(&b, "REGISTRY_ISSUE=%s\n", issue)
- }
- for _, s := range result.Liveness.Deadlocks {
- fmt.Fprintf(&b, "DEADLOCK=%s\n", s)
- }
- for _, s := range result.Liveness.GoalUnreachable {
- fmt.Fprintf(&b, "GOAL_UNREACHABLE=%s\n", s)
- }
- fmt.Fprintf(&b, "REACHABLE=%d LIVE=%t\n", len(result.Liveness.Reachable), result.Liveness.Live)
- return b.String()
-}
-
-// flowStateFromStage maps a read-only NextStatus.ObservedStage to a
-// delivery-flow StateID. It resolves ONLY the concrete slice-lifecycle stages,
-// where the position is unambiguous; every planning, ambiguous, or invalid stage
-// returns false so the oracle never scores a guessed position. The oracle stays
-// delivery-only; pre-activation stages are covered instead by prescribePlanning,
-// which names the exact runnable command without ever claiming a flow state.
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-func flowStateFromStage(stage string) (deliverycontrol.StateID, bool) {
- switch stage {
- case "BUILD":
- return deliverycontrol.StateBuild, true
- case "AMENDMENT_REQUIRED":
- return deliverycontrol.StateAmendmentRequired, true
- case "AMENDMENT_DRAFTED":
- return deliverycontrol.StateAmendmentDrafted, true
- case "AMENDMENT_APPROVED":
- return deliverycontrol.StateAmendmentApproved, true
- case "PLAN_INVALID":
- return deliverycontrol.StatePlanInvalid, true
- case "TEST_PASSED":
- return deliverycontrol.StateTestPassed, true
- case "REVIEW_PASSED", "PR_PREVIEW":
- return deliverycontrol.StateReviewPassed, true
- case "PUBLISHED", "FEATURE_COMPLETE":
- return deliverycontrol.StatePublished, true
- default:
- return "", false
- }
-}
-
-// CurrentFlowState resolves the delivery-flow state of the addressable slice via
-// the read-only ResolveNext projection. The boolean is false whenever the
-// position cannot be trusted — an error, a non-VERIFIED status (blocked,
-// ambiguous, uninitialized), or a stage that is not a concrete slice-lifecycle
-// state. Callers must treat false as "unknown" and never fabricate a position.
-func CurrentFlowState(repo, feature string) (deliverycontrol.StateID, bool) {
- status, err := ResolveNext(repo, feature)
- if err != nil {
- return "", false
- }
- if status.VerificationStatus != "VERIFIED" {
- return "", false
- }
- return flowStateFromStage(status.ObservedStage)
-}
-
-// Non-registry prescription markers. These name pre-activation and recovery
-// moves the delivery oracle deliberately does not model (deliverycontrol shadows
-// the DELIVERY machine only). They are never legal registry transitions, never
-// allowlisted for auto-drive, and exist so a prescription's provenance is
-// self-describing in JSON and telemetry.
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-const (
- MarkerPlanningInit = deliverycontrol.TransitionID("planning.init")
- MarkerPlanningBootstrap = deliverycontrol.TransitionID("planning.bootstrap")
- MarkerPlanningCheckPlan = deliverycontrol.TransitionID("planning.check_plan")
- MarkerPlanningActivate = deliverycontrol.TransitionID("planning.activate")
- MarkerPlanningWorkspace = deliverycontrol.TransitionID("planning.workspace_cut")
- MarkerPlanningWrite = deliverycontrol.TransitionID("planning.planning_write")
- MarkerPlanningApproval = deliverycontrol.TransitionID("planning.record_approval")
- MarkerRecoveryDoctor = deliverycontrol.TransitionID("recovery.doctor")
- MarkerRecoveryDiscard = deliverycontrol.TransitionID("recovery.discard_delivery")
- MarkerRecoveryRepair = deliverycontrol.TransitionID("recovery.repair_state")
- // Post-publish markers (merged terminal only). The delivery machine
- // deliberately models nothing past PUBLISHED — merging is not a Boatstack
- // verb and FEATURE_COMPLETE is entered by observation — so the post-publish
- // steps are markers like the planning ones: self-describing provenance,
- // never legal registry transitions, never on the auto-drive allowlist.
- // control-law: merged-terminal-prescribes-merge-never-executes-it
- MarkerPublishedWatch = deliverycontrol.TransitionID("published.watch_checks")
- MarkerPublishedMerge = deliverycontrol.TransitionID("published.merge")
- // MarkerPublishedAttach names the owed-attachment retry of a published
- // PR's visual-evidence comment. Unlike the merged-terminal markers above
- // it fires under BOTH terminals: attaching evidence completes the
- // publication itself, it is not merge pursuit.
- MarkerPublishedAttach = deliverycontrol.TransitionID("published.attach_evidence")
-)
-
-// NextActor names who performs the prescribed next step. The operator owns a
-// step only when it owes operator knowledge or authority — an approval, a
-// publish decision, a feature choice, a plan path, a correction fact. The
-// agent owns every other step, including steps whose owed inputs are evidence
-// the agent produces by doing the work (test runs, the review protocol). A
-// working response may end only on an operator-owned step or a terminal
-// state; that boundary is the operator frontier.
-// control-law: turn-ends-only-at-the-operator-frontier
-type NextActor string
-
-const (
- // NextActorAgent — the coding agent performs this step now. A working
- // response never ends on an agent-owned step; the read-only status view
- // renders it as a one-key delegation instead of executing it.
- NextActorAgent NextActor = "agent"
- // NextActorOperator — the step owes operator knowledge or authority; the
- // response may end here.
- NextActorOperator NextActor = "operator"
- // NextActorNone — terminal; nobody owes an action.
- NextActorNone NextActor = "none"
-)
-
-// operatorOwedFlags are the prescribed-command inputs that carry operator
-// knowledge or authority rather than work-derivable evidence. A prescription
-// owing any of these belongs to the operator. The evidence flags (--status,
-// --evidence, --reviewer-identity, --review-method) are deliberately absent:
-// the agent obtains those by doing the work — never by fabrication — so owing
-// them does not move the step across the frontier.
-// control-law: turn-ends-only-at-the-operator-frontier
-var operatorOwedFlags = map[string]bool{
- "--plan": true, // which source plan: product knowledge
- "--feature": true, // which delivery: the operator names the slug
- "--mutation": true, // which receipt to reverse: an operator decision
- "--preview-fingerprint": true, // publish authority is human-confirmed
- "--message": true, // correction facts are human knowledge
- "--source-stage": true,
- "--classification": true,
- "--mechanism": true,
-}
-
-// classifyNextActor types the next step by who must act. Fail-closed: anything
-// it cannot place returns operator, which preserves prescribe-and-stop — the
-// worst misclassification is today's behavior, never a runaway agent.
-// control-law: turn-ends-only-at-the-operator-frontier
-func classifyNextActor(status NextStatus, next FlowNext) NextActor {
- switch {
- case status.ObservedStage == "FEATURE_COMPLETE",
- status.ObservedStage == "PUBLISHED" && status.Lifecycle == "PUBLISHED_MERGED":
- return NextActorNone
- case status.ObservedStage == "PUBLISHED":
- // Both the current visual_pending state and the legacy manual_required
- // state are work-derivable external-host retries.
- // control-law: turn-ends-only-at-the-operator-frontier
- if status.Lifecycle == "PUBLISHED_OPEN" && status.GoalEscape == "" && (status.VisualPublication == "visual_pending" || status.VisualPublication == "manual_required") {
- return NextActorAgent
- }
- // Under the default published terminal, reviewing the open pull
- // request is the operator's act — unchanged. Under the merged
- // terminal, the frontier extends: the phases whose next step is
- // work-derivable (watch running checks, fix failing checks from the
- // check logs, run the prescribed merge of an eligible PR) are the
- // agent's; every phase owing operator authority or knowledge — a
- // review approval, a changes-requested verdict, a closed PR, an
- // unknown position — stays the operator's. Fail-closed: the zero
- // Terminal behaves as published.
- // control-law: turn-ends-only-at-the-operator-frontier
- // A fired goal escape demotes unconditionally: the pursuit contract
- // ended, so no phase can hand the step back to the agent.
- // control-law: goal-escape-demotes-to-operator-and-stops
- if next.Terminal == TerminalMerged && status.GoalEscape == "" {
- switch PRPhase(status.PRPhase) {
- case PRPhaseChecksPending, PRPhaseChecksFailing, PRPhaseMergeEligible:
- return NextActorAgent
- }
- }
- return NextActorOperator
- case next.Prescribed == nil:
- // Ambiguity and unprescribed blocks resolve only by operator choice.
- return NextActorOperator
- case next.Prescribed.Transition == PublishTransition:
- // Opening or updating a PR is operator-confirmed (`o`/`u`), regardless
- // of which flags happen to be owed.
- return NextActorOperator
- }
- for _, flag := range next.Prescribed.RequiresHumanInput {
- if operatorOwedFlags[flag] {
- return NextActorOperator
- }
- }
- return NextActorAgent
-}
-
-// FlowNext is the advisory answer for `flow next`: the current delivery-flow
-// state, the real recommended operation (from ResolveNext — the authoritative
-// next-move table), and the oracle's lowest-cost next control plus the remaining
-// cost to the goal. It is purely advisory; it changes no command, gate, or
-// authority. Resolved is false when the oracle cannot place the flow, in which
-// case only the real recommendation is meaningful.
-type FlowNext struct {
- Resolved bool `json:"resolved"`
- State deliverycontrol.StateID `json:"state,omitempty"`
- Goal deliverycontrol.StateID `json:"goal"`
- // Terminal is the standing goal of this delivery ("published" or
- // "merged"), resolved state-then-config-then-default. Goal above remains
- // the ORACLE's sink (always StatePublished — the delivery machine has no
- // modeled transition past it); Terminal is the operator-facing setpoint
- // that decides whether anything is still owed after publish. In this
- // slice it is surfaced only; post-publish prescriptions follow.
- // control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
- Terminal DeliveryTerminal `json:"terminal"`
- RecommendedOp string `json:"recommended_operation"`
- OracleNext deliverycontrol.TransitionID `json:"oracle_next_transition,omitempty"`
- RemainingCost int `json:"remaining_flow_cost"`
- Reason string `json:"reason"`
- // Prescribed is the exact runnable command for the next move. When the flow
- // position resolves it is the oracle's lowest-cost transition; when it does
- // not, it is the pre-activation prescription for the observed stage (marked by
- // a planning./recovery. Transition). It is non-nil ONLY when the command can
- // be assembled faithfully; otherwise nothing is prescribed rather than a
- // guessed command.
- Prescribed *PrescribedCommand `json:"prescribed,omitempty"`
- // FollowUp names the step after the prescribed pre-activation command, set
- // only by the planning prescription layer (e.g. record approval after
- // check-plan; re-author via planning-write after repair-state). Empty for
- // oracle moves.
- FollowUp string `json:"follow_up,omitempty"`
- // SubAction is the read-only next coding sub-action from the plan's task DAG,
- // surfaced only while the active slice is in BUILD (where "build" is otherwise
- // opaque). It is a pointer into the slice's dependency-ordered tasks; it is nil
- // when the slice is not building or the task graph cannot be read. It carries no
- // completion state and prescribes no command — coding work is never a modeled
- // transition, only an ordered pointer.
- SubAction *FlowTask `json:"sub_action,omitempty"`
- // Alternatives are the other admissible next commands from this position —
- // the computed solution set minus the single Prescribed primary. They let a
- // caller PICK a legal move instead of deriving one from the law's prose.
- // Advisory, never a second primary: the rendering keeps exactly one Run line.
- // control-law: solution-set-derives-from-guard-declarations
- Alternatives []PrescribedCommand `json:"alternatives,omitempty"`
- // Actor names who performs the next step: "agent" when the step is the
- // coding agent's to do now, "operator" when it owes operator knowledge or
- // authority (the response may end there — the operator frontier), "none"
- // when the flow is terminal.
- // control-law: turn-ends-only-at-the-operator-frontier
- Actor NextActor `json:"next_actor"`
-}
-
-// PrescribedCommand is the exact next command that makes the oracle's lowest-cost
-// move. Args carries only auto-derivable flags (repo/feature/slice/gate/preview
-// path/action). RequiresHumanInput names flags or bounded content that must be
-// supplied by a human/CI/authoring agent and must NEVER be fabricated (evidence,
-// gate status, the planning document, the human-confirmed preview fingerprint,
-// reviewer identity); those inputs are deliberately absent from Args.
-// AutoDerivable is true exactly when RequiresHumanInput is empty — the only
-// commands the opt-in execute driver may run. Transition is the registry
-// TransitionID of an oracle move, or a planning./recovery.-prefixed marker for a
-// pre-activation prescription outside the delivery model; markers never pass the
-// auto-drive allowlist, so a marked prescription is always prescribe-and-stop.
-type PrescribedCommand struct {
- Verb string `json:"verb"`
- Args []string `json:"args,omitempty"`
- RequiresHumanInput []string `json:"requires_human_input,omitempty"`
- AutoDerivable bool `json:"auto_derivable"`
- Transition deliverycontrol.TransitionID `json:"transition"`
- // Program names the executable when the prescribed step is honestly NOT a
- // boatstack-helper verb (today: `gh`, for the operator-frontier merge).
- // Empty means boatstack-helper, exactly as before. A foreign-program
- // command is rendering-only by construction: canAutoDrive refuses it
- // categorically and executePrescribed has no executor for it, so the
- // execute driver can never run a program that is not the helper.
- // control-law: merged-terminal-prescribes-merge-never-executes-it
- Program string `json:"program,omitempty"`
-}
-
-const planningMarkdownInput = "stdin:markdown"
-
-func posixPlanningWord(value string) string {
- if value != "" {
- safe := true
- for _, char := range []byte(value) {
- if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("_@%+=:,./-", rune(char))) {
- safe = false
- break
- }
- }
- if safe {
- return value
- }
- }
- return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
-}
-
-func powerShellCommandWord(value string) string {
- if value == "" {
- return value
- }
- if posixPlanningWord(value) == value {
- return value
- }
- return powerShellPlanningWord(value)
-}
-
-// CommandLine renders the auto-derivable part of the prescribed command as a
-// runnable string. Human-required inputs use explicit placeholders;
-// planning Markdown is placed inside the same literal envelope the hook admits.
-// The rendering is never fabricated or runnable as-is while input is still owed.
-func (p PrescribedCommand) commandLineForOS(goos string) string {
- literalPlanningInput := false
- for _, input := range p.RequiresHumanInput {
- if input == planningMarkdownInput {
- literalPlanningInput = true
- break
- }
- }
- program := p.Program
- if program == "" {
- if literalPlanningInput {
- program = projectLocalLauncherCommand()
- } else {
- program = "boatstack-helper"
- }
- }
- parts := append([]string{program, p.Verb}, p.Args...)
- for _, flag := range p.RequiresHumanInput {
- if flag == planningMarkdownInput {
- literalPlanningInput = true
- continue
- }
- parts = append(parts, flag, "")
- }
- if goos == "windows" {
- if literalPlanningInput {
- envelope, err := powerShellPlanningEnvelopeFor(parts, []byte("\n"))
- if err == nil {
- return strings.TrimSuffix(envelope, "\n")
- }
- // A single quote in an argv word cannot cross the deliberately small
- // PowerShell grammar. Preserve a valid Git Bash prescription instead
- // of manufacturing a hybrid command that neither shell owns.
- return strings.TrimSuffix(posixPlanningEnvelopeFor(parts, []byte("\n")), "\n")
- }
- for index := range parts {
- parts[index] = powerShellCommandWord(parts[index])
- }
- line := strings.Join(parts, " ")
- if filepath.IsAbs(program) {
- return "& " + line
- }
- return line
- }
- for index := range parts {
- if parts[index] != "" {
- parts[index] = posixPlanningWord(parts[index])
- }
- }
- line := strings.Join(parts, " ")
- if literalPlanningInput {
- return line + " <<'BOATSTACK_PLAN_EOF'\n\nBOATSTACK_PLAN_EOF"
- }
- return line
-}
-
-func (p PrescribedCommand) CommandLine() string {
- return p.commandLineForOS(runtime.GOOS)
-}
-
-// prescribeCommand assembles the runnable command for a forward delivery
-// transition. It returns (nil, false) for any transition it cannot assemble
-// faithfully — so the caller emits nothing rather than a guessed command. The
-// emitted verb is always the registry CLIVerb of the transition (single source),
-// and human-owed inputs are listed, never filled.
-var deriveAutonomousPRPublish = func(repo, feature, previewPath string) (string, string, string, bool) {
- preview, _, err := CheckPRPreview(repo, previewPath)
- if err != nil {
- return "", "", "", false
- }
- action, _, err := RecommendedPRAction(repo)
- if err != nil || (action != "open" && action != "update") {
- return "", "", "", false
- }
- planPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.md")
- check, err := CheckPlan(planPath)
- if err != nil {
- return "", "", "", false
- }
- autonomyPath := filepath.Join(filepath.Dir(planPath), "autonomy.md")
- receipt, err := CheckAutonomyReceipt(autonomyPath, check, repo, RunTargetPR, action)
- if err != nil || receipt.PRAction != action {
- return "", "", "", false
- }
- return action, preview.Fingerprint, autonomyPath, true
-}
-
-func prescribeCommand(repo, feature string, status NextStatus, transition deliverycontrol.TransitionID) (*PrescribedCommand, bool) {
- desc, ok := deliverycontrol.Transition(transition)
- if !ok || desc.CLIVerb == "" {
- return nil, false
- }
- cmd := &PrescribedCommand{Verb: desc.CLIVerb, Transition: transition}
- var repoArgs []string
- if repo != "" && repo != "." {
- repoArgs = []string{"--repo", repo}
- }
- switch transition {
- case deliverycontrol.TransitionID("delivery.amend_write"), deliverycontrol.TransitionID("delivery.invalid_plan_rewrite"):
- cmd.Verb = "flow"
- cmd.Args = append([]string{"bootstrap"}, repoArgs...)
- cmd.Args = append(cmd.Args, "--feature", feature)
- cmd.RequiresHumanInput = []string{"--source-plan", "--artifact", "--shell", planningMarkdownInput}
- case deliverycontrol.TransitionID("delivery.amend_approve"):
- featureDir := planningFeatureDir(repo, feature)
- cmd.Args = []string{
- "--plan", filepath.Join(featureDir, "plan.md"),
- "--expected-lifecycle-sha256", status.LifecycleSHA256,
- "--expected-plan-lock-sha256", status.PlanLockSHA256,
- "--expected-observation", status.ObservationID,
- }
- cmd.RequiresHumanInput = []string{"--approved-by", "--approved-at", "--fingerprint"}
- case deliverycontrol.TransitionID("delivery.amend_activate"):
- featureDir := planningFeatureDir(repo, feature)
- cmd = buildActivatePlan(featureDir, "AMENDMENT_APPROVED")
- cmd.Transition = transition
- case deliverycontrol.TransitionID("delivery.record_gate_test"):
- cmd.Args = append(repoArgs, "--feature", feature, "--slice", status.ActiveSlice, "--gate", "test")
- cmd.RequiresHumanInput = []string{"--status", "--evidence"}
- case deliverycontrol.TransitionID("delivery.record_gate_review"):
- cmd.Args = append(repoArgs, "--feature", feature, "--slice", status.ActiveSlice, "--gate", "review")
- cmd.RequiresHumanInput = []string{"--status", "--evidence", "--reviewer-identity", "--review-method"}
- case PublishTransition:
- preview := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "features", feature, "pr.md")
- if action, fingerprint, autonomyPath, authorized := deriveAutonomousPRPublish(repo, feature, preview); authorized {
- cmd.Args = append(repoArgs, "--preview", preview, "--action", action, "--preview-fingerprint", fingerprint, "--autonomy", autonomyPath)
- } else {
- cmd.Args = append(repoArgs, "--preview", preview, "--action", "open")
- cmd.RequiresHumanInput = []string{"--preview-fingerprint"}
- }
- case deliverycontrol.TransitionID("delivery.record_change"):
- if feature == "" {
- return nil, false
- }
- // Rework: the correction facts (what changed, where it was observed, and
- // its classification) are human knowledge; owe them, never fabricate them.
- cmd.Args = append(repoArgs, "--feature", feature)
- if status.ActiveSlice != "" {
- cmd.Args = append(cmd.Args, "--slice", status.ActiveSlice)
- }
- cmd.RequiresHumanInput = []string{"--message", "--source-stage", "--classification", "--mechanism"}
- case deliverycontrol.TransitionID("delivery.undo"):
- // The mutation id names WHICH receipt to reverse — a human decision.
- cmd.Args = repoArgs
- cmd.RequiresHumanInput = []string{"--mutation"}
- case deliverycontrol.TransitionID("delivery.discard_delivery"):
- if feature == "" {
- return nil, false
- }
- cmd.Args = append(repoArgs, "--feature", feature)
- default:
- // Recovery/observe transitions outside the set above are not prescribed as
- // a forward move; emit nothing rather than a command whose arguments we
- // cannot derive.
- return nil, false
- }
- cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0
- return cmd, true
-}
-
-// prescribePlanning assembles the exact runnable command for a stage the
-// delivery oracle deliberately does not model: the pre-activation planning
-// stages and the blocked recovery stages. It closes the prescriptive loop —
-// every reachable pre-activation stage names at least one concrete command —
-// without adding planning states to deliverycontrol, whose declared scope is
-// the DELIVERY machine only. It returns (nil, "") exactly for the documented
-// exceptions: AMBIGUOUS (choosing a feature is a human act, and the candidates
-// already surface via Reason/BlockingAmbiguity) and unknown stages (never
-// guess). AutoDerivable here is a rendering fact ("all arguments follow from
-// state"), not an execution grant: markers are off the auto-drive allowlist
-// and have no executor, so the driver always prescribes-and-stops on them.
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-// planningFeatureDir is the single joined form of a feature's planning
-// directory used by the prescription layer and the solution-set enumerator.
-func planningFeatureDir(repo, feature string) string {
- return WorkspaceFor(repo).FeatureDir(feature)
-}
-
-func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, string) {
- var repoArgs []string
- if repo != "" && repo != "." {
- repoArgs = []string{"--repo", repo}
- }
- featureDir := planningFeatureDir(repo, status.Feature)
- finish := func(cmd *PrescribedCommand, followUp string) (*PrescribedCommand, string) {
- cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0
- return cmd, followUp
- }
- switch status.ObservedStage {
- case "NOT_INITIALIZED":
- return finish(&PrescribedCommand{
- Verb: "init", Args: repoArgs, Transition: MarkerPlanningInit,
- }, "")
- case "NOT_STARTED":
- // The feature, source plan, artifact, target shell, and Markdown are supplied
- // by the explicit host invocation. The workspace-bound bootstrap oracle turns
- // them into the only executable planning envelope.
- return finish(&PrescribedCommand{
- Verb: "flow", Args: append([]string{"bootstrap"}, repoArgs...),
- RequiresHumanInput: []string{"--feature", "--source-plan", "--artifact", "--shell", planningMarkdownInput},
- Transition: MarkerPlanningBootstrap,
- }, "Execute only the returned planning_envelope, then resolve flow bootstrap again for each artifact. After workspace-cut, discard every earlier prescription and resolve from destination_repository.")
- case "DRAFT_PLAN":
- if status.NextOperation == "workspace-cut" {
- return finish(buildWorkspaceCut(repoArgs, status.Feature),
- "Continue from destination_repository, then check and approve the same plan fingerprint.")
- }
- return finish(&PrescribedCommand{
- Verb: "check-plan",
- Args: []string{"--plan", filepath.Join(featureDir, "plan.md")},
- Transition: MarkerPlanningCheckPlan,
- }, "After the check passes, present the plan for approval and record it with `record-approval` using the exact PLAN_FINGERPRINT it printed.")
- case "APPROVED", "POLICY_READY":
- // ResolveNext already ordered the move: a fresh workspace cut when one is
- // needed, otherwise activation. "build" is an operation name, not a verb;
- // activate-plan is the build operation's first concrete command.
- if status.NextOperation == "workspace-cut" {
- return finish(buildWorkspaceCut(repoArgs, status.Feature),
- "Then activate the plan from the fresh workspace with `activate-plan`.")
- }
- return finish(buildActivatePlan(featureDir, status.ObservedStage), "")
- case "INVALID_STATE":
- switch status.NextOperation {
- case "doctor":
- return finish(&PrescribedCommand{
- Verb: "doctor", Args: repoArgs, Transition: MarkerRecoveryDoctor,
- }, "")
- case "discard-delivery":
- cmd := &PrescribedCommand{Verb: "discard-delivery", Args: repoArgs, Transition: MarkerRecoveryDiscard}
- if len(status.BlockingAmbiguity) == 1 {
- cmd.Args = append(cmd.Args, "--feature", status.BlockingAmbiguity[0])
- } else {
- cmd.RequiresHumanInput = []string{"--feature"}
- }
- return finish(cmd, "")
- case "repair-state":
- // ResolveNext never routes here today; the safety finding does. Keep the
- // case so any carrier of the repair-state operation gets the full loop:
- // quarantine, then re-author through the owned channel.
- cmd := &PrescribedCommand{Verb: "repair-state", Args: repoArgs, Transition: MarkerRecoveryRepair}
- slug := status.Feature
- if slug == "" && len(status.BlockingAmbiguity) == 1 {
- slug = status.BlockingAmbiguity[0]
- }
- if slug != "" {
- cmd.Args = append(cmd.Args, "--feature", slug)
- } else {
- slug = ""
- }
- return finish(cmd, fmt.Sprintf("After repair, resolve `flow bootstrap --feature %s` with the current source plan, artifact, shell, and complete Markdown; execute only its returned planning_envelope.", slug))
- }
- return nil, ""
- default:
- // AMBIGUOUS and anything unrecognized: no prescription, never a guess.
- return nil, ""
- }
-}
-
-// prescribeVisualAttach closes the owed-attachment gap of a published-open
-// PR so the flow never goes dark on visual_pending or manual_required. It
-// fires under BOTH terminals — the attachment completes publication, it is
-// not merge pursuit. Both current and legacy owed states prescribe the same
-// externally hosted attach-evidence retry. A fired goal escape prescribes
-// nothing, exactly like the post-publish layer.
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-func prescribeVisualAttach(repo string, status NextStatus) (*PrescribedCommand, string) {
- if status.ObservedStage != "PUBLISHED" || status.Lifecycle != "PUBLISHED_OPEN" || status.Feature == "" || status.GoalEscape != "" {
- return nil, ""
- }
- var repoArgs []string
- if repo != "" && repo != "." {
- repoArgs = []string{"--repo", repo}
- }
- switch status.VisualPublication {
- case "visual_pending", "manual_required":
- cmd := &PrescribedCommand{
- Verb: "attach-evidence", Args: append(repoArgs, "--feature", status.Feature),
- AutoDerivable: true, Transition: MarkerPublishedAttach,
- }
- return cmd, "The PR is open; only its externally hosted Boatstack visual-evidence comment is owed. Retry the same evidence fingerprint and comment after host access recovers."
- }
- return nil, ""
-}
-
-// prescribePostPublish closes the prescriptive loop past publish, but ONLY
-// under the merged terminal: with the published default this function returns
-// nothing and post-publish behavior is exactly what it always was. The
-// delivery oracle is at its sink at PUBLISHED, so these prescriptions derive
-// from the live PR observation instead of the registry graph:
-//
-// checks running -> flow watch (agent; read-only wait, exits on change)
-// checks failing -> record-change --source-stage ci (agent; the failure
-// facts are work-derivable from the failing check logs,
-// so this branch's owed flags do not cross the frontier)
-// merge eligible -> gh pr merge --squash (agent, PRESCRIBE-ONLY:
-// Program!="" is categorically undrivable and the agent
-// runs gh under its own authority, never Boatstack's)
-// everything else -> nothing; approvals, changes-requested verdicts, closed
-// PRs, and unknown positions are the operator's.
-//
-// FEATURE_COMPLETE and a merged lifecycle prescribe nothing: the goal is met.
-// control-law: merged-terminal-prescribes-merge-never-executes-it
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-func prescribePostPublish(repo string, status NextStatus, terminal DeliveryTerminal) (*PrescribedCommand, string) {
- if terminal != TerminalMerged || status.ObservedStage != "PUBLISHED" || status.Lifecycle == "PUBLISHED_MERGED" {
- return nil, ""
- }
- // A fired escape prescribes nothing: demote-and-stop, never
- // demote-and-suggest. control-law: goal-escape-demotes-to-operator-and-stops
- if status.GoalEscape != "" {
- return nil, ""
- }
- var repoArgs []string
- if repo != "" && repo != "." {
- repoArgs = []string{"--repo", repo}
- }
- switch PRPhase(status.PRPhase) {
- case PRPhaseChecksPending:
- cmd := &PrescribedCommand{
- Verb: "flow",
- Args: append([]string{"watch"}, repoArgs...),
- Transition: MarkerPublishedWatch,
- }
- cmd.AutoDerivable = true
- return cmd, "When the watch exits, resolve the flow again and continue from the fresh state."
- case PRPhaseChecksFailing:
- if status.Feature == "" {
- return nil, ""
- }
- desc, ok := deliverycontrol.Transition(deliverycontrol.TransitionID("delivery.record_change"))
- if !ok || desc.CLIVerb == "" {
- return nil, ""
- }
- // The registry transition IS the fix path — no new machinery. The
- // source stage is derivable (this observation is the CI failure); the
- // message and classification are owed, to be derived from the failing
- // check logs, never fabricated.
- cmd := &PrescribedCommand{Verb: desc.CLIVerb, Transition: deliverycontrol.TransitionID("delivery.record_change")}
- cmd.Args = append(append([]string{}, repoArgs...), "--feature", status.Feature)
- if status.ActiveSlice != "" {
- cmd.Args = append(cmd.Args, "--slice", status.ActiveSlice)
- }
- cmd.Args = append(cmd.Args, "--source-stage", "ci")
- cmd.RequiresHumanInput = []string{"--message", "--classification", "--mechanism"}
- followUp := "Read the failing check logs"
- if len(status.PRFailingChecks) > 0 {
- followUp += " (" + strings.Join(status.PRFailingChecks, ", ") + ")"
- }
- followUp += " to derive the exact message, classification, and changed mechanism; after the correction re-passes its gates, republish with publish-pr --action update."
- return cmd, followUp
- case PRPhaseMergeEligible:
- if strings.TrimSpace(status.PRURL) == "" {
- return nil, ""
- }
- cmd := &PrescribedCommand{
- Program: "gh",
- Verb: "pr",
- Args: []string{"merge", status.PRURL, "--squash"},
- Transition: MarkerPublishedMerge,
- }
- cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0
- return cmd, "Run it exactly as rendered — this merge is prescribed only from the live merge-eligible observation, never with --admin, and never for a different pull request."
- default:
- return nil, ""
- }
-}
-
-// buildWorkspaceCut and buildActivatePlan are the single assembly points for
-// their commands, shared by prescribePlanning (the primary) and the solution-set
-// enumerator (the alternatives) so the two can never drift apart.
-// control-law: solution-set-derives-from-guard-declarations
-func buildWorkspaceCut(repoArgs []string, feature string) *PrescribedCommand {
- return &PrescribedCommand{
- Verb: "workspace-cut",
- Args: append(append([]string{}, repoArgs...), "--feature", feature),
- Transition: MarkerPlanningWorkspace,
- }
-}
-
-func buildActivatePlan(featureDir, stage string) *PrescribedCommand {
- args := []string{
- "--plan", filepath.Join(featureDir, "plan.md"),
- "--out-dir", filepath.Join(featureDir, "compiled"),
- "--output", filepath.Join(featureDir, "plan.lock.json"),
- }
- if stage == "APPROVED" || (stage == "AMENDMENT_APPROVED" && fileExists(filepath.Join(featureDir, "approval.md"))) {
- args = append(args, "--approval", filepath.Join(featureDir, "approval.md"))
- }
- return &PrescribedCommand{Verb: "activate-plan", Args: args, Transition: MarkerPlanningActivate}
-}
-
-// NextControl composes the authoritative read-only recommendation (ResolveNext)
-// with the deterministic oracle to advise the lowest-cost next move toward a
-// published delivery. It performs no mutation and is safe to call at any time.
-func NextControl(repo, feature string) (FlowNext, error) {
- status, err := ResolveNext(repo, feature)
- if err != nil {
- return FlowNext{}, err
- }
- return nextControlFromStatus(repo, status)
-}
-
-func bindFlowCommandPrograms(repo string, next *FlowNext) {
- bind := func(command *PrescribedCommand) {
- if command == nil || command.Program != "" || command.Verb == "init" {
- return
- }
- workspace := WorkspaceFor(repo)
- if workspace.Mode == SupervisionDetached {
- command.Program = workspace.HelperPath()
- return
- }
- command.Program = projectLocalLauncherCommand()
- }
- bind(next.Prescribed)
- for index := range next.Alternatives {
- bind(&next.Alternatives[index])
- }
-}
-
-// nextControlFromStatus is NextControl on an already-resolved status, so a
-// caller that renders both the friendly phrase and the prescription (the
-// response contract) observes state exactly once — one resolution, no drift.
-func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) {
- out := FlowNext{
- Goal: flowGoal,
- Terminal: resolveDeliveryTerminal(repo, status.Feature),
- RecommendedOp: status.NextOperation,
- Reason: status.Reason,
- }
-
- var state deliverycontrol.StateID
- resolved := false
- if status.VerificationStatus == "VERIFIED" {
- state, resolved = flowStateFromStage(status.ObservedStage)
- }
- if !resolved {
- // Pre-activation and blocked stages sit outside the delivery oracle, but
- // they still name their exact runnable command. Resolved stays false: the
- // flow-state conservativeness contract is untouched.
- if cmd, followUp := prescribePlanning(repo, status); cmd != nil {
- out.Prescribed = cmd
- out.FollowUp = followUp
- }
- out.Alternatives = alternativesFor(repo, status, out)
- bindFlowCommandPrograms(repo, &out)
- out.Actor = classifyNextActor(status, out)
- return out, nil
- }
- out.State = state
-
- graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights())
- advice := graph.Advise(state, flowGoal)
- if advice.Resolution == deliverycontrol.Resolved {
- out.Resolved = true
- out.OracleNext = advice.NextTransition
- out.RemainingCost = advice.RemainingCost
- if advice.NextTransition != "" {
- if prescribed, ok := prescribeCommand(repo, status.Feature, status, advice.NextTransition); ok {
- out.Prescribed = prescribed
- }
- }
- // While the slice is building, "build" is opaque; surface the read-only
- // dependency-ordered next sub-action from the plan's task DAG as a hint.
- if state == deliverycontrol.StateBuild {
- if tasks, err := FlowTasksForActiveSlice(repo, status.Feature); err == nil && tasks.Resolved && len(tasks.Ordered) > 0 {
- hint := tasks.Ordered[0]
- out.SubAction = &hint
- }
- }
- }
- // Past publish the oracle sits at its sink and prescribes nothing. An
- // owed visual attachment is consulted first and under BOTH terminals —
- // it completes the publication itself — then, under the merged terminal
- // only, the observation-derived post-publish layer. Each fills only an
- // empty prescription — neither can override an oracle move.
- if out.Prescribed == nil {
- if cmd, followUp := prescribeVisualAttach(repo, status); cmd != nil {
- out.Prescribed = cmd
- out.FollowUp = followUp
- }
- }
- if out.Prescribed == nil {
- if cmd, followUp := prescribePostPublish(repo, status, out.Terminal); cmd != nil {
- out.Prescribed = cmd
- out.FollowUp = followUp
- }
- }
- out.Alternatives = alternativesFor(repo, status, out)
- bindFlowCommandPrograms(repo, &out)
- out.Actor = classifyNextActor(status, out)
- return out, nil
-}
-
-// FormatFlowNext renders a FlowNext advisory as human-facing lines. The
-// recommended operation always comes from the authoritative next-move table; the
-// oracle line is shown only when the flow position resolves, and is explicitly
-// labeled advisory so it is never mistaken for a gate.
-func FormatFlowNext(next FlowNext) string {
- var b strings.Builder
- fmt.Fprintf(&b, "Recommended: %s\n", next.RecommendedOp)
- if next.Reason != "" {
- fmt.Fprintf(&b, "Reason: %s\n", next.Reason)
- }
- if next.Actor != "" {
- fmt.Fprintf(&b, "Next actor: %s\n", next.Actor)
- }
- // The published default renders exactly as before; only the widened goal
- // earns a line, so opting in is visible and not opting in changes nothing.
- if next.Terminal == TerminalMerged {
- fmt.Fprintf(&b, "Terminal goal: merged (delivery.terminal)\n")
- }
- if next.Resolved {
- fmt.Fprintf(&b, "Flow state: %s -> goal %s\n", next.State, next.Goal)
- fmt.Fprintf(&b, "Advisory (flow oracle): next %s, remaining cost %d\n", next.OracleNext, next.RemainingCost)
- if next.Prescribed != nil {
- writePrescribed(&b, next.Prescribed)
- }
- if next.SubAction != nil {
- title := next.SubAction.Title
- if title != "" {
- title = " — " + title
- }
- fmt.Fprintf(&b, "Next sub-action: %s%s (from the plan task DAG; see `flow tasks`)\n", next.SubAction.ID, title)
- }
- } else if next.Prescribed != nil {
- fmt.Fprintf(&b, "Flow state: pre-activation (delivery oracle not engaged)\n")
- writePrescribed(&b, next.Prescribed)
- if next.FollowUp != "" {
- fmt.Fprintf(&b, "Then: %s\n", next.FollowUp)
- }
- } else {
- fmt.Fprintf(&b, "Flow state: unresolved (no oracle advisory; follow the recommended operation above)\n")
- }
- writeAlternatives(&b, next.Alternatives)
- return b.String()
-}
-
-// writeAlternatives renders the solution set's other legal moves as ONE line of
-// verbs with a short purpose gloss — never a second Run line, so the response
-// contract's single primary action holds.
-// control-law: solution-set-derives-from-guard-declarations
-func writeAlternatives(b *strings.Builder, alternatives []PrescribedCommand) {
- if len(alternatives) == 0 {
- return
- }
- shown := alternatives
- if len(shown) > solutionSetTextCap {
- shown = shown[:solutionSetTextCap]
- }
- labels := make([]string, 0, len(shown))
- for _, alt := range shown {
- labels = append(labels, alt.Verb+" ("+solutionGloss(alt.Transition)+")")
- }
- fmt.Fprintf(b, "Also legal from here: %s\n", strings.Join(labels, ", "))
- if len(alternatives) > len(shown) {
- fmt.Fprintf(b, " (%d more in `flow next --json` under alternatives)\n", len(alternatives)-len(shown))
- }
-}
-
-// writePrescribed renders the Run line and its owed-input annotation for a
-// prescribed command, shared by the oracle and pre-activation branches.
-func writePrescribed(b *strings.Builder, p *PrescribedCommand) {
- fmt.Fprintf(b, "Run: %s\n", p.CommandLine())
- if p.AutoDerivable {
- fmt.Fprintf(b, " (auto-derivable — all arguments follow from state)\n")
- } else {
- fmt.Fprintf(b, " You must supply: %s (never auto-filled)\n", strings.Join(p.RequiresHumanInput, " "))
- }
-}
diff --git a/boatstack/flow_control_test.go b/boatstack/flow_control_test.go
deleted file mode 100644
index fa3cac1..0000000
--- a/boatstack/flow_control_test.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package boatstack
-
-import (
- "strings"
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// FlowCheck must pass on the shipped model: the CLI gate would otherwise block
-// every invocation.
-func TestFlowCheckPassesOnShippedModel(t *testing.T) {
- result := FlowCheck()
- if !result.OK {
- t.Fatalf("shipped flow model failed static check: registry=%v deadlocks=%v goal-unreachable=%v",
- result.RegistryIssues, result.Liveness.Deadlocks, result.Liveness.GoalUnreachable)
- }
- if !result.Liveness.Live {
- t.Error("shipped model must be live")
- }
-}
-
-// CurrentFlowState resolves the concrete slice-lifecycle position from the
-// read-only projection; NextControl advises the lowest-cost next move that
-// matches the oracle.
-func TestNextControlAdvisesFromBuild(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
-
- state, ok := CurrentFlowState(repo, feature)
- if !ok || state != deliverycontrol.StateBuild {
- t.Fatalf("current flow state = %s (ok=%t), want BUILD", state, ok)
- }
-
- next, err := NextControl(repo, feature)
- if err != nil {
- t.Fatalf("NextControl: %v", err)
- }
- if next.RecommendedOp == "" {
- t.Error("expected an authoritative recommended operation")
- }
- if !next.Resolved {
- t.Fatal("expected the oracle to resolve a BUILD flow")
- }
- if next.OracleNext != "delivery.record_gate_test" {
- t.Errorf("oracle next = %s, want delivery.record_gate_test", next.OracleNext)
- }
- if next.RemainingCost != 3 {
- t.Errorf("remaining flow cost = %d, want 3", next.RemainingCost)
- }
-}
-
-// After the review gate the slice is REVIEW_PASSED, one low-cost publish from the
-// goal.
-func TestNextControlAdvisesFromReviewPassed(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- gateSlice(t, repo, feature, "phase-one")
-
- state, ok := CurrentFlowState(repo, feature)
- if !ok || state != deliverycontrol.StateReviewPassed {
- t.Fatalf("current flow state = %s (ok=%t), want REVIEW_PASSED", state, ok)
- }
-
- next, err := NextControl(repo, feature)
- if err != nil {
- t.Fatalf("NextControl: %v", err)
- }
- if !next.Resolved || next.OracleNext != "delivery.publish" || next.RemainingCost != 1 {
- t.Errorf("advisory from REVIEW_PASSED = %+v, want publish at cost 1", next)
- }
-}
-
-// A stage that is not a concrete slice-lifecycle position must resolve to unknown
-// so callers fall back rather than act on a guess. Pre-activation stages are
-// covered by prescribePlanning instead — a prescription, never a flow state.
-func TestFlowStateFromStageIsConservative(t *testing.T) {
- for _, stage := range []string{"NOT_STARTED", "AMBIGUOUS", "INVALID_STATE", "POLICY_READY", "DRAFT_PLAN", ""} {
- if _, ok := flowStateFromStage(stage); ok {
- t.Errorf("stage %q must not resolve to a flow state", stage)
- }
- }
-}
-
-// A pre-activation stage keeps Resolved=false (flow-state conservativeness) yet
-// still names its exact runnable command, and the rendering shows the honest
-// pre-activation label rather than the bare unresolved line.
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-func TestNextControlPrescribesWithoutResolvingBeforeActivation(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
-
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatalf("NextControl: %v", err)
- }
- if next.Resolved {
- t.Fatal("pre-activation must never resolve a flow state")
- }
- if next.Prescribed == nil || next.Prescribed.Verb != "check-plan" {
- t.Fatalf("DRAFT_PLAN must prescribe check-plan: %+v", next.Prescribed)
- }
- out := FormatFlowNext(next)
- if !strings.Contains(out, "pre-activation (delivery oracle not engaged)") || !strings.Contains(out, "Run: .product-loop/boatstack check-plan") {
- t.Fatalf("pre-activation rendering must label the state and carry the Run line: %q", out)
- }
-}
diff --git a/boatstack/flow_drive.go b/boatstack/flow_drive.go
deleted file mode 100644
index 2320359..0000000
--- a/boatstack/flow_drive.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package boatstack
-
-import (
- "os"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// flowDriveKillSwitch is the org-level master off switch for the opt-in execute
-// driver. Execution is OFF by default — it happens only when the caller passes
-// --execute per invocation — and setting this switch to "0" refuses execution
-// even then, falling back to prescribe-and-stop. It never affects the read-only
-// advisory (`flow next` without --execute).
-const flowDriveKillSwitch = "BOATSTACK_FLOW_DRIVE"
-
-// autoDrivableTransitions is the conservative allowlist of transitions the execute
-// driver may run without any human input. A transition qualifies ONLY if its full
-// argument set is state-derivable and it is read-only or reversible with nothing to
-// fabricate (no evidence, gate status, preview fingerprint, or reviewer identity).
-//
-// Test/review gate recordings still owe evidence and a status. Publication is
-// eligible only when prescription has already revalidated an exact preview and a
-// PR-target autonomy receipt for the exact open/update action. The allowlist is
-// the mechanism: it grows only as specific verbs gain provably-derivable inputs, and
-// execution is double-gated (a verb must be BOTH allowlisted here AND have an
-// explicit executor handler), so nothing runs by accident.
-var autoDrivableTransitions = map[deliverycontrol.TransitionID]bool{PublishTransition: true}
-
-// DriveAction is the driver's decision for one step.
-type DriveAction string
-
-const (
- // DriveNone: there is no prescribed next move (unresolved or terminal flow).
- DriveNone DriveAction = "none"
- // DrivePrescribe: emit the exact command for a human/CI to run, and stop.
- DrivePrescribe DriveAction = "prescribe"
- // DriveExecute: the move is safe and fully derivable; the driver may run it.
- DriveExecute DriveAction = "execute"
-)
-
-// DriveDecision is what the execute driver should do for one flow step. Command
-// is the prescribed command from the flow oracle (nil only when Action is
-// DriveNone); Reason explains the decision for the operator and telemetry.
-type DriveDecision struct {
- Action DriveAction `json:"action"`
- Command *PrescribedCommand `json:"command,omitempty"`
- Reason string `json:"reason"`
-}
-
-// canAutoDrive reports whether the driver may run a prescribed command with no
-// human input. Both conditions must hold: the command owes no human input
-// (AutoDerivable) AND its transition is on the allowlist. A derivable command off
-// the allowlist is not driven, and an allowlisted transition that still owes input
-// is not driven. A foreign-program command (Program != "", e.g. the prescribed
-// `gh pr merge`) is refused CATEGORICALLY, before the allowlist is even
-// consulted: the driver executes boatstack-helper verbs only, so no future
-// allowlist entry can ever make Boatstack run someone else's program.
-// control-law: merged-terminal-prescribes-merge-never-executes-it
-func canAutoDrive(cmd *PrescribedCommand, allowlist map[deliverycontrol.TransitionID]bool) bool {
- if cmd == nil || !cmd.AutoDerivable {
- return false
- }
- if cmd.Program != "" {
- return false
- }
- return allowlist[cmd.Transition]
-}
-
-// decideDrive is the pure decision at the heart of the execute driver. It never
-// executes an unresolved, human-gated, or off-allowlist move: such moves are
-// prescribed-and-stopped so the operator supplies what only a human can. The
-// allowlist is a parameter so the decision is testable independently of the
-// production set.
-func decideDrive(next FlowNext, executeOptIn, killed bool, allowlist map[deliverycontrol.TransitionID]bool) DriveDecision {
- if next.Prescribed == nil {
- return DriveDecision{Action: DriveNone, Reason: "no prescribed next move (flow position unresolved or already at goal)"}
- }
- if !executeOptIn {
- return DriveDecision{Action: DrivePrescribe, Command: next.Prescribed, Reason: "execute not requested; run the prescribed command by hand"}
- }
- if killed {
- return DriveDecision{Action: DrivePrescribe, Command: next.Prescribed, Reason: "execute disabled by " + flowDriveKillSwitch + "=0; prescribe-and-stop"}
- }
- if canAutoDrive(next.Prescribed, allowlist) {
- return DriveDecision{Action: DriveExecute, Command: next.Prescribed, Reason: "auto-derivable allowlisted move"}
- }
- return DriveDecision{Action: DrivePrescribe, Command: next.Prescribed, Reason: "move owes human input or is off the auto-drive allowlist; prescribe-and-stop"}
-}
-
-// DecideDrive is the production decision for one flow step, using the conservative
-// package allowlist. executeOptIn is the per-invocation --execute flag; killed is
-// the BOATSTACK_FLOW_DRIVE=0 kill switch.
-func DecideDrive(next FlowNext, executeOptIn, killed bool) DriveDecision {
- return decideDrive(next, executeOptIn, killed, autoDrivableTransitions)
-}
-
-// FlowDriveKilled reports whether the execute kill switch is set to "0". Exposed so
-// CLI wrappers in other packages can read the switch without importing the constant.
-func FlowDriveKilled() bool {
- return os.Getenv(flowDriveKillSwitch) == "0"
-}
diff --git a/boatstack/flow_drive_conformance_test.go b/boatstack/flow_drive_conformance_test.go
deleted file mode 100644
index c809232..0000000
--- a/boatstack/flow_drive_conformance_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package boatstack
-
-import (
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: execute-drives-only-safe-derivable-moves
-//
-// The opt-in execute driver runs a move ONLY when three things hold at once: the
-// caller asked to execute (--execute), the move owes no human input
-// (AutoDerivable), and its transition is on the auto-drive allowlist. Any of those
-// missing means prescribe-and-stop — the operator supplies what only a human can.
-// The contract, by class: an allowlisted derivable move with execute on runs
-// (Positive); an unresolved position never runs anything (Negative); a derivable
-// move whose transition is off the allowlist does not run (Relation); an
-// allowlisted move that still owes human input is never run — the driver cannot
-// fabricate that input (Bypass); and the kill switch or an execute-off invocation
-// forces prescribe even for an otherwise-runnable move (Failure-state).
-
-// syntheticTransition is an allowlisted transition used only by these tests, so the
-// decision logic can be exercised without depending on the production allowlist
-// (which is intentionally empty of forward moves).
-const syntheticTransition = deliverycontrol.TransitionID("test.synthetic_safe_move")
-
-func syntheticAllowlist() map[deliverycontrol.TransitionID]bool {
- return map[deliverycontrol.TransitionID]bool{syntheticTransition: true}
-}
-
-// derivableMove is a resolved FlowNext whose prescribed command owes no human input
-// and rides an allowlisted transition — the only shape the driver may execute.
-func derivableMove() FlowNext {
- return FlowNext{
- Resolved: true,
- Prescribed: &PrescribedCommand{
- Verb: "observe",
- AutoDerivable: true,
- Transition: syntheticTransition,
- },
- }
-}
-
-// Positive: execute requested, kill switch off, move derivable and allowlisted ->
-// the driver executes it.
-func TestDecideDriveExecutesDerivableAllowlisted(t *testing.T) {
- d := decideDrive(derivableMove(), true, false, syntheticAllowlist())
- if d.Action != DriveExecute {
- t.Fatalf("expected DriveExecute for a derivable allowlisted move, got %s (%s)", d.Action, d.Reason)
- }
- if d.Command == nil {
- t.Error("an executable decision must carry the command it drives")
- }
-}
-
-// Negative: an unresolved position (no prescribed command) never drives anything,
-// even with execute on — there is nothing legal to run.
-func TestDecideDriveUnresolvedDrivesNothing(t *testing.T) {
- d := decideDrive(FlowNext{Resolved: false}, true, false, syntheticAllowlist())
- if d.Action != DriveNone {
- t.Fatalf("unresolved flow must decide DriveNone, got %s", d.Action)
- }
- if d.Command != nil {
- t.Error("DriveNone must carry no command")
- }
-}
-
-// Relation: only allowlisted transitions execute. A derivable move whose transition
-// is NOT on the allowlist is prescribed, not run — allowlisting is necessary.
-func TestDecideDriveOffAllowlistPrescribes(t *testing.T) {
- next := derivableMove()
- d := decideDrive(next, true, false, map[deliverycontrol.TransitionID]bool{})
- if d.Action != DrivePrescribe {
- t.Fatalf("a derivable move off the allowlist must prescribe, got %s", d.Action)
- }
- if !canAutoDrive(next.Prescribed, syntheticAllowlist()) {
- t.Error("sanity: the same move on the allowlist should be auto-drivable")
- }
- if canAutoDrive(next.Prescribed, map[deliverycontrol.TransitionID]bool{}) {
- t.Error("a move off the allowlist must never report auto-drivable")
- }
-}
-
-// Bypass: a move that still owes human input is never executed, even when its
-// transition is allowlisted — the driver cannot fabricate the missing input, so it
-// prescribes-and-stops. This is the core safety property.
-func TestDecideDriveHumanGatedNeverExecutes(t *testing.T) {
- next := FlowNext{
- Resolved: true,
- Prescribed: &PrescribedCommand{
- Verb: "record-gate",
- AutoDerivable: false,
- RequiresHumanInput: []string{"--status", "--evidence"},
- Transition: syntheticTransition, // allowlisted, yet still owes input
- },
- }
- d := decideDrive(next, true, false, syntheticAllowlist())
- if d.Action != DrivePrescribe {
- t.Fatalf("a human-gated move must prescribe even when allowlisted, got %s", d.Action)
- }
- if canAutoDrive(next.Prescribed, syntheticAllowlist()) {
- t.Error("a move owing human input must never be auto-drivable")
- }
-}
-
-// Bypass (production allowlist): gate moves remain excluded. Publish eligibility
-// alone is insufficient because canAutoDrive also requires an exact fully-derived
-// command (which only a current PR receipt can produce).
-func TestProductionAllowlistContainsOnlyReceiptBoundPublish(t *testing.T) {
- gates := []deliverycontrol.TransitionID{
- deliverycontrol.TransitionID("delivery.record_gate_test"),
- deliverycontrol.TransitionID("delivery.record_gate_review"),
- }
- for _, tr := range gates {
- if autoDrivableTransitions[tr] {
- t.Errorf("human-gated transition %s must not be on the auto-drive allowlist", tr)
- }
- }
- if !autoDrivableTransitions[PublishTransition] {
- t.Fatal("receipt-bound publish transition must be allowlisted")
- }
- manual := &PrescribedCommand{Verb: "publish-pr", Transition: PublishTransition, RequiresHumanInput: []string{"--preview-fingerprint"}}
- if canAutoDrive(manual, autoDrivableTransitions) {
- t.Fatal("allowlisted publish with unresolved human input must not execute")
- }
-}
-
-// Failure-state: the kill switch (BOATSTACK_FLOW_DRIVE=0) and an execute-off
-// invocation both force prescribe-and-stop, even for an otherwise-executable move.
-func TestDecideDriveKillSwitchAndOptOutPrescribe(t *testing.T) {
- killed := decideDrive(derivableMove(), true, true, syntheticAllowlist())
- if killed.Action != DrivePrescribe {
- t.Errorf("kill switch must force prescribe, got %s", killed.Action)
- }
- optOut := decideDrive(derivableMove(), false, false, syntheticAllowlist())
- if optOut.Action != DrivePrescribe {
- t.Errorf("execute-off must prescribe, got %s", optOut.Action)
- }
-}
diff --git a/boatstack/flow_frontier.go b/boatstack/flow_frontier.go
deleted file mode 100644
index 59e8689..0000000
--- a/boatstack/flow_frontier.go
+++ /dev/null
@@ -1,228 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "strings"
-)
-
-// The frontier report answers "where is every ball, and whose is it" in one
-// read-only view: every managed delivery — active, published, or invalid —
-// becomes a row carrying its observed position and the actor who owes the
-// next step. It is pure presentation over the same resolution the flow oracle
-// uses, so a frontier row can never disagree with `flow next` for the same
-// delivery; and unlike next/recovery it performs ZERO writes — not even the
-// best-effort terminal PR-state cache — because a report that mutates is a
-// report that can lie about what it found.
-// control-law: frontier-reports-never-mutates
-const flowFrontierSchemaVersion = 1
-
-// FrontierRow is one delivery slice's position on the operator frontier.
-type FrontierRow struct {
- Feature string `json:"feature"`
- Slice string `json:"slice,omitempty"`
- SliceIndex int `json:"slice_index,omitempty"`
- TotalSlices int `json:"total_slices,omitempty"`
- Stage string `json:"stage"`
- Lifecycle string `json:"lifecycle,omitempty"`
- GoalEscape string `json:"goal_escape,omitempty"`
- PRPhase string `json:"pr_phase,omitempty"`
- PRFailingChecks []string `json:"pr_failing_checks,omitempty"`
- PRURL string `json:"pr_url,omitempty"`
- Actor string `json:"next_actor"`
- NextOperation string `json:"next_operation"`
- Prescribed string `json:"prescribed,omitempty"`
- Reason string `json:"reason"`
- Blocked bool `json:"blocked,omitempty"`
-}
-
-// FlowFrontier is the full cross-delivery dashboard.
-type FlowFrontier struct {
- SchemaVersion int `json:"schema_version"`
- Initialized bool `json:"initialized"`
- Rows []FrontierRow `json:"rows"`
- AgentSteps int `json:"agent_steps"`
- OperatorSteps int `json:"operator_steps"`
- TerminalRows int `json:"terminal_rows"`
- BlockedRows int `json:"blocked_rows"`
-}
-
-// ResolveFrontier builds the frontier report. Faults are partitioned, never
-// propagated: one invalid delivery becomes one blocked row instead of
-// poisoning the view of every healthy delivery (the same partition law the
-// read-only recovery boundary uses).
-// control-law: frontier-reports-never-mutates
-// control-law: stale-delivery-cannot-block-unrelated-feature
-func ResolveFrontier(repoPath string) (FlowFrontier, error) {
- frontier := FlowFrontier{SchemaVersion: flowFrontierSchemaVersion}
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return frontier, err
- }
- if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) {
- return frontier, nil
- }
- frontier.Initialized = true
- config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if configErr != nil {
- return frontier, fmt.Errorf("boatstack project configuration is invalid; fix the config file (doctor diagnoses): %w", configErr)
- }
- states, invalid, err := allManagedDeliveryStates(repo)
- if err != nil {
- return frontier, err
- }
- states = withoutIgnoredDeliveryStates(states, config.Workflow.IgnoredDeliveries)
- invalid = withoutIgnoredDeliveries(invalid, config.Workflow.IgnoredDeliveries)
-
- for _, slug := range invalid {
- frontier.Rows = append(frontier.Rows, FrontierRow{
- Feature: slug, Stage: "INVALID_STATE", Actor: string(NextActorOperator),
- NextOperation: "discard-delivery", Blocked: true,
- Reason: "This managed delivery state cannot be verified; restore its evidence, ignore it, or discard it.",
- })
- }
- for _, state := range states {
- if state.ActiveIndex < len(state.Slices) {
- frontier.Rows = append(frontier.Rows, activeDeliveryRows(repo, state)...)
- continue
- }
- branch, _, prURL := deliveryBranchAndSlice(state)
- status := publishedNextStatus(state, observePRTarget(repo, prURL, branch), resolveDeliveryTerminal(repo, state.Feature), observeVisualPublication(repo, state.Feature))
- frontier.Rows = append(frontier.Rows, frontierRowFromStatus(repo, status))
- }
- for _, row := range frontier.Rows {
- switch {
- case row.Blocked:
- frontier.BlockedRows++
- case row.Actor == string(NextActorAgent):
- frontier.AgentSteps++
- case row.Actor == string(NextActorNone):
- frontier.TerminalRows++
- default:
- frontier.OperatorSteps++
- }
- }
- return frontier, nil
-}
-
-// activeDeliveryRows renders an active delivery: one row for the active slice
-// via the authoritative resolution, plus one row for every earlier slice that
-// is published with a still-open PR — those are live balls too (their checks
-// can be failing while the active slice builds), and they are exactly the
-// addressable set the actuators can still re-gate in place.
-func activeDeliveryRows(repo string, state DeliveryState) []FrontierRow {
- rows := []FrontierRow{}
- status, err := nextForDelivery(repo, state.Feature)
- if err != nil {
- rows = append(rows, FrontierRow{
- Feature: state.Feature, Stage: "INVALID_STATE", Actor: string(NextActorOperator),
- NextOperation: "discard-delivery", Blocked: true,
- Reason: "The active managed delivery cannot be verified: " + err.Error(),
- })
- } else {
- rows = append(rows, frontierRowFromStatus(repo, status))
- }
- limit := state.ActiveIndex
- if limit > len(state.Slices) {
- limit = len(state.Slices)
- }
- for i := 0; i < limit; i++ {
- slice := state.Slices[i]
- if slice.Status != "PUBLISHED" || strings.TrimSpace(slice.PRState) == "" || isTerminalPRState(slice.PRState) {
- continue
- }
- observation := observePRTarget(repo, slice.PRURL, slice.HeadBranch)
- sliceStatus := NextStatus{
- SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED",
- Feature: state.Feature, ActiveSlice: slice.ID, SliceIndex: i + 1,
- TotalSlices: len(state.Slices), ObservedStage: "PUBLISHED", NextOperation: "none",
- Lifecycle: observation.Lifecycle, PRURL: observation.URL, HeadBranch: observation.Branch,
- PRPhase: string(observation.Phase), PRReviewDecision: observation.ReviewDecision,
- PRMergeState: observation.MergeState, PRFailingChecks: observation.FailingChecks,
- Reason: fmt.Sprintf("Slice %q is published with an open pull request while a later slice is active.", slice.ID),
- }
- if resolveDeliveryTerminal(repo, state.Feature) == TerminalMerged && observation.Lifecycle != "PUBLISHED_MERGED" {
- sliceStatus.GoalEscape = evaluateGoalEscape(slice, observation)
- }
- rows = append(rows, frontierRowFromStatus(repo, sliceStatus))
- }
- return rows
-}
-
-// frontierRowFromStatus projects one resolved status through the SAME actor
-// classification and prescription layer `flow next` uses — one resolution
-// path, so the dashboard and the advisor can never name different owners for
-// the same step.
-func frontierRowFromStatus(repo string, status NextStatus) FrontierRow {
- row := FrontierRow{
- Feature: status.Feature, Slice: status.ActiveSlice,
- SliceIndex: status.SliceIndex, TotalSlices: status.TotalSlices,
- Stage: status.ObservedStage, Lifecycle: status.Lifecycle,
- GoalEscape: status.GoalEscape,
- PRPhase: status.PRPhase, PRFailingChecks: status.PRFailingChecks,
- PRURL: status.PRURL, NextOperation: status.NextOperation,
- Reason: status.Reason,
- Blocked: status.VerificationStatus == "BLOCKED",
- }
- next, err := nextControlFromStatus(repo, status)
- if err != nil {
- row.Actor = string(NextActorOperator)
- row.Blocked = true
- return row
- }
- row.Actor = string(next.Actor)
- if next.Prescribed != nil {
- row.Prescribed = next.Prescribed.CommandLine()
- }
- return row
-}
-
-// frontierPosition is the one-word position column: the observed PR phase when
-// it is positively known, the stage otherwise.
-func frontierPosition(row FrontierRow) string {
- if row.PRPhase != "" && row.PRPhase != string(PRPhaseUnknown) {
- return row.PRPhase
- }
- return row.Stage
-}
-
-// FormatFlowFrontier renders the dashboard as fixed-width human-facing lines.
-func FormatFlowFrontier(frontier FlowFrontier) string {
- var b strings.Builder
- if !frontier.Initialized {
- b.WriteString("Boatstack is not tracking anything here yet.\n")
- return b.String()
- }
- if len(frontier.Rows) == 0 {
- b.WriteString("Frontier: no managed deliveries.\n")
- return b.String()
- }
- fmt.Fprintf(&b, "Frontier: %d for you, %d for the agent, %d complete, %d blocked\n",
- frontier.OperatorSteps, frontier.AgentSteps, frontier.TerminalRows, frontier.BlockedRows)
- nameWidth, positionWidth := len("FEATURE"), len("POSITION")
- for _, row := range frontier.Rows {
- if len(frontierLabel(row)) > nameWidth {
- nameWidth = len(frontierLabel(row))
- }
- if len(frontierPosition(row)) > positionWidth {
- positionWidth = len(frontierPosition(row))
- }
- }
- fmt.Fprintf(&b, "%-*s %-*s %-8s %s\n", nameWidth, "FEATURE", positionWidth, "POSITION", "ACTOR", "NEXT")
- for _, row := range frontier.Rows {
- next := row.NextOperation
- if len(row.PRFailingChecks) > 0 {
- next += " (failing: " + strings.Join(row.PRFailingChecks, ", ") + ")"
- }
- fmt.Fprintf(&b, "%-*s %-*s %-8s %s\n", nameWidth, frontierLabel(row), positionWidth, frontierPosition(row), row.Actor, next)
- }
- return b.String()
-}
-
-// frontierLabel names a row: the feature, with the slice id appended when the
-// delivery has more than one slice so two rows of one delivery stay distinct.
-func frontierLabel(row FrontierRow) string {
- if row.TotalSlices > 1 && row.Slice != "" {
- return row.Feature + "/" + row.Slice
- }
- return row.Feature
-}
diff --git a/boatstack/flow_frontier_conformance_test.go b/boatstack/flow_frontier_conformance_test.go
deleted file mode 100644
index 11f676a..0000000
--- a/boatstack/flow_frontier_conformance_test.go
+++ /dev/null
@@ -1,217 +0,0 @@
-package boatstack
-
-// control-law: frontier-reports-never-mutates
-//
-// The frontier dashboard is a pure projection: it reports every managed
-// delivery's position and owing actor while performing ZERO writes — not even
-// the best-effort terminal PR-state cache the next/recovery resolvers
-// maintain. A report that mutates is a report that can lie about what it
-// found. Companion laws exercised here:
-// stale-delivery-cannot-block-unrelated-feature (one corrupt delivery is one
-// blocked row, never a poisoned view) and
-// turn-ends-only-at-the-operator-frontier (a frontier row's actor equals the
-// flow advisor's actor for the same delivery — one classification path).
-//
-// Test classes: positive (a multi-delivery store renders every slice with a
-// typed actor and live PR position), negative (a corrupt delivery yields one
-// blocked row while healthy rows survive), bypass (state files are
-// byte-identical after a frontier run, even under a terminal MERGED
-// observation), relation (frontier actor == flow next actor per delivery).
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func frontierStateBytes(t *testing.T, repo string, features ...string) map[string]string {
- t.Helper()
- snapshot := map[string]string{}
- for _, feature := range features {
- path, err := deliveryStatePath(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- value, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- snapshot[feature] = string(value)
- }
- return snapshot
-}
-
-// Positive + relation: every delivery renders with a typed actor, the
-// published delivery carries its live PR phase, and each row's actor matches
-// the flow advisor's actor for the same feature.
-func TestFrontierRendersEveryDeliveryWithTypedActor(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "building", "BUILD", 0)
- writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "")
- withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail))
-
- frontier, err := ResolveFrontier(repo)
- if err != nil {
- t.Fatal(err)
- }
- rows := map[string]FrontierRow{}
- for _, row := range frontier.Rows {
- rows[row.Feature] = row
- }
- building, ok := rows["building"]
- if !ok || building.Stage != "BUILD" || building.Actor != string(NextActorAgent) {
- t.Fatalf("unexpected building row: %#v", building)
- }
- if building.Prescribed == "" {
- t.Fatal("an agent-owned row must carry its prescribed command")
- }
- shipped, ok := rows["shipped"]
- if !ok || shipped.Stage != "PUBLISHED" || shipped.PRPhase != string(PRPhaseChecksFailing) {
- t.Fatalf("unexpected shipped row: %#v", shipped)
- }
- if shipped.Actor != string(NextActorOperator) {
- t.Fatalf("published-open step belongs to the operator today: %#v", shipped)
- }
- if frontier.AgentSteps != 1 || frontier.OperatorSteps != 1 || frontier.BlockedRows != 0 {
- t.Fatalf("unexpected summary: %#v", frontier)
- }
-
- // Relation: the frontier's actor for each feature equals the advisor's.
- for _, feature := range []string{"building", "shipped"} {
- next, nextErr := NextControl(repo, feature)
- if nextErr != nil {
- t.Fatal(nextErr)
- }
- if string(next.Actor) != rows[feature].Actor {
- t.Fatalf("frontier actor %q disagrees with flow next actor %q for %s", rows[feature].Actor, next.Actor, feature)
- }
- }
-
- rendered := FormatFlowFrontier(frontier)
- if !strings.Contains(rendered, "PR_CHECKS_FAILING") || !strings.Contains(rendered, "failing: unit") {
- t.Fatalf("rendered frontier hides the live PR position:\n%s", rendered)
- }
-}
-
-// Positive: an active delivery with an earlier published-but-open slice shows
-// both balls — the building active slice and the open PR of the earlier slice.
-func TestFrontierShowsEarlierPublishedOpenSlices(t *testing.T) {
- repo := nextTestRepo(t)
- directory := filepath.Join(repo, ".product-loop", "features", "layered")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- hash := writeNextPlanLock(t, directory)
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "layered", PlanLockHash: hash,
- ActiveIndex: 1, Slices: []DeliverySlice{
- {ID: "first", Title: "First", Status: "PUBLISHED", PRURL: "https://example.invalid/pr/9", HeadBranch: "feat/phase", PRState: "OPEN"},
- {ID: "second", Title: "Second", Status: "BUILD"},
- },
- }); err != nil {
- t.Fatal(err)
- }
- withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail))
-
- frontier, frontierErr := ResolveFrontier(repo)
- if frontierErr != nil {
- t.Fatal(frontierErr)
- }
- if len(frontier.Rows) != 2 {
- t.Fatalf("want active + earlier published rows, got %#v", frontier.Rows)
- }
- var earlier *FrontierRow
- for i := range frontier.Rows {
- if frontier.Rows[i].Slice == "first" {
- earlier = &frontier.Rows[i]
- }
- }
- if earlier == nil || earlier.PRPhase != string(PRPhaseChecksFailing) || earlier.Actor != string(NextActorOperator) {
- t.Fatalf("earlier published-open slice not surfaced: %#v", frontier.Rows)
- }
-}
-
-// Negative: one corrupt delivery becomes one blocked row; the healthy
-// delivery's row survives untouched.
-func TestFrontierPartitionsCorruptDeliveries(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "healthy", "BUILD", 0)
- writeNextDelivery(t, repo, "corrupt", "BUILD", 0)
- statePath, err := deliveryStatePath(repo, "corrupt")
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(statePath, []byte("not json"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- frontier, err := ResolveFrontier(repo)
- if err != nil {
- t.Fatal(err)
- }
- if len(frontier.Rows) != 2 || frontier.BlockedRows != 1 || frontier.AgentSteps != 1 {
- t.Fatalf("unexpected partition: %#v", frontier)
- }
- for _, row := range frontier.Rows {
- if row.Feature == "corrupt" && (!row.Blocked || row.NextOperation != "discard-delivery") {
- t.Fatalf("corrupt delivery not routed to its remedy: %#v", row)
- }
- if row.Feature == "healthy" && (row.Blocked || row.Actor != string(NextActorAgent)) {
- t.Fatalf("healthy delivery poisoned by corrupt neighbor: %#v", row)
- }
- }
-}
-
-// Bypass: the frontier performs zero writes — the delivery ledger is
-// byte-identical after a run, even when the live observation is terminal
-// (MERGED), which next/recovery WOULD cache. The report never mutates.
-func TestFrontierWritesNothingEvenOnTerminalObservation(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "building", "BUILD", 0)
- writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "")
- withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", ""))
-
- before := frontierStateBytes(t, repo, "building", "shipped")
- frontier, err := ResolveFrontier(repo)
- if err != nil {
- t.Fatal(err)
- }
- after := frontierStateBytes(t, repo, "building", "shipped")
- for feature, value := range before {
- if after[feature] != value {
- t.Fatalf("frontier mutated delivery state for %q", feature)
- }
- }
- var shipped FrontierRow
- for _, row := range frontier.Rows {
- if row.Feature == "shipped" {
- shipped = row
- }
- }
- if shipped.Actor != string(NextActorNone) || shipped.Stage != "FEATURE_COMPLETE" {
- t.Fatalf("terminal observation misclassified: %#v", shipped)
- }
-}
-
-// Failure-state: an uninitialized repository reports an empty, unblocked
-// frontier rather than an error.
-func TestFrontierOnUninitializedRepository(t *testing.T) {
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- frontier, err := ResolveFrontier(repo)
- if err != nil {
- t.Fatal(err)
- }
- if frontier.Initialized || len(frontier.Rows) != 0 {
- t.Fatalf("unexpected frontier: %#v", frontier)
- }
- if rendered := FormatFlowFrontier(frontier); !strings.Contains(rendered, "not tracking") {
- t.Fatalf("unexpected rendering: %s", rendered)
- }
-}
diff --git a/boatstack/flow_guard.go b/boatstack/flow_guard.go
deleted file mode 100644
index 2da5989..0000000
--- a/boatstack/flow_guard.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// flowControlKillSwitch disables active flow control when set to "0". Control is
-// ON by default: the controller pre-denies a committed mutation the delivery
-// graph proves is friction — illegal from the current state, so the real state
-// machine would reject it anyway — and points at the low-cost move instead.
-// Setting the switch to "0" restores the pre-control behavior exactly: the real
-// handler still rejects the move, just without the early guidance.
-const flowControlKillSwitch = "BOATSTACK_FLOW_CONTROL"
-
-// enforceableFrictions is the conservative allowlist of committed mutations whose
-// graph legality provably matches the real state-machine guard, so pre-denying an
-// illegal attempt changes no outcome (the handler would reject it too) and only
-// improves the message and the trajectory record:
-//
-// - delivery.publish requires REVIEW_PASSED (CheckDeliveryReadyForShip);
-// publishing earlier is the canonical friction the flow model targets.
-// - delivery.record_gate_review requires TEST_PASSED; a review gate before the
-// test gate is rejected by the same guard.
-//
-// Verbs whose real guards are mode-sensitive or re-entrant — record-change, undo,
-// discard, ignore, and re-recording the test gate — are deliberately excluded.
-// They are never pre-denied here; the real handler remains the sole authority.
-var enforceableFrictions = map[deliverycontrol.TransitionID]bool{
- deliverycontrol.TransitionID("delivery.publish"): true,
- deliverycontrol.TransitionID("delivery.record_gate_review"): true,
-}
-
-// PublishTransition is the registry transition for publishing a delivery PR,
-// exported so CLI wrappers can guard it without importing the internal package.
-var PublishTransition = deliverycontrol.TransitionID("delivery.publish")
-
-// FlowGuard is the decision of the flow-control choke point for one committed
-// delivery mutation. Allow=false means the move was pre-denied as proven
-// friction; Message carries the guidance to surface. From/Transition/Resolved
-// describe the resolved flow position for the caller's trajectory record.
-type FlowGuard struct {
- Allow bool
- Message string
- From deliverycontrol.StateID
- Transition deliverycontrol.TransitionID
- Resolved bool
-}
-
-// GuardFlowMove is the pure decision the CLI choke point consults before running
-// an enforced committed mutation. It is conservative by construction and has no
-// side effects (recording is the caller's job, so the outcome reflects the real
-// handler):
-//
-// - unresolved flow position -> Allow (never act on a guessed state);
-// - transition outside the enforceable-friction allowlist -> Allow;
-// - kill switch BOATSTACK_FLOW_CONTROL=0 -> Allow (restores prior behavior);
-// - legal (productive) move from the current state -> Allow;
-// - otherwise -> pre-deny with guidance toward the low-cost move.
-//
-// A pre-denied move is one the real state machine would reject anyway, so
-// enforcement changes guidance and telemetry, never the outcome of a move the
-// machine would have allowed.
-func GuardFlowMove(repo, feature string, transition deliverycontrol.TransitionID) FlowGuard {
- from, resolved := CurrentFlowState(repo, feature)
- guard := FlowGuard{Allow: true, From: from, Transition: transition, Resolved: resolved}
-
- if !resolved || !enforceableFrictions[transition] || os.Getenv(flowControlKillSwitch) == "0" {
- return guard
- }
-
- graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights())
- if graph.IsLegalMove(from, transition) {
- return guard
- }
-
- guard.Allow = false
- advice := graph.Advise(from, flowGoal)
- if advice.Resolution == deliverycontrol.Resolved && advice.NextTransition != "" {
- guard.Message = fmt.Sprintf("flow control: %s is not available from %s; the low-cost next move is %s (set %s=0 to disable)",
- transition, from, advice.NextTransition, flowControlKillSwitch)
- } else {
- guard.Message = fmt.Sprintf("flow control: %s is not available from %s (set %s=0 to disable)",
- transition, from, flowControlKillSwitch)
- }
- return guard
-}
-
-// GateTransition maps a record-delivery-gate --gate value to its registry
-// transition, or "" when the gate is not one the controller reasons about.
-func GateTransition(gate string) deliverycontrol.TransitionID {
- switch gate {
- case "test":
- return deliverycontrol.TransitionID("delivery.record_gate_test")
- case "review":
- return deliverycontrol.TransitionID("delivery.record_gate_review")
- default:
- return ""
- }
-}
diff --git a/boatstack/flow_guard_test.go b/boatstack/flow_guard_test.go
deleted file mode 100644
index a8d6a9b..0000000
--- a/boatstack/flow_guard_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package boatstack
-
-import (
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// Positive: a productive move is always allowed. Publishing from REVIEW_PASSED is
-// the legal out-edge, so the controller must not interfere.
-func TestGuardAllowsProductiveMove(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- gateSlice(t, repo, feature, "phase-one")
-
- guard := GuardFlowMove(repo, feature, PublishTransition)
- if !guard.Allow {
- t.Fatalf("publish from REVIEW_PASSED must be allowed; got deny %q", guard.Message)
- }
- if guard.From != deliverycontrol.StateReviewPassed {
- t.Errorf("guard.From = %s, want REVIEW_PASSED", guard.From)
- }
-}
-
-// Negative: publishing before review is proven friction — illegal from BUILD, so
-// the real machine would reject it too. The controller pre-denies with guidance.
-func TestGuardDeniesPublishBeforeReview(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
-
- guard := GuardFlowMove(repo, feature, PublishTransition)
- if guard.Allow {
- t.Fatal("publish from BUILD must be pre-denied as friction")
- }
- if guard.From != deliverycontrol.StateBuild {
- t.Errorf("guard.From = %s, want BUILD", guard.From)
- }
- if guard.Message == "" {
- t.Error("a denied guard must carry guidance")
- }
-}
-
-// Negative: a review gate before the test gate is friction from BUILD.
-func TestGuardDeniesReviewGateBeforeTest(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
-
- guard := GuardFlowMove(repo, feature, GateTransition("review"))
- if guard.Allow {
- t.Fatal("review gate from BUILD must be pre-denied as friction")
- }
-}
-
-// Relation: the kill switch restores prior behavior exactly — the controller
-// allows the move and defers entirely to the real handler.
-func TestGuardKillSwitchAllows(t *testing.T) {
- t.Setenv(flowControlKillSwitch, "0")
- repo, feature := activateTwoSliceDelivery(t)
-
- if guard := GuardFlowMove(repo, feature, PublishTransition); !guard.Allow {
- t.Fatal("kill switch must restore prior (allow) behavior")
- }
-}
-
-// Bypass guard: verbs outside the enforceable-friction allowlist are never
-// pre-denied, even when illegal in the graph — the real handler stays the sole
-// authority for mode-sensitive/re-entrant moves.
-func TestGuardNeverDeniesNonEnforceableVerb(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
-
- // record_change is illegal from BUILD in the graph, but it is not enforceable.
- guard := GuardFlowMove(repo, feature, deliverycontrol.TransitionID("delivery.record_change"))
- if !guard.Allow {
- t.Fatal("non-enforceable verb must never be pre-denied")
- }
- // The test gate is legal from BUILD and also not enforced.
- if guard := GuardFlowMove(repo, feature, GateTransition("test")); !guard.Allow {
- t.Fatal("test gate from BUILD must be allowed")
- }
-}
-
-// Failure-state: an unresolved flow position falls back to allow — the controller
-// never acts on a guessed state.
-func TestGuardUnresolvedAllows(t *testing.T) {
- repo := prTestRepo(t)
-
- if guard := GuardFlowMove(repo, "", PublishTransition); !guard.Allow {
- t.Fatal("unresolved flow must fall back to allow")
- } else if guard.Resolved {
- t.Error("expected the guard to report the position unresolved")
- }
-}
diff --git a/boatstack/flow_planning_prescribe_conformance_test.go b/boatstack/flow_planning_prescribe_conformance_test.go
deleted file mode 100644
index 57457d2..0000000
--- a/boatstack/flow_planning_prescribe_conformance_test.go
+++ /dev/null
@@ -1,331 +0,0 @@
-package boatstack
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-//
-// `flow next` used to go quiet for every pre-activation stage: the delivery
-// oracle deliberately does not model planning, so the advisory printed only a
-// soft operation name and no runnable command, and a denied raw write named
-// only the cleanup verb. The contract these tests hold: every ObservedStage
-// ResolveNext can emit either resolves through the delivery oracle, or is
-// prescribed a concrete runnable command by prescribePlanning, or sits on an
-// explicit documented exception list — and the prescriptions never fabricate
-// human-owed input, never become legal delivery moves, and never auto-execute.
-
-// planningExceptions are the stages that deliberately prescribe nothing.
-// Adding a stage to ResolveNext without a prescription rule or an entry here
-// fails the totality sweep below.
-var planningExceptions = map[string]string{
- "AMBIGUOUS": "choosing between candidate features/deliveries is a human act; candidates surface via Reason/BlockingAmbiguity",
-}
-
-func containsAllStrings(values, required []string) bool {
- set := map[string]bool{}
- for _, value := range values {
- set[value] = true
- }
- for _, value := range required {
- if !set[value] {
- return false
- }
- }
- return true
-}
-
-// planningStages is one representative synthetic NextStatus per pre-activation
-// stage (INVALID_STATE once per NextOperation route ResolveNext or the safety
-// finding can carry).
-var planningStages = []NextStatus{
- {ObservedStage: "NOT_INITIALIZED", NextOperation: "init"},
- {ObservedStage: "NOT_STARTED", NextOperation: "auto-plan"},
- {ObservedStage: "DRAFT_PLAN", NextOperation: "plan-gate", Feature: "demo"},
- {ObservedStage: "DRAFT_PLAN", NextOperation: "workspace-cut", Feature: "demo"},
- {ObservedStage: "APPROVED", NextOperation: "build", Feature: "demo"},
- {ObservedStage: "APPROVED", NextOperation: "workspace-cut", Feature: "demo"},
- {ObservedStage: "POLICY_READY", NextOperation: "build", Feature: "demo"},
- {ObservedStage: "POLICY_READY", NextOperation: "workspace-cut", Feature: "demo"},
- {ObservedStage: "INVALID_STATE", NextOperation: "doctor"},
- {ObservedStage: "INVALID_STATE", NextOperation: "discard-delivery", BlockingAmbiguity: []string{"stale"}},
- {ObservedStage: "INVALID_STATE", NextOperation: "repair-state", Feature: "demo"},
-}
-
-// Positive: each pre-activation stage reached through the real read-only
-// projection (NextControl end to end) yields the exact runnable command.
-func TestNextControlPrescribesPreActivationStages(t *testing.T) {
- t.Run("not_initialized", func(t *testing.T) {
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if next.Resolved {
- t.Fatalf("pre-activation must not resolve a flow state: %+v", next)
- }
- if next.Prescribed == nil || next.Prescribed.Verb != "init" {
- t.Fatalf("NOT_INITIALIZED must prescribe init: %+v", next.Prescribed)
- }
- })
-
- t.Run("not_started_owes_plan_path", func(t *testing.T) {
- repo := nextTestRepo(t)
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- p := next.Prescribed
- if p == nil || p.Verb != "flow" || len(p.Args) == 0 || p.Args[0] != "bootstrap" {
- t.Fatalf("NOT_STARTED must prescribe flow bootstrap: %+v", p)
- }
- if p.AutoDerivable || !containsAllStrings(p.RequiresHumanInput, []string{"--feature", "--source-plan", "--artifact", "--shell", planningMarkdownInput}) {
- t.Fatalf("bootstrap creation evidence must be owed, never fabricated: %+v", p)
- }
- if !strings.Contains(next.FollowUp, "planning_envelope") {
- t.Fatalf("the planning follow-up must name the owned authoring channel: %q", next.FollowUp)
- }
- })
-
- t.Run("draft_plan_prescribes_check_plan_on_real_path", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- p := next.Prescribed
- if p == nil || p.Verb != "check-plan" || !p.AutoDerivable {
- t.Fatalf("DRAFT_PLAN must prescribe an auto-derivable check-plan: %+v", p)
- }
- planPath := ""
- for i, arg := range p.Args {
- if arg == "--plan" && i+1 < len(p.Args) {
- planPath = p.Args[i+1]
- }
- }
- if planPath == "" {
- t.Fatalf("check-plan prescription carries no --plan: %+v", p.Args)
- }
- if _, err := os.Stat(planPath); err != nil {
- t.Fatalf("prescribed --plan does not point at the saved plan: %v", err)
- }
- if !strings.Contains(next.FollowUp, "record-approval") {
- t.Fatalf("DRAFT_PLAN follow-up must route to record-approval: %q", next.FollowUp)
- }
- })
-
- t.Run("approved_prescribes_activate_plan_with_approval", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- approval := filepath.Join(repo, ".product-loop", "features", "demo", "approval.md")
- if err := os.WriteFile(approval, []byte("approved\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- p := next.Prescribed
- if p == nil || p.Verb != "activate-plan" || !p.AutoDerivable {
- t.Fatalf("APPROVED must prescribe an auto-derivable activate-plan: %+v", p)
- }
- joined := strings.Join(p.Args, " ")
- for _, want := range []string{"--plan", "--out-dir", "--output", "--approval"} {
- if !strings.Contains(joined, want) {
- t.Fatalf("activate-plan prescription missing %s: %q", want, joined)
- }
- }
- })
-
- t.Run("policy_ready_prescribes_activate_plan_without_approval", func(t *testing.T) {
- repo := nextTestRepo(t)
- config := testConfig()
- config.Workflow.HumanPlanApproval = false
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- writeSavedFeaturePlan(t, repo, "demo")
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- p := next.Prescribed
- if p == nil || p.Verb != "activate-plan" {
- t.Fatalf("POLICY_READY must prescribe activate-plan: %+v", p)
- }
- if strings.Contains(strings.Join(p.Args, " "), "--approval") {
- t.Fatalf("policy activation must not reference an approval receipt: %+v", p.Args)
- }
- })
-
- t.Run("invalid_state_prescribes_discard_delivery", func(t *testing.T) {
- repo := nextTestRepo(t)
- directory := filepath.Join(repo, ".product-loop", "features", "orphan")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "pr.md"), []byte("# Preview\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- p := next.Prescribed
- if p == nil || p.Verb != "discard-delivery" {
- t.Fatalf("INVALID_STATE orphan must prescribe discard-delivery: %+v", p)
- }
- if !strings.Contains(strings.Join(p.Args, " "), "--feature orphan") {
- t.Fatalf("a unique blocker derives --feature: %+v", p.Args)
- }
- })
-}
-
-// Relation/Totality: every ObservedStage ResolveNext can emit is oracle-owned,
-// prescribed, or a documented exception. A new stage fails here until it gets a
-// rule or an exception entry.
-func TestEveryObservedStageIsPrescribedOrExcepted(t *testing.T) {
- prescribed := map[string]bool{}
- for _, status := range planningStages {
- if cmd, _ := prescribePlanning(".", status); cmd != nil {
- prescribed[status.ObservedStage] = true
- }
- }
- allStages := []string{
- "NOT_INITIALIZED", "NOT_STARTED", "DRAFT_PLAN", "APPROVED", "POLICY_READY",
- "AMBIGUOUS", "INVALID_STATE",
- "BUILD", "TEST_PASSED", "REVIEW_PASSED", "PR_PREVIEW", "PUBLISHED", "FEATURE_COMPLETE",
- }
- for _, stage := range allStages {
- if _, oracle := flowStateFromStage(stage); oracle {
- continue
- }
- if prescribed[stage] {
- continue
- }
- if reason, ok := planningExceptions[stage]; ok {
- if reason == "" {
- t.Fatalf("exception for %s must carry a reason", stage)
- }
- continue
- }
- t.Errorf("stage %s is neither oracle-owned, prescribed, nor a documented exception", stage)
- }
-}
-
-// Negative: prescriptions never fabricate human-owed input, and AutoDerivable
-// is exactly the absence of owed input.
-func TestPlanningPrescriptionsNeverFabricateHumanInput(t *testing.T) {
- for _, status := range planningStages {
- cmd, _ := prescribePlanning(".", status)
- if cmd == nil {
- continue
- }
- if cmd.AutoDerivable != (len(cmd.RequiresHumanInput) == 0) {
- t.Errorf("%s/%s: AutoDerivable must equal owed-input emptiness: %+v", status.ObservedStage, status.NextOperation, cmd)
- }
- for _, owed := range cmd.RequiresHumanInput {
- for _, arg := range cmd.Args {
- if arg == owed {
- t.Errorf("%s/%s: owed flag %s must never appear in Args: %+v", status.ObservedStage, status.NextOperation, owed, cmd.Args)
- }
- }
- }
- }
- // A non-unique blocker cannot derive --feature; it must be owed.
- cmd, _ := prescribePlanning(".", NextStatus{
- ObservedStage: "INVALID_STATE", NextOperation: "discard-delivery",
- BlockingAmbiguity: []string{"one", "two"},
- })
- if cmd == nil || cmd.AutoDerivable || len(cmd.RequiresHumanInput) != 1 || cmd.RequiresHumanInput[0] != "--feature" {
- t.Fatalf("ambiguous discard-delivery must owe --feature: %+v", cmd)
- }
-}
-
-// Bypass: planning/recovery markers can never reach the delivery machine or
-// the execute driver — a marked prescription is always prescribe-and-stop.
-func TestPlanningMarkersCannotReachDeliveryMachineOrDriver(t *testing.T) {
- graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights())
- states := []deliverycontrol.StateID{
- deliverycontrol.StateUninitialized, deliverycontrol.StatePending,
- deliverycontrol.StateBuild, deliverycontrol.StateTestPassed,
- deliverycontrol.StateReviewPassed, deliverycontrol.StatePublished,
- }
- for _, status := range planningStages {
- cmd, _ := prescribePlanning(".", status)
- if cmd == nil {
- continue
- }
- if _, ok := deliverycontrol.Transition(cmd.Transition); ok {
- t.Errorf("marker %s must not be a registry transition", cmd.Transition)
- }
- for _, state := range states {
- if graph.IsLegalMove(state, cmd.Transition) {
- t.Errorf("marker %s must not be a legal move from %s", cmd.Transition, state)
- }
- }
- decision := decideDrive(FlowNext{Prescribed: cmd}, true, false, autoDrivableTransitions)
- if decision.Action != DrivePrescribe {
- t.Errorf("marker %s must prescribe-and-stop under --execute, got %s", cmd.Transition, decision.Action)
- }
- }
-}
-
-// Failure-state: the repair loop names the owned authoring channel; ambiguous
-// and unknown stages prescribe nothing rather than a guess, and the rendering
-// still routes to the recommended operation.
-func TestPlanningPrescriptionFailureStates(t *testing.T) {
- t.Run("repair_follow_up_names_planning_write", func(t *testing.T) {
- cmd, followUp := prescribePlanning(".", NextStatus{
- ObservedStage: "INVALID_STATE", NextOperation: "repair-state", Feature: "demo",
- })
- if cmd == nil || cmd.Verb != "repair-state" {
- t.Fatalf("repair-state route must be prescribed: %+v", cmd)
- }
- if !strings.Contains(followUp, "flow bootstrap --feature demo") {
- t.Fatalf("repair follow-up must name flow bootstrap for the feature: %q", followUp)
- }
- })
-
- t.Run("unknown_and_ambiguous_prescribe_nothing", func(t *testing.T) {
- for _, stage := range []string{"AMBIGUOUS", "SOMETHING_NEW", ""} {
- if cmd, followUp := prescribePlanning(".", NextStatus{ObservedStage: stage}); cmd != nil || followUp != "" {
- t.Fatalf("stage %q must prescribe nothing: %+v %q", stage, cmd, followUp)
- }
- }
- })
-
- t.Run("ambiguous_render_keeps_operation_fallback", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "plan-one")
- writeSavedFeaturePlan(t, repo, "plan-two")
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if next.Resolved || next.Prescribed != nil {
- t.Fatalf("AMBIGUOUS must stay unresolved and unprescribed: %+v", next)
- }
- out := FormatFlowNext(next)
- if !strings.Contains(out, "follow the recommended operation above") {
- t.Fatalf("ambiguous rendering must route to the recommended operation: %q", out)
- }
- if strings.Contains(out, "Run: ") {
- t.Fatalf("ambiguous rendering must not fabricate a command: %q", out)
- }
- })
-}
diff --git a/boatstack/flow_prescribe_conformance_test.go b/boatstack/flow_prescribe_conformance_test.go
deleted file mode 100644
index 7c5dfc8..0000000
--- a/boatstack/flow_prescribe_conformance_test.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package boatstack
-
-import (
- "strings"
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: prescribed-command-is-legal-lowest-cost-or-none
-//
-// `flow next` now emits the exact runnable command for the oracle's lowest-cost
-// next move. The contract these tests hold: the prescribed command is always the
-// registry CLIVerb of the oracle's chosen (and therefore legal) transition; it
-// never fabricates human-owed inputs (evidence, gate status, preview fingerprint,
-// reviewer identity); and when the flow position cannot be assembled faithfully it
-// prescribes NOTHING rather than a guess.
-
-// forwardStates pairs each resolvable slice-lifecycle state with the transition
-// the oracle takes toward the published goal from it.
-var forwardStates = []struct {
- state deliverycontrol.StateID
- transition deliverycontrol.TransitionID
- verb string
-}{
- {deliverycontrol.StateBuild, "delivery.record_gate_test", "record-delivery-gate"},
- {deliverycontrol.StateTestPassed, "delivery.record_gate_review", "record-delivery-gate"},
- {deliverycontrol.StateReviewPassed, "delivery.publish", "publish-pr"},
-}
-
-func syntheticStatus() NextStatus {
- return NextStatus{ActiveSlice: "slice-a", Feature: "demo"}
-}
-
-// Positive: from REVIEW_PASSED the prescribed command is publish-pr for the
-// delivery.publish transition, carrying the derivable preview/action and owing
-// only the human-confirmed fingerprint.
-func TestPrescribePublishFromReviewPassed(t *testing.T) {
- cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), PublishTransition)
- if !ok {
- t.Fatal("expected a prescribed command for delivery.publish")
- }
- if cmd.Verb != "publish-pr" || cmd.Transition != PublishTransition {
- t.Errorf("verb/transition = %s/%s, want publish-pr/delivery.publish", cmd.Verb, cmd.Transition)
- }
- if cmd.AutoDerivable {
- t.Error("publish must not be auto-derivable: it owes a human-confirmed fingerprint")
- }
- if !contains(cmd.RequiresHumanInput, "--preview-fingerprint") {
- t.Errorf("publish must require --preview-fingerprint; got %v", cmd.RequiresHumanInput)
- }
- if !contains(cmd.Args, "--preview") || !contains(cmd.Args, "--action") {
- t.Errorf("publish should derive --preview and --action; got %v", cmd.Args)
- }
-}
-
-// Positive/replay: an already-validated PR-target receipt supplies the exact
-// action, preview fingerprint, and receipt path. No new authority is fabricated.
-func TestPrescribePublishBecomesDerivableOnlyWithExactPRAuthority(t *testing.T) {
- original := deriveAutonomousPRPublish
- t.Cleanup(func() { deriveAutonomousPRPublish = original })
- deriveAutonomousPRPublish = func(repo, feature, preview string) (string, string, string, bool) {
- return "update", strings.Repeat("a", 64), "/repo/.product-loop/features/demo/autonomy.md", true
- }
- cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), PublishTransition)
- if !ok || !cmd.AutoDerivable || len(cmd.RequiresHumanInput) != 0 {
- t.Fatalf("valid PR authority did not produce a derivable command: %#v", cmd)
- }
- for _, expected := range []string{"--action", "update", "--preview-fingerprint", strings.Repeat("a", 64), "--autonomy", "/repo/.product-loop/features/demo/autonomy.md"} {
- if !contains(cmd.Args, expected) {
- t.Fatalf("authorized command omitted %q: %v", expected, cmd.Args)
- }
- }
- if !canAutoDrive(cmd, autoDrivableTransitions) {
- t.Fatal("exact receipt-bound publish should be executable")
- }
-
- deriveAutonomousPRPublish = func(repo, feature, preview string) (string, string, string, bool) {
- return "", "", "", false
- }
- stale, _ := prescribeCommand("/repo", "demo", syntheticStatus(), PublishTransition)
- if stale.AutoDerivable || !contains(stale.RequiresHumanInput, "--preview-fingerprint") {
- t.Fatalf("stale or mismatched authority bypassed the stop: %#v", stale)
- }
-}
-
-// Relation: the emitted verb is exactly the registry CLIVerb of the transition,
-// and the transition prescribed for each state is exactly the oracle's lowest-cost
-// next edge from that state. Prescription can never diverge from the registry or
-// from the oracle.
-func TestPrescribedVerbMatchesRegistryAndOracle(t *testing.T) {
- graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights())
- for _, fs := range forwardStates {
- advice := graph.Advise(fs.state, flowGoal)
- if advice.Resolution != deliverycontrol.Resolved {
- t.Fatalf("oracle could not resolve from %s", fs.state)
- }
- if advice.NextTransition != fs.transition {
- t.Errorf("oracle next from %s = %s, want %s", fs.state, advice.NextTransition, fs.transition)
- }
- cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), advice.NextTransition)
- if !ok {
- t.Fatalf("no prescription for oracle edge %s", advice.NextTransition)
- }
- desc, _ := deliverycontrol.Transition(advice.NextTransition)
- if cmd.Verb != desc.CLIVerb {
- t.Errorf("prescribed verb %q != registry CLIVerb %q for %s", cmd.Verb, desc.CLIVerb, advice.NextTransition)
- }
- if cmd.Verb != fs.verb {
- t.Errorf("prescribed verb %q != expected %q for %s", cmd.Verb, fs.verb, fs.state)
- }
- }
-}
-
-// Negative: the prescribed transition is always a LEGAL move from the state it is
-// prescribed at — never a friction/illegal move the real machine would reject.
-func TestPrescribedMoveIsLegalFromItsState(t *testing.T) {
- graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights())
- for _, fs := range forwardStates {
- if !graph.IsLegalMove(fs.state, fs.transition) {
- t.Errorf("prescribed transition %s is not legal from %s (would be friction)", fs.transition, fs.state)
- }
- }
-}
-
-// Bypass: no human-owed flag is ever placed in Args (never fabricated); the gate
-// commands owe evidence + status, and the review gate additionally owes reviewer
-// identity/method. The auto-derivable flag is true iff nothing is owed.
-func TestPrescribeNeverFabricatesHumanInput(t *testing.T) {
- for _, fs := range forwardStates {
- cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), fs.transition)
- if !ok {
- t.Fatalf("no prescription for %s", fs.transition)
- }
- for _, owed := range cmd.RequiresHumanInput {
- if contains(cmd.Args, owed) {
- t.Errorf("%s: human-owed flag %q leaked into auto-derived Args %v", fs.transition, owed, cmd.Args)
- }
- }
- if cmd.AutoDerivable != (len(cmd.RequiresHumanInput) == 0) {
- t.Errorf("%s: AutoDerivable=%t but RequiresHumanInput=%v", fs.transition, cmd.AutoDerivable, cmd.RequiresHumanInput)
- }
- // None of the forward gate/publish moves are auto-drivable today: each owes
- // evidence, status, or a fingerprint that must not be fabricated.
- if cmd.AutoDerivable {
- t.Errorf("%s: no forward gate/publish move should be auto-derivable (all owe human input)", fs.transition)
- }
- }
- // The CommandLine rendering surfaces owed inputs as explicit
- // placeholders, so it is never a fabricated, runnable-as-is command.
- cmd, _ := prescribeCommand(".", "demo", syntheticStatus(), PublishTransition)
- if !strings.Contains(cmd.CommandLine(), "--preview-fingerprint ") {
- t.Errorf("CommandLine must mark owed input as ; got %q", cmd.CommandLine())
- }
-}
-
-// Failure-state: a transition that is not a faithfully-assemblable move
-// prescribes nothing — the caller emits no command rather than a guess.
-// Observation rows are assembled by prescribeObserve, never here; unknown
-// transitions are never assembled anywhere. (delivery.undo, record_change, and
-// discard_delivery ARE assemblable since the solution set enumerates every
-// legal edge; their fidelity is held by the closure conformance sweeps.)
-func TestPrescribeEmitsNothingForUnassemblableTransition(t *testing.T) {
- for _, id := range []deliverycontrol.TransitionID{
- "delivery.status", "delivery.recovery_status", "not.a.transition",
- } {
- if cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), id); ok {
- t.Errorf("transition %s should not be prescribed as a forward move; got %+v", id, cmd)
- }
- }
- // The rework/recovery edges owe exactly their human facts, never fabricated.
- for id, owed := range map[deliverycontrol.TransitionID][]string{
- "delivery.record_change": {"--message", "--source-stage", "--classification", "--mechanism"},
- "delivery.undo": {"--mutation"},
- "delivery.discard_delivery": nil,
- } {
- cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), id)
- if !ok {
- t.Fatalf("edge %s must be assemblable for the solution set", id)
- }
- if len(cmd.RequiresHumanInput) != len(owed) {
- t.Errorf("%s owes %v, got %v", id, owed, cmd.RequiresHumanInput)
- continue
- }
- for i, flag := range owed {
- if cmd.RequiresHumanInput[i] != flag {
- t.Errorf("%s owes %v, got %v", id, owed, cmd.RequiresHumanInput)
- }
- }
- }
-}
diff --git a/boatstack/flow_report.go b/boatstack/flow_report.go
deleted file mode 100644
index d92c127..0000000
--- a/boatstack/flow_report.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "sort"
- "strings"
- "time"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// FlowReport reads the session's shadow trajectory and coding-effort logs for a
-// repo and measures them against the oracle toward a published delivery. It is
-// read-only and best-effort on the logs — a missing log is an empty session, not
-// an error — so the report is safe to request at any time. The result keeps
-// J_flow, its oracle baseline J_flow*, the regret between them, and J_coding as
-// independent figures; regret is derived purely from flow navigation.
-func FlowReport(repo string) (deliverycontrol.FlowTrajectoryReport, error) {
- return FlowReportFor(repo, "")
-}
-
-// FlowReportFor scopes command telemetry to one feature when provided. The
-// delivery trajectory remains the per-worktree walk because its legacy records
-// predate feature correlation; command evidence is never silently attributed.
-func FlowReportFor(repo, feature string) (deliverycontrol.FlowTrajectoryReport, error) {
- dir, err := flowLogDirectory(repo)
- if err != nil {
- return deliverycontrol.FlowTrajectoryReport{}, err
- }
- trajectory, err := deliverycontrol.ReadTrajectory(dir)
- if err != nil {
- return deliverycontrol.FlowTrajectoryReport{}, err
- }
- signals, err := deliverycontrol.ReadCodingSignals(dir)
- if err != nil {
- return deliverycontrol.FlowTrajectoryReport{}, err
- }
- weights := deliverycontrol.DefaultFlowCostWeights()
- graph := deliverycontrol.RegistryGraph(weights)
- report := deliverycontrol.ComputeReportWithCoding(trajectory, graph, weights, flowGoal, signals)
- report.Feature = strings.TrimSpace(feature)
- events, err := deliverycontrol.ReadCommandEvents(dir)
- if err != nil {
- return deliverycontrol.FlowTrajectoryReport{}, err
- }
- filtered := make([]deliverycontrol.CommandEvent, 0, len(events))
- for _, event := range events {
- if report.Feature == "" || event.Feature == report.Feature {
- filtered = append(filtered, event)
- }
- }
- applyCommandReport(&report, filtered)
- return report, nil
-}
-
-func applyCommandReport(report *deliverycontrol.FlowTrajectoryReport, events []deliverycontrol.CommandEvent) {
- report.CommandCoverageStatus = "NO_EVENTS"
- if len(events) == 0 {
- return
- }
- report.CommandCoverageStatus = "SCOPED_COMPLETE"
- report.CommandEvents = len(events)
- report.CommandFailureByCategory = map[string]int{}
- var first, last time.Time
- for _, event := range events {
- report.ObservedCommandMS += event.DurationMS
- started, _ := time.Parse(time.RFC3339Nano, event.StartedAt)
- finished, _ := time.Parse(time.RFC3339Nano, event.FinishedAt)
- if first.IsZero() || started.Before(first) {
- first = started
- }
- if last.IsZero() || finished.After(last) {
- last = finished
- }
- if event.ExitCode != 0 {
- report.CommandFailures++
- report.CommandFailureByCategory[event.Category]++
- }
- }
- if report.CommandFailures == 0 {
- report.CommandFailureByCategory = nil
- }
- report.FirstCommandAt = first.UTC().Format(time.RFC3339Nano)
- report.LastCommandAt = last.UTC().Format(time.RFC3339Nano)
- report.CommandWallSpanMS = last.Sub(first).Milliseconds()
-}
-
-// FormatFlowReport renders a session flow report as human-facing lines. When the
-// oracle cannot place the session's start against the goal the regret line is
-// withheld rather than fabricated, and coding effort is always shown as a
-// separate figure so it is never read as part of the flow regret.
-func FormatFlowReport(report deliverycontrol.FlowTrajectoryReport) string {
- var b strings.Builder
- fmt.Fprintf(&b, "Flow report: %d steps, start %s -> goal %s\n", report.Steps, startLabel(report.Start), report.Goal)
- if report.Resolution == deliverycontrol.Resolved {
- fmt.Fprintf(&b, "J_flow=%d J_flow*=%d regret=%d\n", report.JFlow, report.JFlowStar, report.Regret)
- } else {
- fmt.Fprintf(&b, "J_flow=%d regret=unresolved (no oracle baseline for this start)\n", report.JFlow)
- }
- fmt.Fprintf(&b, "J_coding=%d (telemetry, separate from flow)\n", report.JCoding)
- fmt.Fprintf(&b, "command_coverage=%s events=%d failures=%d observed_ms=%d wall_span_ms=%d\n", report.CommandCoverageStatus, report.CommandEvents, report.CommandFailures, report.ObservedCommandMS, report.CommandWallSpanMS)
- if report.Feature != "" {
- fmt.Fprintf(&b, "feature=%s\n", report.Feature)
- }
- if len(report.PositiveGapByCategory) > 0 {
- categories := make([]string, 0, len(report.PositiveGapByCategory))
- for category := range report.PositiveGapByCategory {
- categories = append(categories, category)
- }
- sort.Strings(categories)
- for _, category := range categories {
- fmt.Fprintf(&b, "positive_gap[%s]=%d\n", category, report.PositiveGapByCategory[category])
- }
- }
- if len(report.CommandFailureByCategory) > 0 {
- categories := make([]string, 0, len(report.CommandFailureByCategory))
- for category := range report.CommandFailureByCategory {
- categories = append(categories, category)
- }
- sort.Strings(categories)
- for _, category := range categories {
- fmt.Fprintf(&b, "command_failure[%s]=%d\n", category, report.CommandFailureByCategory[category])
- }
- }
- return b.String()
-}
-
-func startLabel(start deliverycontrol.StateID) string {
- if start == "" {
- return "(none)"
- }
- return string(start)
-}
diff --git a/boatstack/flow_report_test.go b/boatstack/flow_report_test.go
deleted file mode 100644
index c190b79..0000000
--- a/boatstack/flow_report_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package boatstack
-
-import (
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// A recorded session with one friction attempt followed by the productive path
-// reports the observed cost, the oracle baseline, the flow regret between them,
-// and coding effort as a separate figure.
-func TestFlowReportMeasuresSession(t *testing.T) {
- repo := prTestRepo(t)
-
- // Friction: publish attempted (and denied) from BUILD -> billed at 3.
- RecordFlowTransition(repo, PublishTransition, deliverycontrol.StateBuild, false)
- // Then the productive path, each move billed at 1.
- RecordFlowTransition(repo, GateTransition("test"), deliverycontrol.StateBuild, true)
- RecordFlowTransition(repo, GateTransition("review"), deliverycontrol.StateTestPassed, true)
- RecordFlowTransition(repo, PublishTransition, deliverycontrol.StateReviewPassed, true)
-
- RecordCodingEffort(repo, 2, "repair")
- RecordCodingEffort(repo, 0, "amend") // bare marker -> one unit
-
- report, err := FlowReport(repo)
- if err != nil {
- t.Fatal(err)
- }
- if report.Steps != 4 {
- t.Errorf("steps = %d, want 4", report.Steps)
- }
- if report.JFlow != 6 { // 3 + 1 + 1 + 1
- t.Errorf("J_flow = %d, want 6", report.JFlow)
- }
- if report.Resolution != deliverycontrol.Resolved || report.JFlowStar != 3 || report.Regret != 3 {
- t.Errorf("oracle baseline wrong: %+v (want J_flow*=3, regret=3)", report)
- }
- if report.JCoding != 3 { // 2 + 1, never folded into regret
- t.Errorf("J_coding = %d, want 3", report.JCoding)
- }
-}
-
-// An empty session is well-defined: zero steps, no fabricated oracle baseline.
-func TestFlowReportEmptySession(t *testing.T) {
- repo := prTestRepo(t)
-
- report, err := FlowReport(repo)
- if err != nil {
- t.Fatal(err)
- }
- if report.Steps != 0 || report.JFlow != 0 || report.JCoding != 0 {
- t.Errorf("empty session should be all zero: %+v", report)
- }
- if report.Resolution != deliverycontrol.Unresolved {
- t.Errorf("empty session has no start, so the oracle must be unresolved; got %s", report.Resolution)
- }
-}
-
-// control-law: positive-flow-gaps-are-attributable-by-control-class
-func TestFlowReportAttributesPositiveGapCategories(t *testing.T) {
- repo := prTestRepo(t)
- RecordFlowTransition(repo, PublishTransition, deliverycontrol.StateBuild, false)
- RecordFlowAttribution(repo, "readiness", deliverycontrol.CostQuery, false, "blocked")
- RecordFlowAttribution(repo, "repair.review_repair", deliverycontrol.CostFriction, true, "duplicate")
- report, err := FlowReport(repo)
- if err != nil {
- t.Fatal(err)
- }
- if report.Regret <= 0 {
- t.Fatalf("fixture must produce positive regret: %+v", report)
- }
- if report.PositiveGapByCategory["readiness"] != 1 || report.PositiveGapByCategory["repair.review_repair"] != 3 {
- t.Fatalf("unexpected positive-gap attribution: %+v", report.PositiveGapByCategory)
- }
-}
diff --git a/boatstack/flow_solutions.go b/boatstack/flow_solutions.go
deleted file mode 100644
index 518320b..0000000
--- a/boatstack/flow_solutions.go
+++ /dev/null
@@ -1,307 +0,0 @@
-package boatstack
-
-import (
- "path/filepath"
- "sort"
- "strings"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// A law states what is admissible; a weak model cannot always derive a
-// compliant action from that statement. The solution set is the law compiled
-// into its concrete admissible actions, so a caller PICKS a legal move instead
-// of deriving one. It is computed from the same declarations the guard
-// enforces — the transition registry, the guard's stage-verb tables, the
-// planning prescription layer — never from a hand-written list, so it cannot
-// drift from the law (the oracle-as-advisor rule: the checker is exposed as a
-// constructive advisor at the authoring boundary, not only as a terminal gate).
-// control-law: solution-set-derives-from-guard-declarations
-
-// solutionSetCap bounds the structured payload; solutionSetTextCap bounds the
-// plain-text rendering so the response contract stays scannable.
-const (
- solutionSetCap = 8
- solutionSetTextCap = 3
-)
-
-// SolutionSet is the computed enumeration of admissible next commands from a
-// flow position. Options are ordered most-productive first: mutations by total
-// remaining flow cost through the move, then observations.
-type SolutionSet struct {
- Basis string `json:"basis"`
- Stage string `json:"stage,omitempty"`
- State deliverycontrol.StateID `json:"state,omitempty"`
- Options []PrescribedCommand `json:"options"`
- Truncated bool `json:"truncated,omitempty"`
-}
-
-// enumerateFlowSolutions computes the solution set for a flow position: the
-// delivery graph's out-edges when the oracle resolves the state, the guard's
-// pre-activation stage tables when it does not.
-func enumerateFlowSolutions(repo string, status NextStatus, next FlowNext) SolutionSet {
- if next.Resolved {
- return enumerateDeliverySolutions(repo, status, next.State)
- }
- return enumeratePlanningSolutions(repo, status, next)
-}
-
-// alternativesFor is the FlowNext carrier hook: the full solution set minus the
-// single Prescribed primary. Advisory only — it adds no verb the registry or
-// the guard tables do not already admit.
-func alternativesFor(repo string, status NextStatus, next FlowNext) []PrescribedCommand {
- set := enumerateFlowSolutions(repo, status, next)
- if next.Prescribed == nil {
- return set.Options
- }
- primary := prescriptionKey(*next.Prescribed)
- options := make([]PrescribedCommand, 0, len(set.Options))
- for _, option := range set.Options {
- if prescriptionKey(option) == primary {
- continue
- }
- options = append(options, option)
- }
- return options
-}
-
-// enumerateDeliverySolutions enumerates from a resolved delivery state: every
-// registry out-edge that can be assembled faithfully, ordered by the total
-// remaining cost through the edge (edge cost + shortest path from its target to
-// the goal, unreachable targets last, ties by transition ID), then the observe
-// rows admissible from this state. ignore-delivery is deliberately absent: it
-// is a policy filter over ResolveNext, not a move on the delivery walk.
-func enumerateDeliverySolutions(repo string, status NextStatus, state deliverycontrol.StateID) SolutionSet {
- set := SolutionSet{Basis: "flow-position", State: state}
- graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights())
-
- type scoredEdge struct {
- edge deliverycontrol.Edge
- total int
- reachable bool
- }
- edges := make([]scoredEdge, 0)
- for _, edge := range graph.Out(state) {
- scored := scoredEdge{edge: edge}
- if path := graph.ShortestFlow(edge.To, flowGoal); path.Resolution == deliverycontrol.Resolved {
- scored.reachable = true
- scored.total = edge.Cost + path.Cost
- }
- edges = append(edges, scored)
- }
- sort.SliceStable(edges, func(i, j int) bool {
- if edges[i].reachable != edges[j].reachable {
- return edges[i].reachable
- }
- if edges[i].total != edges[j].total {
- return edges[i].total < edges[j].total
- }
- return edges[i].edge.Transition < edges[j].edge.Transition
- })
- for _, scored := range edges {
- if cmd, ok := prescribeCommand(repo, status.Feature, status, scored.edge.Transition); ok {
- appendSolution(&set, *cmd)
- }
- }
-
- for _, descriptor := range deliverycontrol.Transitions() {
- if descriptor.To != "" {
- continue
- }
- if descriptor.CostClass != deliverycontrol.CostObserve && descriptor.CostClass != deliverycontrol.CostQuery {
- continue
- }
- if descriptor.From != nil && !transitionAccepts(descriptor, state) {
- continue
- }
- if cmd, ok := prescribeObserve(repo, status.Feature, descriptor); ok {
- appendSolution(&set, *cmd)
- }
- }
- return set
-}
-
-// enumeratePlanningSolutions enumerates from a pre-activation or blocked stage:
-// the stage's prescribed primary, the other mutation verbs the guard's
-// stageMutationVerbs table admits there, and the planning-relevant read-only
-// helpers. Every mutation verb comes from the SAME table the guard checks, so
-// this list is closed under guard admission by construction.
-func enumeratePlanningSolutions(repo string, status NextStatus, next FlowNext) SolutionSet {
- set := SolutionSet{Basis: "flow-position", Stage: status.ObservedStage}
- if next.Prescribed != nil {
- appendSolution(&set, *next.Prescribed)
- }
- for _, verb := range stageMutationVerbs[status.ObservedStage] {
- // workspace-cut gains authority from a successful plan check, not from
- // DRAFT_PLAN alone. Keep it out of the alternative set unless ResolveNext
- // selected that exact transition for the current validated package.
- if verb == "workspace-cut" && status.NextOperation != "workspace-cut" {
- continue
- }
- if cmd, ok := prescribePlanningVerb(repo, status, verb); ok {
- appendSolution(&set, *cmd)
- }
- }
- // The stage-independent recovery verbs relevant to a blocked plan, then the
- // position observers. Both are admitted at every stage by the guard tables.
- if status.ObservedStage == "INVALID_STATE" {
- if cmd, ok := prescribePlanningVerb(repo, status, "repair-state"); ok {
- appendSolution(&set, *cmd)
- }
- }
- if descriptor, ok := deliverycontrol.Transition(deliverycontrol.TransitionID("delivery.next")); ok {
- if cmd, ok := prescribeObserve(repo, "", descriptor); ok {
- appendSolution(&set, *cmd)
- }
- }
- appendSolution(&set, PrescribedCommand{
- Verb: "doctor", Args: repoFlagArgs(repo), AutoDerivable: true,
- Transition: MarkerRecoveryDoctor,
- })
- return set
-}
-
-// prescribePlanningVerb assembles the faithful command for one admissible
-// pre-activation verb. Shapes shared with prescribePlanning go through the
-// same builders; anything it cannot assemble faithfully is omitted, never
-// guessed.
-func prescribePlanningVerb(repo string, status NextStatus, verb string) (*PrescribedCommand, bool) {
- repoArgs := repoFlagArgs(repo)
- featureDir := planningFeatureDir(repo, status.Feature)
- var cmd *PrescribedCommand
- switch verb {
- case "planning-write":
- cmd = &PrescribedCommand{Verb: verb, Args: repoArgs, Transition: MarkerPlanningWrite}
- if status.Feature != "" {
- cmd.Args = append(cmd.Args, "--feature", status.Feature)
- } else {
- cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, "--feature")
- }
- // The artifact name and its Markdown (stdin) are authored content — owed.
- cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, "--artifact")
- cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, planningMarkdownInput)
- case "record-approval":
- if status.Feature == "" {
- return nil, false
- }
- cmd = &PrescribedCommand{
- Verb: verb,
- Args: []string{"--plan", filepath.Join(featureDir, "plan.md")},
- // Approval facts are a human act; never fabricated.
- RequiresHumanInput: []string{"--approved-by", "--approved-at", "--fingerprint"},
- Transition: MarkerPlanningApproval,
- }
- case "activate-plan":
- if status.Feature == "" {
- return nil, false
- }
- cmd = buildActivatePlan(featureDir, status.ObservedStage)
- case "workspace-cut":
- if status.Feature == "" {
- return nil, false
- }
- cmd = buildWorkspaceCut(repoArgs, status.Feature)
- case "repair-state":
- cmd = &PrescribedCommand{Verb: verb, Args: repoArgs, Transition: MarkerRecoveryRepair}
- if status.Feature != "" {
- cmd.Args = append(cmd.Args, "--feature", status.Feature)
- }
- default:
- return nil, false
- }
- cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0
- return cmd, true
-}
-
-// prescribeObserve assembles a read-only observation command for a registry row
-// with no target state. Observations never outrank a mutation in the pick list,
-// but they must be present — inspecting is the legal move that costs nothing
-// when the next mutation is not yet clear.
-func prescribeObserve(repo, feature string, descriptor deliverycontrol.TransitionDescriptor) (*PrescribedCommand, bool) {
- if descriptor.CLIVerb == "" {
- return nil, false
- }
- cmd := &PrescribedCommand{Verb: descriptor.CLIVerb, Transition: descriptor.ID, AutoDerivable: true}
- cmd.Args = repoFlagArgs(repo)
- switch descriptor.ID {
- case deliverycontrol.TransitionID("delivery.status"), deliverycontrol.TransitionID("delivery.check_ship"):
- if feature == "" {
- return nil, false
- }
- cmd.Args = append(cmd.Args, "--feature", feature)
- case deliverycontrol.TransitionID("delivery.next"), deliverycontrol.TransitionID("delivery.recovery_status"):
- if feature != "" {
- cmd.Args = append(cmd.Args, "--feature", feature)
- }
- default:
- return nil, false
- }
- return cmd, true
-}
-
-// appendSolution adds an option, deduplicating on the rendered verb+args
-// identity and enforcing the structured cap.
-func appendSolution(set *SolutionSet, option PrescribedCommand) {
- key := prescriptionKey(option)
- for _, existing := range set.Options {
- if prescriptionKey(existing) == key {
- return
- }
- }
- if len(set.Options) >= solutionSetCap {
- set.Truncated = true
- return
- }
- set.Options = append(set.Options, option)
-}
-
-// prescriptionKey is the dedup identity of a prescribed command.
-func prescriptionKey(p PrescribedCommand) string {
- return p.Verb + "\x00" + strings.Join(p.Args, "\x00")
-}
-
-// repoFlagArgs mirrors the prescription layer's convention: --repo appears only
-// when it differs from the default working directory.
-func repoFlagArgs(repo string) []string {
- if repo != "" && repo != "." {
- return []string{"--repo", repo}
- }
- return nil
-}
-
-// transitionAccepts reports whether a registry row's From set contains a state.
-func transitionAccepts(descriptor deliverycontrol.TransitionDescriptor, state deliverycontrol.StateID) bool {
- for _, from := range descriptor.From {
- if from == state {
- return true
- }
- }
- return false
-}
-
-// solutionGloss names a move's purpose in one STE word or two, keyed by the
-// transition family — used only in the compact text rendering.
-func solutionGloss(transition deliverycontrol.TransitionID) string {
- switch transition {
- case deliverycontrol.TransitionID("delivery.record_gate_test"):
- return "record test gate"
- case deliverycontrol.TransitionID("delivery.record_gate_review"):
- return "record review gate"
- case deliverycontrol.TransitionID("delivery.record_change"):
- return "rework"
- case deliverycontrol.TransitionID("delivery.publish"):
- return "publish"
- case deliverycontrol.TransitionID("delivery.undo"):
- return "reverse"
- case deliverycontrol.TransitionID("delivery.discard_delivery"), MarkerRecoveryDiscard:
- return "abandon"
- case deliverycontrol.TransitionID("delivery.repair_state"), MarkerRecoveryRepair:
- return "repair"
- case MarkerRecoveryDoctor:
- return "diagnose"
- }
- if strings.HasPrefix(string(transition), "planning.") {
- return "plan"
- }
- return "inspect"
-}
diff --git a/boatstack/flow_tasks.go b/boatstack/flow_tasks.go
deleted file mode 100644
index cfd35b8..0000000
--- a/boatstack/flow_tasks.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-// FlowTask is one read-only sub-action from the compiled plan's task DAG, scoped
-// to a delivery slice. It carries only what is needed to order and name the work;
-// it holds no completion state.
-type FlowTask struct {
- ID string `json:"id"`
- Title string `json:"title,omitempty"`
- DependsOn []string `json:"depends_on,omitempty"`
-}
-
-// FlowTasks is the read-only ordering of the active slice's sub-actions. It adds
-// no state and tracks no completion: it orders the plan's tasks by their DAG,
-// scoped to the active slice, and points at the one to start. The agent decides
-// done-ness. Resolved is false when the flow position or the compiled task graph
-// cannot be read faithfully, in which case nothing is ordered or pointed at
-// (never a guessed sub-action).
-type FlowTasks struct {
- Resolved bool `json:"resolved"`
- Feature string `json:"feature,omitempty"`
- Slice string `json:"slice,omitempty"`
- SliceStatus string `json:"slice_status,omitempty"`
- Ordered []FlowTask `json:"ordered,omitempty"`
- StartHere string `json:"start_here,omitempty"`
- Reason string `json:"reason"`
-}
-
-// FlowTasksForActiveSlice reads the compiled task DAG for a feature, scopes it to
-// the active delivery slice, and returns the sub-actions in dependency order. It
-// is read-only and best-effort on the inputs: a missing delivery state, a fully
-// published delivery, or an unreadable task graph resolves to an Unresolved result
-// with a reason rather than an error, so it is safe to request at any time.
-func FlowTasksForActiveSlice(repo, feature string) (FlowTasks, error) {
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return FlowTasks{Reason: "no readable delivery state for this feature"}, nil
- }
- slice, err := activeDeliverySlice(state)
- if err != nil {
- return FlowTasks{Feature: feature, Reason: err.Error()}, nil
- }
- out := FlowTasks{Feature: feature, Slice: slice.ID, SliceStatus: slice.Status}
- if len(slice.TaskIDs) == 0 {
- out.Reason = "active slice declares no task ids to order"
- return out, nil
- }
- allTasks, ok := readCompiledTasks(repo, feature)
- if !ok {
- out.Reason = "no readable compiled task graph (compiled/tasks.json)"
- return out, nil
- }
- ordered := orderSliceTasks(slice.TaskIDs, allTasks)
- if len(ordered) == 0 {
- out.Reason = "active slice task ids resolve to no compiled tasks"
- return out, nil
- }
- out.Ordered = ordered
- out.StartHere = ordered[0].ID
- out.Resolved = true
- out.Reason = fmt.Sprintf("%d sub-action(s) for slice %s in dependency order", len(ordered), slice.ID)
- return out, nil
-}
-
-// readCompiledTasks loads the tasks array from the feature's compiled task graph
-// through the shared dual-layout artifact resolver. The boolean is false whenever
-// the graph is absent or malformed, so the caller stays Unresolved rather than
-// ordering a guess.
-func readCompiledTasks(repo, feature string) ([]FlowTask, bool) {
- directory := WorkspaceFor(repo).FeatureDir(feature)
- tasksPath := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json")
- raw, err := os.ReadFile(tasksPath)
- if err != nil {
- return nil, false
- }
- var graph struct {
- Tasks []struct {
- ID string `json:"id"`
- Title string `json:"title"`
- DependsOn []string `json:"depends_on"`
- } `json:"tasks"`
- }
- if err := json.Unmarshal(raw, &graph); err != nil {
- return nil, false
- }
- tasks := make([]FlowTask, 0, len(graph.Tasks))
- for _, t := range graph.Tasks {
- if strings.TrimSpace(t.ID) == "" {
- continue
- }
- tasks = append(tasks, FlowTask{ID: t.ID, Title: t.Title, DependsOn: t.DependsOn})
- }
- return tasks, true
-}
-
-// orderSliceTasks scopes the plan's tasks to the active slice's task ids and
-// returns them in dependency order. Ordering is a Kahn topological sort over the
-// intra-scope depends_on edges only — a dependency on an earlier slice's task is
-// treated as already satisfied, and a dependency outside the scope never pulls a
-// foreign task in. Ties are broken by the tasks' order in the compiled plan, so
-// the result is deterministic and plan-faithful. The compile step already
-// guarantees the DAG is acyclic; if a cycle somehow remained, the unresolved
-// remainder is appended in plan order rather than dropped.
-func orderSliceTasks(sliceTaskIDs []string, allTasks []FlowTask) []FlowTask {
- scope := map[string]bool{}
- for _, id := range sliceTaskIDs {
- scope[id] = true
- }
-
- // Scoped tasks in plan order, with dependencies restricted to the scope.
- var scoped []FlowTask
- planIndex := map[string]int{}
- indegree := map[string]int{}
- dependents := map[string][]string{}
- for _, task := range allTasks {
- if !scope[task.ID] {
- continue
- }
- intra := make([]string, 0, len(task.DependsOn))
- for _, dep := range task.DependsOn {
- if scope[dep] {
- intra = append(intra, dep)
- }
- }
- planIndex[task.ID] = len(scoped)
- indegree[task.ID] = len(intra)
- scoped = append(scoped, FlowTask{ID: task.ID, Title: task.Title, DependsOn: intra})
- for _, dep := range intra {
- dependents[dep] = append(dependents[dep], task.ID)
- }
- }
-
- emitted := map[string]bool{}
- var ordered []FlowTask
- for len(ordered) < len(scoped) {
- // Among not-yet-emitted tasks with no unmet intra-scope dependency, pick the
- // earliest in plan order.
- next := -1
- for _, task := range scoped {
- if emitted[task.ID] || indegree[task.ID] != 0 {
- continue
- }
- if next == -1 || planIndex[task.ID] < planIndex[scoped[next].ID] {
- next = planIndex[task.ID]
- }
- }
- if next == -1 {
- // Defensive: a residual cycle (should be impossible post-compile). Append
- // the remaining tasks in plan order rather than silently dropping them.
- for _, task := range scoped {
- if !emitted[task.ID] {
- ordered = append(ordered, task)
- emitted[task.ID] = true
- }
- }
- break
- }
- chosen := scoped[next]
- ordered = append(ordered, chosen)
- emitted[chosen.ID] = true
- for _, dependent := range dependents[chosen.ID] {
- indegree[dependent]--
- }
- }
- return ordered
-}
-
-// FormatFlowTasks renders the scoped, ordered sub-actions as human-facing lines.
-// An unresolved result prints its reason rather than any task, so it is never
-// mistaken for an empty plan.
-func FormatFlowTasks(tasks FlowTasks) string {
- var b strings.Builder
- if !tasks.Resolved {
- fmt.Fprintf(&b, "Flow tasks: unresolved (%s)\n", tasks.Reason)
- return b.String()
- }
- fmt.Fprintf(&b, "Sub-actions for slice %s (%s), in dependency order:\n", tasks.Slice, tasks.SliceStatus)
- for i, task := range tasks.Ordered {
- marker := " "
- if task.ID == tasks.StartHere {
- marker = "->"
- }
- title := task.Title
- if title != "" {
- title = " — " + title
- }
- fmt.Fprintf(&b, "%s %d. %s%s\n", marker, i+1, task.ID, title)
- }
- fmt.Fprintf(&b, "Start here: %s\n", tasks.StartHere)
- return b.String()
-}
diff --git a/boatstack/flow_tasks_conformance_test.go b/boatstack/flow_tasks_conformance_test.go
deleted file mode 100644
index 2a96c53..0000000
--- a/boatstack/flow_tasks_conformance_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package boatstack
-
-import "testing"
-
-// control-law: sub-action-respects-plan-dag-and-slice-scope
-//
-// `flow tasks` (and the sub_action hint on `flow next`) orders the active slice's
-// sub-actions from the compiled plan DAG. The contract: the ordering is a valid
-// topological order of the intra-slice depends_on edges (Positive); it never
-// surfaces a task outside the active slice, nor pulls in a foreign dependency
-// (Relation/Bypass); the pointed-at "start here" task has no unmet intra-slice
-// dependency (Negative); and a missing DAG or position resolves to nothing, never
-// a guessed sub-action (Failure-state).
-
-// A representative plan: two slices, an intentionally out-of-plan-order pair, a
-// cross-slice dependency, and a dependency on an earlier slice's task.
-func sampleTasks() []FlowTask {
- return []FlowTask{
- {ID: "a2", DependsOn: []string{"a1"}}, // listed before its dependency
- {ID: "a1", DependsOn: nil}, // root of slice A
- {ID: "b1", DependsOn: []string{"a2"}}, // slice B — later slice
- {ID: "a3", DependsOn: []string{"x0", "a1"}}, // x0 is an earlier slice's (out-of-scope) task
- }
-}
-
-var sliceAScope = []string{"a1", "a2", "a3"}
-
-// assertTopoValid checks that every intra-scope dependency of each task appears
-// before it in the ordering.
-func assertTopoValid(t *testing.T, ordered []FlowTask) {
- t.Helper()
- position := map[string]int{}
- for i, task := range ordered {
- position[task.ID] = i
- }
- for i, task := range ordered {
- for _, dep := range task.DependsOn {
- depPos, ok := position[dep]
- if !ok {
- t.Errorf("task %s retains out-of-scope dependency %s", task.ID, dep)
- continue
- }
- if depPos >= i {
- t.Errorf("task %s at %d precedes its dependency %s at %d", task.ID, i, dep, depPos)
- }
- }
- }
-}
-
-// Positive: the ordering is a valid topological order and starts at the slice's
-// dependency root, even though the plan listed a2 before a1.
-func TestOrderSliceTasksIsTopological(t *testing.T) {
- ordered := orderSliceTasks(sliceAScope, sampleTasks())
- if len(ordered) != 3 {
- t.Fatalf("expected 3 scoped tasks, got %d: %+v", len(ordered), ordered)
- }
- if ordered[0].ID != "a1" {
- t.Errorf("start-here should be the dependency root a1, got %s", ordered[0].ID)
- }
- assertTopoValid(t, ordered)
-}
-
-// Relation + Bypass: every ordered task is in the active slice's scope; the
-// later-slice task b1 never appears, and the out-of-scope dependency x0 is dropped
-// rather than pulling a foreign task into the ordering.
-func TestOrderSliceTasksStaysInScope(t *testing.T) {
- scope := map[string]bool{}
- for _, id := range sliceAScope {
- scope[id] = true
- }
- ordered := orderSliceTasks(sliceAScope, sampleTasks())
- for _, task := range ordered {
- if !scope[task.ID] {
- t.Errorf("ordered task %s is outside the active slice scope", task.ID)
- }
- if task.ID == "b1" {
- t.Error("later-slice task b1 must never be ordered for slice A")
- }
- for _, dep := range task.DependsOn {
- if !scope[dep] {
- t.Errorf("task %s kept out-of-scope dependency %s", task.ID, dep)
- }
- }
- }
-}
-
-// Negative: the pointed-at start task has no unmet intra-scope dependency (it is a
-// legal place to begin), and no later-slice task is ever the start.
-func TestOrderSliceTasksStartIsBeginnable(t *testing.T) {
- ordered := orderSliceTasks(sliceAScope, sampleTasks())
- if len(ordered) == 0 {
- t.Fatal("expected a non-empty ordering")
- }
- start := ordered[0]
- if len(start.DependsOn) != 0 {
- t.Errorf("start-here task %s still has unmet intra-scope dependencies %v", start.ID, start.DependsOn)
- }
-}
-
-// Failure-state: with no delivery state and no compiled task graph, the reader
-// resolves to nothing — never a fabricated ordering.
-func TestFlowTasksUnresolvedWithoutDAG(t *testing.T) {
- tasks, err := FlowTasksForActiveSlice(t.TempDir(), "demo")
- if err != nil {
- t.Fatalf("read-only reader must not error on a missing feature: %v", err)
- }
- if tasks.Resolved || len(tasks.Ordered) != 0 || tasks.StartHere != "" {
- t.Errorf("missing DAG must be unresolved with no tasks; got %+v", tasks)
- }
- if tasks.Reason == "" {
- t.Error("an unresolved result must carry a reason")
- }
-}
diff --git a/boatstack/flow_trace.go b/boatstack/flow_trace.go
deleted file mode 100644
index 5630384..0000000
--- a/boatstack/flow_trace.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package boatstack
-
-import (
- "os"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// flowTraceKillSwitch disables shadow trajectory recording when set to "0".
-// Recording is best-effort and off the critical path either way; the switch
-// exists so an operator can silence it entirely without a rebuild.
-const flowTraceKillSwitch = "BOATSTACK_FLOW_TRACE"
-
-// flowLogDirectory is the append-only trajectory log location,
-// /boatstack/flow, a sibling of the delivery-state directory. It reuses
-// deliveryStateDirectory's Git-dir resolution so the two stay in lockstep.
-func flowLogDirectory(repo string) (string, error) {
- return WorkspaceFor(repo).FlowDir()
-}
-
-// RecordFlowTransition appends a best-effort shadow record of one delivery-flow
-// control attempt. It is deliberately inert with respect to command behavior:
-// it never returns an error, never panics into a caller, and writes nothing when
-// disabled or when anything goes wrong. Nothing consumes the log at runtime yet
-// — this is the measurement substrate for the flow-navigation meter, not a
-// control point.
-//
-// transition is the registry TransitionID being attempted; from is the
-// delivery-flow state the attempt started in ("" if unknown); ok reports whether
-// the underlying handler succeeded (a false ok on a mutation is billed as
-// friction, per the cmg model).
-func RecordFlowTransition(repo string, transition deliverycontrol.TransitionID, from deliverycontrol.StateID, ok bool) {
- // A trace must never take down a command. Swallow any panic from the
- // best-effort path.
- defer func() { _ = recover() }()
-
- if os.Getenv(flowTraceKillSwitch) == "0" {
- return
- }
- descriptor, found := deliverycontrol.Transition(transition)
- if !found {
- return
- }
-
- outcome := deliverycontrol.OutcomeAllowed
- if !ok {
- outcome = deliverycontrol.OutcomeDenied
- }
- directory, err := flowLogDirectory(repo)
- if err != nil {
- return
- }
- _ = deliverycontrol.AppendAttempt(directory, deliverycontrol.TransitionAttempt{
- From: from,
- Transition: transition,
- Outcome: outcome,
- CostClass: deliverycontrol.ChargedCostClass(descriptor.Kind, descriptor.CostClass, outcome),
- })
-}
-
-func RecordFlowAttribution(repo, category string, cost deliverycontrol.TransitionCostClass, denied bool, note string) {
- defer func() { _ = recover() }()
- if os.Getenv(flowTraceKillSwitch) == "0" {
- return
- }
- directory, err := flowLogDirectory(repo)
- if err != nil {
- return
- }
- outcome := deliverycontrol.OutcomeAllowed
- if denied {
- outcome = deliverycontrol.OutcomeDenied
- }
- _ = deliverycontrol.AppendAttempt(directory, deliverycontrol.TransitionAttempt{
- From: deliverycontrol.StateUninitialized,
- Transition: deliverycontrol.TransitionID("attribution." + category),
- Outcome: outcome, CostClass: cost, Category: category, Note: note,
- })
-}
diff --git a/boatstack/flow_trace_test.go b/boatstack/flow_trace_test.go
deleted file mode 100644
index 8bcb2d4..0000000
--- a/boatstack/flow_trace_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package boatstack
-
-import (
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-func readFlowTrajectory(t *testing.T, repo string) deliverycontrol.Trajectory {
- t.Helper()
- dir, err := flowLogDirectory(repo)
- if err != nil {
- t.Fatalf("flow log directory: %v", err)
- }
- traj, err := deliverycontrol.ReadTrajectory(dir)
- if err != nil {
- t.Fatalf("read trajectory: %v", err)
- }
- return traj
-}
-
-// An allowed mutation and a denied one are both recorded, and the denial is
-// billed as friction — the recorder is a faithful, cheap witness to real
-// command outcomes.
-func TestRecordFlowTransitionCapturesOutcomes(t *testing.T) {
- repo := prTestRepo(t)
-
- RecordFlowTransition(repo, "delivery.record_gate_test", deliverycontrol.StateBuild, true)
- RecordFlowTransition(repo, "delivery.undo", deliverycontrol.StateBuild, false)
-
- traj := readFlowTrajectory(t, repo)
- if len(traj) != 2 {
- t.Fatalf("recorded %d attempts, want 2", len(traj))
- }
- if traj[0].Outcome != deliverycontrol.OutcomeAllowed || traj[0].CostClass != deliverycontrol.CostMutation {
- t.Errorf("allowed gate: got %+v", traj[0])
- }
- if traj[1].Outcome != deliverycontrol.OutcomeDenied || traj[1].CostClass != deliverycontrol.CostFriction {
- t.Errorf("denied undo should be friction: got %+v", traj[1])
- }
- if cost := traj.WalkCost(deliverycontrol.DefaultFlowCostWeights()); cost != 4 {
- t.Errorf("walk cost = %d, want 4 (1 move + 3 friction)", cost)
- }
-}
-
-// The recorder is best-effort: an unresolvable repo, an unknown transition, and
-// the kill switch each leave command behavior untouched and write nothing —
-// without erroring or panicking.
-func TestRecordFlowTransitionIsBestEffort(t *testing.T) {
- // A non-git directory cannot resolve a flow-log location; must be a silent no-op.
- RecordFlowTransition(t.TempDir(), "delivery.record_gate_test", deliverycontrol.StateBuild, true)
-
- repo := prTestRepo(t)
-
- // An unknown transition is ignored.
- RecordFlowTransition(repo, "delivery.does_not_exist", deliverycontrol.StateBuild, true)
- if traj := readFlowTrajectory(t, repo); len(traj) != 0 {
- t.Errorf("unknown transition should record nothing; got %d", len(traj))
- }
-
- // The kill switch disables recording entirely.
- t.Setenv(flowTraceKillSwitch, "0")
- RecordFlowTransition(repo, "delivery.record_gate_test", deliverycontrol.StateBuild, true)
- if traj := readFlowTrajectory(t, repo); len(traj) != 0 {
- t.Errorf("kill switch should suppress recording; got %d", len(traj))
- }
-}
diff --git a/boatstack/flow_watch.go b/boatstack/flow_watch.go
deleted file mode 100644
index 2312c1c..0000000
--- a/boatstack/flow_watch.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "sort"
- "strings"
- "time"
-)
-
-// `flow watch` is the bounded waiting primitive for the asynchronous world a
-// published PR lives in (CI runs, reviews land, merges happen). Each tick it
-// re-runs the same read-only frontier observation and compares a stable
-// signature of every row; it EXITS on the first change, on an all-terminal
-// frontier, or at the deadline — it never acts on what it sees. Boatstack
-// stays a synchronous oracle: the loop here only decides when to ask the
-// oracle again, and hands control back the moment the answer differs. No
-// daemon, no writes, no transition execution path is reachable from it.
-// control-law: watch-observes-and-exits-never-acts
-const flowWatchSchemaVersion = 1
-
-const (
- WatchOutcomeChanged = "changed"
- WatchOutcomeTerminal = "terminal"
- WatchOutcomeTimeout = "timeout"
-)
-
-// Seams for tests: the watcher must be provable without real waiting.
-var (
- flowWatchNow = time.Now
- flowWatchSleep = time.Sleep
-)
-
-type FlowWatchOptions struct {
- Repo string
- Interval time.Duration
- Timeout time.Duration
-}
-
-// FlowWatchResult reports why the watch loop returned and what it saw. Final
-// always carries the last observed frontier so the caller re-orients without
-// another resolution.
-type FlowWatchResult struct {
- SchemaVersion int `json:"schema_version"`
- Outcome string `json:"outcome"`
- Ticks int `json:"ticks"`
- ChangedRows []string `json:"changed_rows,omitempty"`
- Final FlowFrontier `json:"final"`
-}
-
-const (
- defaultWatchInterval = 30 * time.Second
- defaultWatchTimeout = 30 * time.Minute
- // minimumWatchInterval keeps a mistyped interval from hammering GitHub.
- minimumWatchInterval = 5 * time.Second
-)
-
-// WatchFrontier runs the bounded observe-compare loop. It returns an error
-// only for the faults ResolveFrontier itself refuses (unreadable store,
-// invalid config); a failing gh observation degrades each row to an Unknown
-// phase — a signature like any other — and the loop stays bounded.
-func WatchFrontier(options FlowWatchOptions) (FlowWatchResult, error) {
- interval := options.Interval
- if interval <= 0 {
- interval = defaultWatchInterval
- }
- if interval < minimumWatchInterval {
- interval = minimumWatchInterval
- }
- timeout := options.Timeout
- if timeout <= 0 {
- timeout = defaultWatchTimeout
- }
-
- result := FlowWatchResult{SchemaVersion: flowWatchSchemaVersion}
- initial, err := ResolveFrontier(options.Repo)
- if err != nil {
- return result, err
- }
- result.Final = initial
- if frontierAllTerminal(initial) {
- result.Outcome = WatchOutcomeTerminal
- return result, nil
- }
- baseline := frontierSignatures(initial)
- deadline := flowWatchNow().Add(timeout)
- for {
- if !flowWatchNow().Before(deadline) {
- result.Outcome = WatchOutcomeTimeout
- return result, nil
- }
- flowWatchSleep(interval)
- result.Ticks++
- current, err := ResolveFrontier(options.Repo)
- if err != nil {
- return result, err
- }
- result.Final = current
- signatures := frontierSignatures(current)
- if changed := signatureDiff(baseline, signatures); len(changed) > 0 {
- result.Outcome = WatchOutcomeChanged
- result.ChangedRows = changed
- return result, nil
- }
- }
-}
-
-// frontierAllTerminal reports whether nothing on the frontier can move: no
-// rows at all, or every row terminal. Blocked and operator rows are NOT
-// terminal — external state (a review, a merge, a fix landing elsewhere) can
-// change them, which is exactly what a watcher waits for.
-func frontierAllTerminal(frontier FlowFrontier) bool {
- if !frontier.Initialized || len(frontier.Rows) == 0 {
- return true
- }
- for _, row := range frontier.Rows {
- if row.Actor != string(NextActorNone) {
- return false
- }
- }
- return true
-}
-
-// frontierSignatures reduces each row to the stable facts a caller would act
-// on: position, actor, lifecycle, and the failing-check set. Reasons and
-// prescribed command text are deliberately excluded — wording changes are not
-// frontier changes.
-func frontierSignatures(frontier FlowFrontier) map[string]string {
- signatures := map[string]string{}
- for _, row := range frontier.Rows {
- key := row.Feature + "/" + row.Slice
- signatures[key] = strings.Join([]string{
- row.Stage, row.Lifecycle, row.PRPhase, row.Actor, row.NextOperation,
- row.GoalEscape,
- fmt.Sprintf("blocked=%t", row.Blocked),
- strings.Join(row.PRFailingChecks, "|"),
- }, "·")
- }
- return signatures
-}
-
-func signatureDiff(before, after map[string]string) []string {
- changed := []string{}
- for key, value := range after {
- if previous, ok := before[key]; !ok || previous != value {
- changed = append(changed, key)
- }
- }
- for key := range before {
- if _, ok := after[key]; !ok {
- changed = append(changed, key+" (gone)")
- }
- }
- sort.Strings(changed)
- return changed
-}
-
-// FormatFlowWatch renders the watch outcome and the final frontier.
-func FormatFlowWatch(result FlowWatchResult) string {
- var b strings.Builder
- switch result.Outcome {
- case WatchOutcomeChanged:
- fmt.Fprintf(&b, "Watch: frontier changed after %d tick(s): %s\n", result.Ticks, strings.Join(result.ChangedRows, ", "))
- case WatchOutcomeTerminal:
- b.WriteString("Watch: nothing on the frontier can move; not waiting.\n")
- case WatchOutcomeTimeout:
- fmt.Fprintf(&b, "Watch: no frontier change within the timeout (%d tick(s)).\n", result.Ticks)
- }
- b.WriteString(FormatFlowFrontier(result.Final))
- return b.String()
-}
diff --git a/boatstack/flow_watch_conformance_test.go b/boatstack/flow_watch_conformance_test.go
deleted file mode 100644
index 8d9b4d5..0000000
--- a/boatstack/flow_watch_conformance_test.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package boatstack
-
-// control-law: watch-observes-and-exits-never-acts
-//
-// `flow watch` is a bounded observe-compare loop over the read-only frontier:
-// each tick re-observes, and the loop exits on the first signature change, on
-// an all-terminal frontier, or at the deadline. It never executes a
-// transition and never writes — across any number of ticks the delivery
-// ledger stays byte-identical. Time is injected through seams so the law is
-// provable without real waiting.
-//
-// Test classes: positive (an external phase change ends the wait and names
-// the changed row), negative (no change → timeout outcome, frontier intact),
-// bypass (zero writes across many ticks), failure-state (gh failing every
-// tick degrades to Unknown rows and the loop still terminates at the
-// deadline; an all-terminal frontier refuses to wait at all).
-
-import (
- "errors"
- "os"
- "sync/atomic"
- "testing"
- "time"
-)
-
-// fakeWatchClock replaces the time seams: sleeping advances a virtual clock,
-// so deadlines fire deterministically and instantly.
-func fakeWatchClock(t *testing.T) *atomic.Int64 {
- t.Helper()
- var virtual atomic.Int64
- previousNow, previousSleep := flowWatchNow, flowWatchSleep
- flowWatchNow = func() time.Time { return time.Unix(0, virtual.Load()) }
- flowWatchSleep = func(d time.Duration) { virtual.Add(int64(d)) }
- t.Cleanup(func() { flowWatchNow, flowWatchSleep = previousNow, previousSleep })
- return &virtual
-}
-
-func watchRepoWithOpenPR(t *testing.T) string {
- t.Helper()
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "")
- return repo
-}
-
-// Positive: the PR's checks finish between ticks; the watch exits with
-// outcome "changed" and names the row that moved.
-func TestWatchExitsWhenTheFrontierChanges(t *testing.T) {
- fakeWatchClock(t)
- repo := watchRepoWithOpenPR(t)
- var calls atomic.Int64
- withRecoveryGh(t, func(_ string, args ...string) (string, error) {
- if calls.Add(1) <= 1 {
- return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)(repo, args...)
- }
- return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)(repo, args...)
- })
- result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour})
- if err != nil {
- t.Fatal(err)
- }
- if result.Outcome != WatchOutcomeChanged || result.Ticks != 1 {
- t.Fatalf("unexpected watch result: %#v", result)
- }
- if len(result.ChangedRows) != 1 || result.ChangedRows[0] != "shipped/delivery" {
- t.Fatalf("changed row not named: %#v", result.ChangedRows)
- }
- if result.Final.Rows[0].PRPhase != string(PRPhaseChecksFailing) {
- t.Fatalf("final frontier does not carry the new observation: %#v", result.Final.Rows)
- }
-}
-
-// Negative: nothing changes; the watch times out with the frontier intact and
-// the CLI-visible timeout outcome.
-func TestWatchTimesOutWhenNothingChanges(t *testing.T) {
- fakeWatchClock(t)
- repo := watchRepoWithOpenPR(t)
- withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending))
- result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: 10 * time.Minute, Timeout: time.Hour})
- if err != nil {
- t.Fatal(err)
- }
- if result.Outcome != WatchOutcomeTimeout {
- t.Fatalf("unexpected outcome: %#v", result)
- }
- if result.Ticks < 5 || result.Ticks > 7 {
- t.Fatalf("unexpected tick count for 1h/10m: %d", result.Ticks)
- }
- if result.Final.Rows[0].PRPhase != string(PRPhaseChecksPending) {
- t.Fatalf("frontier drifted without a change: %#v", result.Final.Rows)
- }
-}
-
-// Bypass: across many ticks — including a tick that observes a terminal
-// MERGED lifecycle — the watch writes nothing. The change is reported, never
-// recorded.
-func TestWatchWritesNothingAcrossTicks(t *testing.T) {
- fakeWatchClock(t)
- repo := watchRepoWithOpenPR(t)
- statePath, err := deliveryStatePath(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- before, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- var calls atomic.Int64
- withRecoveryGh(t, func(_ string, args ...string) (string, error) {
- if calls.Add(1) <= 3 {
- return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)(repo, args...)
- }
- return phaseObservationPayload("MERGED", "", "", "")(repo, args...)
- })
- result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour})
- if err != nil {
- t.Fatal(err)
- }
- if result.Outcome != WatchOutcomeChanged {
- t.Fatalf("unexpected outcome: %#v", result)
- }
- after, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatal("watch mutated the delivery ledger")
- }
-}
-
-// Failure-state 1: gh fails on every tick — rows degrade to Unknown, no
-// crash, and the loop still ends at the deadline.
-func TestWatchStaysBoundedWhenObservationFails(t *testing.T) {
- fakeWatchClock(t)
- repo := watchRepoWithOpenPR(t)
- withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("gh unavailable") })
- result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: 15 * time.Minute, Timeout: time.Hour})
- if err != nil {
- t.Fatal(err)
- }
- if result.Outcome != WatchOutcomeTimeout {
- t.Fatalf("unexpected outcome: %#v", result)
- }
- if result.Final.Rows[0].PRPhase != string(PRPhaseUnknown) {
- t.Fatalf("degraded observation not fail-closed: %#v", result.Final.Rows)
- }
-}
-
-// Failure-state 2: when nothing on the frontier can move, the watch refuses
-// to wait at all.
-func TestWatchRefusesToWaitOnTerminalFrontier(t *testing.T) {
- fakeWatchClock(t)
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "done", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "done", "feat/phase", "https://example.invalid/pr/9", "")
- withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", ""))
- result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour})
- if err != nil {
- t.Fatal(err)
- }
- if result.Outcome != WatchOutcomeTerminal || result.Ticks != 0 {
- t.Fatalf("unexpected result on a terminal frontier: %#v", result)
- }
-}
diff --git a/boatstack/goal_escape.go b/boatstack/goal_escape.go
deleted file mode 100644
index eb8e338..0000000
--- a/boatstack/goal_escape.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package boatstack
-
-import "strings"
-
-// Goal escapes bound the merged-terminal pursuit: pursuing a merge
-// autonomously is trustworthy only while the world stays inside the contract
-// the goal was granted under. When a disturbance ends that contract — the fix
-// budget is spent, a reviewer requested changes, the base conflicts — the
-// pursuit DEMOTES to the operator and stops: the actor becomes operator,
-// nothing further is prescribed, and the demotion is persisted best-effort so
-// it holds offline in a fresh session. An escape is cleared only by the next
-// explicit correction cycle (record-change), which is an operator-authorized
-// act in the protocol. Escapes exist only under the merged terminal; the
-// published default never evaluates or records them.
-// control-law: goal-escape-demotes-to-operator-and-stops
-const (
- EscapeFixAttemptsExhausted = "fix_attempts_exhausted"
- EscapeChangesRequested = "changes_requested"
- EscapeBaseConflicts = "base_conflicts"
-)
-
-// postPublishFixBudget bounds how many post-publish correction cycles a
-// published slice may consume before the pursuit hands back to the operator.
-// It mirrors the active-slice repair budget (RepairAttempt < 3).
-const postPublishFixBudget = 3
-
-// evaluateGoalEscape derives the escape for one published slice from its
-// persisted counters and one live observation. Pure and offline-safe: a
-// previously persisted escape is sticky regardless of what gh says now (a
-// re-approval without a recorded correction does not silently re-arm the
-// pursuit), and the attempts bound needs no network at all.
-func evaluateGoalEscape(slice DeliverySlice, pr publishedPRObservation) string {
- if persisted := strings.TrimSpace(slice.GoalEscape); persisted != "" {
- return persisted
- }
- if slice.PostPublishFixAttempts >= postPublishFixBudget {
- return EscapeFixAttemptsExhausted
- }
- if strings.EqualFold(strings.TrimSpace(pr.ReviewDecision), "CHANGES_REQUESTED") {
- return EscapeChangesRequested
- }
- // DIRTY is GitHub's "the branch conflicts with the base". BEHIND is
- // deliberately not an escape: it is often auto-resolvable and already
- // classifies to an operator-owned Unknown phase without stickiness.
- if strings.EqualFold(strings.TrimSpace(pr.MergeState), "DIRTY") {
- return EscapeBaseConflicts
- }
- return ""
-}
-
-// goalEscapeReason renders one escape as the operator-facing explanation.
-func goalEscapeReason(escape string) string {
- switch escape {
- case EscapeFixAttemptsExhausted:
- return "the post-publish fix budget is spent"
- case EscapeChangesRequested:
- return "a reviewer requested changes"
- case EscapeBaseConflicts:
- return "the branch conflicts with its base"
- default:
- return "the pursuit contract ended"
- }
-}
-
-// persistGoalEscape caches a fired escape on the slice it belongs to, exactly
-// like persistObservedTerminalPRState caches a terminal lifecycle: a bounded,
-// best-effort write of an already-derived fact, so the demotion is sticky in
-// a fresh offline session. Failures are swallowed — the demotion holds for
-// this resolution regardless.
-func persistGoalEscape(repo string, state DeliveryState, escape string) {
- if strings.TrimSpace(escape) == "" {
- return
- }
- for i := len(state.Slices) - 1; i >= 0; i-- {
- slice := state.Slices[i]
- if slice.Status != "PUBLISHED" {
- continue
- }
- if strings.TrimSpace(slice.GoalEscape) == escape {
- return
- }
- state.Slices[i].GoalEscape = escape
- _ = saveDeliveryState(repo, state)
- return
- }
-}
-
-// bumpPostPublishFixCycle advances the per-slice correction-cycle bookkeeping
-// when a post-publish correction is explicitly recorded. Recording a
-// correction after an escape is the operator's reset: the escape clears and
-// the new cycle starts at one. Without an escape, the cycle count advances
-// toward the budget.
-func bumpPostPublishFixCycle(slice *DeliverySlice) {
- if strings.TrimSpace(slice.GoalEscape) != "" {
- slice.GoalEscape = ""
- slice.PostPublishFixAttempts = 1
- return
- }
- slice.PostPublishFixAttempts++
-}
diff --git a/boatstack/goal_escape_conformance_test.go b/boatstack/goal_escape_conformance_test.go
deleted file mode 100644
index f23926b..0000000
--- a/boatstack/goal_escape_conformance_test.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package boatstack
-
-// control-law: goal-escape-demotes-to-operator-and-stops
-//
-// The merged-terminal pursuit is bounded by an explicit contract: it runs
-// only while the fix budget holds, no reviewer has requested changes, and the
-// branch merges cleanly. Any of those disturbances fires a goal escape, and a
-// fired escape demotes unconditionally — the actor becomes operator, nothing
-// further is prescribed (demote-and-stop, never demote-and-suggest), and the
-// demotion persists best-effort so it holds OFFLINE in a fresh session. The
-// only reset is the next explicitly recorded correction cycle. Under the
-// published default no escape is ever evaluated or written.
-//
-// Test classes: positive (each escape condition → operator + no
-// prescription + explanatory reason), relation (a persisted escape demotes
-// with gh unavailable — sticky offline; recording a correction clears it and
-// restarts the cycle at one), negative (a budget not yet spent does not
-// escape; the default terminal records nothing), bypass (an escaped delivery
-// never gets the merge prescribed again until reset, even under a live
-// merge-eligible observation).
-
-import (
- "errors"
- "strings"
- "testing"
-)
-
-func recordCIObservation(t *testing.T, repo, feature string) {
- t.Helper()
- if _, _, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "check failed", SourceStage: "ci",
- Classification: "implementation_repair",
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-// Positive: each live disturbance fires its escape — operator actor, no
-// prescription, and a reason that explains the pause.
-func TestLiveDisturbancesFireEscapes(t *testing.T) {
- for _, test := range []struct {
- name string
- payload func(string, ...string) (string, error)
- wantEscape string
- }{
- {"changes_requested", phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunPass), EscapeChangesRequested},
- {"base_conflicts", phaseObservationPayload("OPEN", "APPROVED", "DIRTY", rollupCheckRunPass), EscapeBaseConflicts},
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := mergedTerminalRepo(t)
- withRecoveryGh(t, test.payload)
- status, err := ResolveNext(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if status.GoalEscape != test.wantEscape {
- t.Fatalf("escape = %q, want %q", status.GoalEscape, test.wantEscape)
- }
- if !strings.Contains(status.Reason, "paused") {
- t.Fatalf("reason does not explain the pause: %q", status.Reason)
- }
- next, err := NextControl(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if next.Actor != NextActorOperator || next.Prescribed != nil {
- t.Fatalf("escape did not demote-and-stop: actor=%q prescribed=%#v", next.Actor, next.Prescribed)
- }
- })
- }
-}
-
-// Positive + relation: the fix budget is offline — three recorded cycles
-// exhaust it with no live signal at all, the demotion is sticky with gh
-// unavailable, and the next recorded correction is the reset that starts a
-// fresh cycle at one.
-func TestFixBudgetExhaustsDemotesOfflineAndResets(t *testing.T) {
- repo := mergedTerminalRepo(t)
- for i := 0; i < postPublishFixBudget; i++ {
- recordCIObservation(t, repo, "shipped")
- }
- state, err := LoadDeliveryState(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- last := state.Slices[len(state.Slices)-1]
- if last.PostPublishFixAttempts != postPublishFixBudget {
- t.Fatalf("attempts = %d, want %d", last.PostPublishFixAttempts, postPublishFixBudget)
- }
-
- // gh is unavailable: the escape must fire from the persisted counter alone.
- withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("offline") })
- status, err := ResolveNext(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if status.GoalEscape != EscapeFixAttemptsExhausted {
- t.Fatalf("offline escape = %q", status.GoalEscape)
- }
- next, err := NextControl(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if next.Actor != NextActorOperator || next.Prescribed != nil {
- t.Fatalf("offline demotion failed: actor=%q prescribed=%#v", next.Actor, next.Prescribed)
- }
- // The fired escape was cached; a fresh load shows it without any observation.
- state, err = LoadDeliveryState(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if state.Slices[len(state.Slices)-1].GoalEscape != EscapeFixAttemptsExhausted {
- t.Fatalf("escape not persisted: %#v", state.Slices)
- }
-
- // The reset: recording the next correction clears the escape and starts a
- // new cycle at one.
- recordCIObservation(t, repo, "shipped")
- state, err = LoadDeliveryState(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- last = state.Slices[len(state.Slices)-1]
- if last.GoalEscape != "" || last.PostPublishFixAttempts != 1 {
- t.Fatalf("reset failed: %#v", last)
- }
-}
-
-// Negative: a budget not yet spent does not escape, and the pursuit still
-// prescribes the fix.
-func TestUnspentBudgetKeepsPrescribing(t *testing.T) {
- repo := mergedTerminalRepo(t)
- for i := 0; i < postPublishFixBudget-1; i++ {
- recordCIObservation(t, repo, "shipped")
- }
- withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail))
- next, err := NextControl(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if next.Actor != NextActorAgent || next.Prescribed == nil || next.Prescribed.Verb != "record-change" {
- t.Fatalf("unspent budget stopped prescribing: actor=%q prescribed=%#v", next.Actor, next.Prescribed)
- }
-}
-
-// Negative: the published default never evaluates, surfaces, or writes an
-// escape — its state files stay byte-stable through a correction cycle.
-func TestPublishedDefaultRecordsNoEscapeState(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "")
- withRecoveryGh(t, phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "DIRTY", rollupCheckRunFail))
-
- status, err := ResolveNext(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if status.GoalEscape != "" {
- t.Fatalf("default terminal surfaced an escape: %q", status.GoalEscape)
- }
- recordCIObservation(t, repo, "shipped")
- state, err := LoadDeliveryState(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- last := state.Slices[len(state.Slices)-1]
- if last.PostPublishFixAttempts != 0 || last.GoalEscape != "" {
- t.Fatalf("default terminal wrote pursuit bookkeeping: %#v", last)
- }
-}
-
-// Bypass: once escaped, even a live merge-eligible observation cannot get the
-// merge prescribed again — the contract stays ended until the recorded reset.
-func TestEscapedDeliveryNeverGetsMergePrescribed(t *testing.T) {
- repo := mergedTerminalRepo(t)
- // Fire and persist an escape.
- withRecoveryGh(t, phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunPass))
- if _, err := ResolveNext(repo, "shipped"); err != nil {
- t.Fatal(err)
- }
- // The world now looks perfect — but the escape is sticky.
- withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunPass))
- next, err := NextControl(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if next.Actor != NextActorOperator || next.Prescribed != nil {
- t.Fatalf("sticky escape bypassed: actor=%q prescribed=%#v", next.Actor, next.Prescribed)
- }
- // After the recorded reset, the pursuit re-arms from a fresh observation.
- recordCIObservation(t, repo, "shipped")
- next, err = NextControl(repo, "shipped")
- if err != nil {
- t.Fatal(err)
- }
- if next.Prescribed == nil || next.Prescribed.Program != "gh" {
- t.Fatalf("reset did not re-arm the pursuit: %#v", next.Prescribed)
- }
-}
diff --git a/boatstack/hooks.go b/boatstack/hooks.go
deleted file mode 100644
index d8f0e4f..0000000
--- a/boatstack/hooks.go
+++ /dev/null
@@ -1,906 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "time"
-)
-
-const hookCommandMarker = ".product-loop/hooks/guard"
-
-type HookDiagnostic struct {
- Host string
- ContractStatus string
- LiveEventObserved bool
-}
-
-func canonicalHookEvent(host string) ([]byte, error) {
- switch host {
- case "cursor":
- return []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`), nil
- case "claude", "codex":
- return []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), nil
- case "gemini":
- return []byte(`{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`), nil
- default:
- return nil, fmt.Errorf("unsupported hook host %q; expected cursor, claude, codex, or gemini", host)
- }
-}
-
-func validateCanonicalHookOutput(host string, output []byte) error {
- if host == "cursor" {
- var decision map[string]any
- if err := json.Unmarshal(bytes.TrimSpace(output), &decision); err != nil || stringValue(decision["permission"]) != "allow" {
- return fmt.Errorf("cursor hook diagnostic returned a malformed or non-allow response")
- }
- return nil
- }
- if host == "gemini" {
- var decision map[string]any
- if err := json.Unmarshal(bytes.TrimSpace(output), &decision); err != nil || stringValue(decision["decision"]) != "allow" {
- return fmt.Errorf("gemini hook diagnostic returned a malformed or non-allow response")
- }
- return nil
- }
- if len(bytes.TrimSpace(output)) != 0 {
- return fmt.Errorf("%s hook diagnostic returned unexpected allow output", host)
- }
- return nil
-}
-
-var hookDiagnosticRunner = runInstalledHookDiagnostic
-
-func runInstalledHookDiagnostic(ctx context.Context, repo, host string, input []byte) ([]byte, error) {
- var command *exec.Cmd
- if runtime.GOOS == "windows" {
- path := filepath.Join(repo, ".product-loop", "hooks", "guard.ps1")
- command = exec.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path, "-HostName", host)
- } else {
- path := filepath.Join(repo, ".product-loop", "hooks", "guard.sh")
- command = exec.CommandContext(ctx, "bash", path, host)
- }
- command.Dir = repo
- command.Stdin = bytes.NewReader(append(input, '\n'))
- channels, err := runCommandChannels(command)
- if err != nil {
- return channels.Stdout, commandFailure(channels, err)
- }
- return channels.Stdout, nil
-}
-
-// DiagnoseHook runs the installed guard with a canonical, read-only event. It
-// proves the generated wrapper, shared runtime, decoder, and allow contract; it
-// deliberately cannot observe the coding host's live event payload.
-func DiagnoseHook(repoPath, hostName string) (HookDiagnostic, error) {
- host := strings.ToLower(strings.TrimSpace(hostName))
- input, err := canonicalHookEvent(host)
- if err != nil {
- return HookDiagnostic{}, err
- }
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return HookDiagnostic{}, err
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
- defer cancel()
- output, runErr := hookDiagnosticRunner(ctx, repo, host, input)
- if ctx.Err() != nil {
- return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic timed out", host)
- }
- if runErr != nil {
- detail := boundedObservation(strings.TrimSpace(string(output) + " " + runErr.Error()))
- return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic failed: %s", host, detail)
- }
- if err := validateCanonicalHookOutput(host, output); err != nil {
- return HookDiagnostic{}, err
- }
- return HookDiagnostic{Host: host, ContractStatus: "PASS", LiveEventObserved: false}, nil
-}
-
-// runtimeHydrateCommandBash / runtimeHydrateCommandPowerShell are the single
-// source of truth for the pinned, verified installer invocation that populates
-// an absent shared-runtime slot. They mirror installationRepairRetryCommand but
-// target the branch-free `hydrate` mode, so they are safe to run from any
-// checkout on any branch without rewriting committed generated files. Each is
-// used both as its guard's default auto-hydrate command and, embedded in the
-// fail-closed deny message, as a human copy-paste self-heal. They are keyed by
-// target shell (not the generating host's GOOS) because both guard scripts are
-// generated on every platform. The bash form must contain no single quote: the
-// guard wraps it in a single-quoted default.
-func runtimeHydrateCommandBash(version string) string {
- return `BOATSTACK_MODE=hydrate BOATSTACK_VERSION=` + version + ` BOATSTACK_REPO="$PWD" /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.sh)"`
-}
-
-func runtimeHydrateCommandPowerShell(version string) string {
- return `$env:BOATSTACK_MODE="hydrate"; $env:BOATSTACK_VERSION="` + version + `"; $env:BOATSTACK_REPO=(Get-Location).Path; irm https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.ps1 | iex`
-}
-
-func guardShellScript() []byte {
- return []byte(fmt.Sprintf(`#!/usr/bin/env bash
-# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json.
-set -u
-
-# A denial is a guardrail, not a crash. On a real terminal render a soft-coral
-# badge; when stderr is piped/captured (a host UI, a log) emit the plain message
-# unchanged. Honors NO_COLOR and BOATSTACK_COLOR=never.
-bs_color=0
-case "${BOATSTACK_COLOR:-auto}" in
- always|1|true|yes|on) bs_color=1 ;;
- never|0|false|no|off) bs_color=0 ;;
- *) if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then bs_color=1; fi ;;
-esac
-bs_deny() {
- if [ "$bs_color" = 1 ]; then
- printf '\033[48;2;229;146;128m\033[38;2;23;24;28m\033[1m ⊘ Blocked by Boatstack \033[0m %%s\n' "$1" >&2
- else
- printf '%%s\n' "$1" >&2
- fi
-}
-
-HOST="${1:-}"
-ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
-if [[ -z "$ROOT" ]]; then
- exit 0
-fi
-
-# Repository presence is not engagement. This worktree-local lease probe runs
-# before platform detection, runtime discovery, checksum work, or hydration.
-# Missing, unsafe, stale-branch, and malformed evidence is inert here; the
-# trusted helper validates the complete lease and delivery state when active.
-GIT_DIR="$(git rev-parse --path-format=absolute --git-dir 2>/dev/null || true)"
-LEASE="$GIT_DIR/boatstack/engagement.json"
-if [[ -z "$GIT_DIR" || ! -f "$LEASE" || -L "$LEASE" ]]; then
- exit 0
-fi
-LEASE_BRANCH="$(sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$LEASE" | head -n 1)"
-CURRENT_BRANCH="$(git branch --show-current 2>/dev/null || true)"
-if [[ -z "$LEASE_BRANCH" || "$LEASE_BRANCH" != "$CURRENT_BRANCH" ]]; then
- exit 0
-fi
-
-COMMON="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
-if [[ -z "$COMMON" ]]; then
- bs_deny "Boatstack safety guard could not resolve the Git common directory; denying tool execution."
- exit 2
-fi
-
-case "$(uname -s)" in
- Darwin) OS_NAME="darwin"; EXTENSION="" ;;
- Linux) OS_NAME="linux"; EXTENSION="" ;;
- MINGW*|MSYS*|CYGWIN*) OS_NAME="windows"; EXTENSION=".exe" ;;
- *) bs_deny "Boatstack safety guard found an unsupported operating system; denying tool execution."; exit 2 ;;
-esac
-case "$(uname -m)" in
- x86_64|amd64) ARCH="amd64" ;;
- arm64|aarch64) ARCH="arm64" ;;
- *) bs_deny "Boatstack safety guard found an unsupported architecture; denying tool execution."; exit 2 ;;
-esac
-
-HELPER="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/boatstack-helper${EXTENSION}"
-MANIFEST="$COMMON/boatstack/runtimes/%s/%s/${OS_NAME}-${ARCH}/runtime.lock.json"
-HYDRATE_LOCK="$COMMON/boatstack/hydrate-%s.lock"
-# Auto-hydrate a missing or incomplete shared-runtime slot. A teammate who pulls
-# a version bump or clones fresh inherits the committed pointers (this guard's
-# baked version path) but an empty, gitignored slot, so without this the very next
-# tool call would hard-deny before any Go runs. On an incomplete slot we run the
-# tag-pinned, checksum-verifying installer in branch-free hydrate mode, serialize
-# clone-wide with an atomic mkdir lock, and bound the attempt. This is purely
-# additive: the existing missing/symlink/checksum gates below stay authoritative
-# and fail-closed, so a disabled, timed-out, or failed hydration simply denies.
-# The entry test mirrors those gates (helper AND manifest present, non-symlink):
-# an installer copies the helper before the manifest, so a peer arriving in that
-# window must join the lock and wait, not skip the block and deny a half-slot.
-if { [[ ! -x "$HELPER" || -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; } && [[ "${BOATSTACK_AUTO_HYDRATE:-1}" != "0" ]]; then
- mkdir -p "$COMMON/boatstack" 2>/dev/null || true
- if mkdir "$HYDRATE_LOCK" 2>/dev/null; then
- # Double-checked locking. A slow guard can reach this mkdir only after the
- # winner already hydrated and released the lock, so its mkdir succeeds too.
- # Re-test the slot now that we hold the lock and run the installer only if it
- # is still missing or incomplete, so exactly one hydration happens under
- # contention (redundant installer runs also widen the exec-time race window).
- if [[ ! -x "$HELPER" || -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; then
- (
- cd "$ROOT" || exit 0
- export BOATSTACK_MODE=hydrate
- export BOATSTACK_VERSION="%s"
- export BOATSTACK_REPO="$ROOT"
- HYDRATE_COMMAND="${BOATSTACK_HYDRATE_COMMAND:-}"
- if [[ -z "$HYDRATE_COMMAND" ]]; then
- HYDRATE_COMMAND='%s'
- fi
- if command -v timeout >/dev/null 2>&1; then
- timeout 8 /bin/bash -c "$HYDRATE_COMMAND"
- else
- /bin/bash -c "$HYDRATE_COMMAND"
- fi
- ) >&2 || true
- fi
- rmdir "$HYDRATE_LOCK" 2>/dev/null || true
- else
- # A peer holds the hydrate lock. Wait for the peer to finish — it removes the
- # lock only after its hydrate command returns — before inspecting the slot, so
- # a waiter never observes a half-written runtime (for example the helper copied
- # but the manifest not yet in place). A released lock means the slot is as
- # complete as it will get; the authoritative gates below then accept it or fail
- # closed. Bound the wait above the peer's own hydrate timeout so a slow but
- # succeeding peer still wins.
- for _ in $(seq 1 12); do
- [[ -d "$HYDRATE_LOCK" ]] || break
- sleep 1
- done
- fi
-fi
-# Both paths can become visible while the writer still owns the lock. Treat
-# lock release, not path existence, as the shared-runtime publication point.
-for _ in $(seq 1 12); do
- [[ -d "$HYDRATE_LOCK" ]] || break
- sleep 1
-done
-if [[ -d "$HYDRATE_LOCK" ]]; then
- bs_deny "Boatstack shared runtime hydration did not complete; denying tool execution."
- exit 2
-fi
-if [[ ! -x "$HELPER" ]]; then
- bs_deny "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:"
- echo " %s" >&2
- exit 2
-fi
-if [[ -L "$HELPER" || ! -f "$MANIFEST" || -L "$MANIFEST" ]]; then
- bs_deny "Boatstack shared runtime is unsafe or incomplete; rerun the verified tagged installer."
- exit 2
-fi
-EXPECTED="$(sed -n 's/.*"binary_sha256"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "$MANIFEST" | head -n 1)"
-if command -v sha256sum >/dev/null 2>&1; then
- ACTUAL="$(sha256sum "$HELPER" | awk '{print $1}')"
-elif command -v shasum >/dev/null 2>&1; then
- ACTUAL="$(shasum -a 256 "$HELPER" | awk '{print $1}')"
-else
- bs_deny "Boatstack cannot verify the shared runtime checksum; denying tool execution."
- exit 2
-fi
-if [[ -z "$EXPECTED" || "$ACTUAL" != "$EXPECTED" ]]; then
- bs_deny "Boatstack shared runtime checksum is invalid; rerun the verified tagged installer."
- exit 2
-fi
-
-# Linux refuses to exec a file another process still holds open for writing
-# (ETXTBSY, surfaced as exit 126). Under concurrent first use a peer guard can be
-# finishing hydration at this instant, even though the writer replaces the binary
-# atomically. Retry briefly, then hand off. A genuinely non-executable helper keeps
-# returning 126 and the final status still propagates unchanged. Running the helper
-# as a child (not exec) is required so a failed start is observable; stdio and the
-# exit code pass through, and an ETXTBSY start never consumes stdin.
-ATTEMPT=0
-while :; do
- "$HELPER" bootstrap-safety-hook --host "$HOST" --repo "$ROOT"
- HELPER_STATUS=$?
- if [[ $HELPER_STATUS -eq 126 && $ATTEMPT -lt 30 ]]; then
- ATTEMPT=$((ATTEMPT + 1))
- sleep 0.1
- continue
- fi
- exit $HELPER_STATUS
-done
-`, Version, SourceCommit, Version, SourceCommit, Version, Version, runtimeHydrateCommandBash(Version), runtimeHydrateCommandBash(Version)))
-}
-
-func guardPowerShellScript() []byte {
- return []byte(fmt.Sprintf(`# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json.
-param([Parameter(Mandatory=$true)][string]$HostName)
-$ErrorActionPreference = "Stop"
-
-# A denial is a guardrail, not a crash. On a real console render a soft-coral
-# badge; when stderr is redirected (a host UI, a log) emit the plain message
-# unchanged. Honors NO_COLOR and BOATSTACK_COLOR=never. ESC via [char]27 (no
-# backtick — this script is a Go raw string).
-$bsColor = $false
-switch ("$($env:BOATSTACK_COLOR)".ToLowerInvariant()) {
- { $_ -in 'always','1','true','yes','on' } { $bsColor = $true }
- { $_ -in 'never','0','false','no','off' } { $bsColor = $false }
- default { if ((-not [Console]::IsErrorRedirected) -and (-not $env:NO_COLOR)) { $bsColor = $true } }
-}
-function Bs-Deny($msg) {
- if ($bsColor) {
- $e = [char]27
- [Console]::Error.WriteLine("$e[48;2;229;146;128m$e[38;2;23;24;28m$e[1m ⊘ Blocked by Boatstack $e[0m $msg")
- } else {
- [Console]::Error.WriteLine($msg)
- }
-}
-$root = (& git rev-parse --show-toplevel 2>$null)
-if (-not $root) {
- exit 0
-}
-$gitDir = (& git rev-parse --path-format=absolute --git-dir 2>$null)
-if (-not $gitDir) { exit 0 }
-$leasePath = Join-Path $gitDir "boatstack/engagement.json"
-if (-not (Test-Path -LiteralPath $leasePath -PathType Leaf)) { exit 0 }
-$leaseInfo = Get-Item -LiteralPath $leasePath
-if ($leaseInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) { exit 0 }
-try { $lease = Get-Content -LiteralPath $leasePath -Raw | ConvertFrom-Json } catch { exit 0 }
-$currentBranch = (& git branch --show-current 2>$null)
-if (-not $lease.branch -or $lease.branch -ne $currentBranch) { exit 0 }
-$common = (& git rev-parse --path-format=absolute --git-common-dir 2>$null)
-if (-not $common) {
- Bs-Deny "Boatstack safety guard could not resolve the Git common directory; denying tool execution."
- exit 2
-}
-$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
-$arch = switch ($architecture) {
- "x64" { "amd64" }
- "arm64" { "arm64" }
- default {
- Bs-Deny "Boatstack safety guard found an unsupported architecture; denying tool execution."
- exit 2
- }
-}
-$helper = Join-Path $common "boatstack/runtimes/%s/%s/windows-$arch/boatstack-helper.exe"
-$manifestPath = Join-Path $common "boatstack/runtimes/%s/%s/windows-$arch/runtime.lock.json"
-$bsCommon = Join-Path $common "boatstack"
-$hydrateLock = Join-Path $bsCommon "hydrate-%s.lock"
-# Auto-hydrate a missing shared-runtime slot (see the bash guard for rationale):
-# a teammate who pulls a version bump or clones fresh inherits the committed
-# pointers but an empty, gitignored slot. On an absent slot we run the tag-pinned,
-# checksum-verifying installer in branch-free hydrate mode, serialized clone-wide
-# with an atomic directory lock. Purely additive: the gates below stay
-# authoritative and fail-closed if hydration is disabled, fails, or is skipped.
-if (((-not (Test-Path -LiteralPath $helper -PathType Leaf)) -or (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf))) -and $env:BOATSTACK_AUTO_HYDRATE -ne "0") {
- New-Item -ItemType Directory -Path $bsCommon -Force -ErrorAction SilentlyContinue | Out-Null
- $acquired = $false
- try { New-Item -ItemType Directory -Path $hydrateLock -ErrorAction Stop | Out-Null; $acquired = $true } catch { $acquired = $false }
- if ($acquired) {
- try {
- $env:BOATSTACK_MODE = "hydrate"
- $env:BOATSTACK_VERSION = "%s"
- $env:BOATSTACK_REPO = $root
- $hydrateCommand = $env:BOATSTACK_HYDRATE_COMMAND
- if (-not $hydrateCommand) {
- $hydrateCommand = 'irm https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.ps1 | iex'
- }
- & powershell -NoProfile -Command $hydrateCommand 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) }
- } catch {
- } finally {
- Remove-Item -LiteralPath $hydrateLock -Recurse -Force -ErrorAction SilentlyContinue
- }
- } else {
- # Wait for the peer to release the lock (it does so only after its hydrate
- # command returns) before inspecting the slot, so a waiter never observes a
- # half-written runtime. The authoritative gates below then accept it or fail
- # closed. Bound the wait above the peer's own hydrate timeout.
- for ($i = 0; $i -lt 12; $i++) {
- if (-not (Test-Path -LiteralPath $hydrateLock)) { break }
- Start-Sleep -Seconds 1
- }
- }
-}
-# A ready-looking slot is not published until its writer releases the lock.
-for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $hydrateLock); $i++) { Start-Sleep -Seconds 1 }
-if (Test-Path -LiteralPath $hydrateLock) {
- Bs-Deny "Boatstack shared runtime hydration did not complete; denying tool execution."
- exit 2
-}
-if (-not (Test-Path -LiteralPath $helper -PathType Leaf)) {
- Bs-Deny "Boatstack shared runtime is missing; run the verified installer once from any checkout in this Git clone:"
- [Console]::Error.WriteLine(" %s")
- exit 2
-}
-$helperInfo = Get-Item -LiteralPath $helper
-if (($helperInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) -or -not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
- Bs-Deny "Boatstack shared runtime is unsafe or incomplete; rerun the verified tagged installer."
- exit 2
-}
-$manifestInfo = Get-Item -LiteralPath $manifestPath
-if ($manifestInfo.Attributes -band [IO.FileAttributes]::ReparsePoint) {
- Bs-Deny "Boatstack shared runtime manifest is unsafe; rerun the verified tagged installer."
- exit 2
-}
-try {
- $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
- $actual = (Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash.ToLowerInvariant()
-} catch {
- Bs-Deny "Boatstack could not verify the shared runtime; denying tool execution."
- exit 2
-}
-if (-not $manifest.binary_sha256 -or $actual -ne $manifest.binary_sha256.ToLowerInvariant()) {
- Bs-Deny "Boatstack shared runtime checksum is invalid; rerun the verified tagged installer."
- exit 2
-}
-& $helper bootstrap-safety-hook --host $HostName --repo $root
-exit $LASTEXITCODE
-`, Version, SourceCommit, Version, SourceCommit, Version, Version, Version, runtimeHydrateCommandPowerShell(Version)))
-}
-
-func hookCommand(host string) string {
- if host == "claude" {
- return `bash "${CLAUDE_PROJECT_DIR}/.product-loop/hooks/guard.sh" claude`
- }
- return `bash "$(git rev-parse --show-toplevel)/.product-loop/hooks/guard.sh" ` + host
-}
-
-func hookCommandWindows(host string) string {
- return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$r = & git rev-parse --show-toplevel; & (Join-Path $r '.product-loop/hooks/guard.ps1') ` + host + `"`
-}
-
-func desiredHostHookForEvent(host, event string) map[string]any {
- switch host {
- case "cursor":
- entry := map[string]any{
- "command": hookCommand(host), "commandWindows": hookCommandWindows(host),
- "failClosed": true, "timeout": 10,
- }
- if event == "preToolUse" || event == "postToolUse" {
- entry["matcher"] = "Write|Edit|ApplyPatch|Create|Delete|Move|Rename"
- }
- return entry
- case "claude":
- return map[string]any{
- "matcher": "Bash|Shell|Write|Edit|ApplyPatch|Create|Delete|Move|Rename|mcp__.*",
- "hooks": []any{map[string]any{
- "type": "command", "command": hookCommand(host),
- "shell": "bash", "timeout": 10,
- }},
- }
- case "codex":
- return map[string]any{
- "matcher": "Bash|Shell|Write|Edit|ApplyPatch|Create|Delete|Move|Rename|mcp__.*",
- "hooks": []any{map[string]any{
- "type": "command", "command": hookCommand(host), "commandWindows": hookCommandWindows(host),
- "timeout": 10,
- }},
- }
- case "gemini":
- return map[string]any{
- "matcher": ".*", "sequential": true,
- "hooks": []any{map[string]any{
- "name": "boatstack-engagement-probe", "type": "command", "command": hookCommand(host),
- "timeout": 10000,
- }},
- }
- default:
- return map[string]any{}
- }
-}
-
-func hookEvents(host string) []string {
- if host == "cursor" {
- return []string{"preToolUse", "postToolUse", "postToolUseFailure", "beforeShellExecution", "afterShellExecution", "beforeMCPExecution", "afterMCPExecution"}
- }
- if host == "gemini" {
- return []string{"BeforeTool", "AfterTool"}
- }
- if host == "claude" {
- return []string{"PreToolUse", "PostToolUse", "PostToolUseFailure"}
- }
- return []string{"PreToolUse", "PostToolUse"}
-}
-
-func desiredHostHook(host string) map[string]any {
- return desiredHostHookForEvent(host, hookEvents(host)[0])
-}
-
-func hookFragmentJSON(host string) ([]byte, error) {
- events := map[string]any{}
- for _, event := range hookEvents(host) {
- events[event] = desiredHostHookForEvent(host, event)
- }
- return GeneratedJSON(map[string]any{"schema_version": 1, "host": host, "events": events})
-}
-
-func hostHookConfigPath(repo, host string) string {
- switch host {
- case "cursor":
- return filepath.Join(repo, ".cursor", "hooks.json")
- case "claude":
- return filepath.Join(repo, ".claude", "settings.json")
- case "codex":
- return filepath.Join(repo, ".codex", "hooks.json")
- case "gemini":
- return filepath.Join(repo, ".gemini", "settings.json")
- default:
- return ""
- }
-}
-
-func HostHookPaths(adapters []string) []string {
- paths := []string{}
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if contains(adapters, host) {
- paths = append(paths, filepath.ToSlash(strings.TrimPrefix(hostHookConfigPath("", host), string(filepath.Separator))))
- }
- }
- return paths
-}
-
-func containsBoatstackHook(value any) bool {
- switch typed := value.(type) {
- case string:
- return strings.Contains(typed, hookCommandMarker)
- case []any:
- for _, item := range typed {
- if containsBoatstackHook(item) {
- return true
- }
- }
- case map[string]any:
- for _, item := range typed {
- if containsBoatstackHook(item) {
- return true
- }
- }
- }
- return false
-}
-
-func validateBoatstackHookEntry(host, event string, value any) error {
- entry, ok := value.(map[string]any)
- if !ok {
- return fmt.Errorf("%s Boatstack hook for %s is not an object", host, event)
- }
- allowedOuter := map[string]bool{}
- if host == "cursor" {
- allowedOuter = map[string]bool{"command": true, "commandWindows": true, "failClosed": true, "timeout": true, "matcher": true}
- if stringValue(entry["command"]) == "" {
- return fmt.Errorf("cursor Boatstack hook for %s has no command", event)
- }
- if entry["failClosed"] != true {
- return fmt.Errorf("cursor Boatstack hook for %s must fail closed", event)
- }
- } else {
- allowedOuter = map[string]bool{"matcher": true, "hooks": true}
- if host == "gemini" {
- allowedOuter["sequential"] = true
- }
- if stringValue(entry["matcher"]) == "" {
- return fmt.Errorf("%s Boatstack hook for %s has no matcher", host, event)
- }
- handlers, ok := entry["hooks"].([]any)
- if !ok || len(handlers) != 1 {
- return fmt.Errorf("%s Boatstack hook for %s must contain exactly one command handler", host, event)
- }
- handler, ok := handlers[0].(map[string]any)
- if !ok || handler["type"] != "command" || stringValue(handler["command"]) == "" {
- return fmt.Errorf("%s Boatstack hook for %s has an invalid command handler", host, event)
- }
- allowedHandler := map[string]bool{"type": true, "command": true, "timeout": true, "statusMessage": true}
- if host == "claude" {
- allowedHandler["shell"] = true
- // Older installed Boatstack fragments relied on Claude's Bash default.
- // Accept that structurally during update preflight, while every newly
- // generated hook pins the documented shell explicitly.
- if handler["shell"] != nil && handler["shell"] != "bash" {
- return fmt.Errorf("claude Boatstack hook for %s must use the bash harness", event)
- }
- } else if host == "codex" {
- allowedHandler["commandWindows"] = true
- } else if host == "gemini" {
- allowedHandler["name"] = true
- allowedHandler["description"] = true
- }
- for key := range handler {
- if !allowedHandler[key] {
- return fmt.Errorf("%s Boatstack hook for %s contains unsupported handler field %s", host, event, key)
- }
- }
- }
- for key := range entry {
- if !allowedOuter[key] {
- return fmt.Errorf("%s Boatstack hook for %s contains unsupported field %s", host, event, key)
- }
- }
- return nil
-}
-
-func validateHostHookConfig(host string, config map[string]any) error {
- if host == "cursor" && config["version"] != float64(1) {
- return fmt.Errorf("Cursor hook config version must be 1")
- }
- hooks, ok := config["hooks"].(map[string]any)
- if !ok {
- return fmt.Errorf("host hook config has non-object hooks")
- }
- expected := map[string]bool{}
- for _, event := range hookEvents(host) {
- expected[event] = true
- }
- for event, entries := range hooks {
- if containsBoatstackHook(entries) && !expected[event] {
- return fmt.Errorf("%s Boatstack hook is attached to unsupported event %s", host, event)
- }
- }
- return nil
-}
-
-func loadHookConfig(path string) (map[string]any, error) {
- value, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return map[string]any{}, nil
- }
- if err != nil {
- return nil, err
- }
- config := map[string]any{}
- if err := DecodeJSON("load host hook configuration", path, value, &config); err != nil {
- return nil, err
- }
- return config, nil
-}
-
-func mergeHostHook(config map[string]any, host string) error {
- return mergeHostHookWithOwnership(config, host, nil, false)
-}
-
-func mergeHostHookWithOwnership(config map[string]any, host string, installed map[string]any, repair bool) error {
- hooks, ok := config["hooks"].(map[string]any)
- if config["hooks"] == nil {
- hooks = map[string]any{}
- config["hooks"] = hooks
- } else if !ok {
- return fmt.Errorf("host hook config has non-object hooks")
- }
- desiredEvents := map[string]bool{}
- for _, event := range hookEvents(host) {
- desiredEvents[event] = true
- }
- // Retired events are an ordinary template migration when every Boatstack
- // entry exactly matches the committed fragment from the installed release.
- // The incoming helper may remove those entries before validating its own
- // event set; otherwise the updater would be blocked by the state it owns.
- for event, raw := range hooks {
- if desiredEvents[event] || !containsBoatstackHook(raw) {
- continue
- }
- entries, entriesOK := raw.([]any)
- if !entriesOK {
- return fmt.Errorf("host hook event %s is not a list", event)
- }
- kept := []any{}
- owned := 0
- verified := installed != nil && installed[event] != nil
- for _, entry := range entries {
- if !containsBoatstackHook(entry) {
- kept = append(kept, entry)
- continue
- }
- owned++
- verified = verified && sameJSON(entry, installed[event])
- }
- if owned > 0 && !verified && !repair {
- return fmt.Errorf("%s Boatstack hook is attached to unsupported event %s; rerun the update with --repair only after reviewing the owned-state preview", host, event)
- }
- if len(kept) == 0 {
- delete(hooks, event)
- } else {
- hooks[event] = kept
- }
- }
- for _, event := range hookEvents(host) {
- entries := []any{}
- if existing := hooks[event]; existing != nil {
- var entriesOK bool
- entries, entriesOK = existing.([]any)
- if !entriesOK {
- return fmt.Errorf("host hook event %s is not a list", event)
- }
- }
- kept := []any{}
- found := 0
- verified := true
- for _, entry := range entries {
- if containsBoatstackHook(entry) {
- found++
- isDesired := sameJSON(entry, desiredHostHookForEvent(host, event))
- isInstalled := installed != nil && installed[event] != nil && sameJSON(entry, installed[event])
- if installed != nil && !isDesired && !isInstalled {
- verified = false
- }
- if installed == nil {
- if err := validateBoatstackHookEntry(host, event, entry); err != nil {
- return err
- }
- }
- continue
- }
- kept = append(kept, entry)
- }
- if found > 1 && (installed == nil || !verified) && !repair {
- return fmt.Errorf("ambiguous Boatstack hook collision in %s", event)
- }
- if found > 0 && !verified && !repair {
- return fmt.Errorf("drifted %s Boatstack engagement probe for %s; rerun the update with --repair only after reviewing the owned-state preview", host, event)
- }
- kept = append(kept, desiredHostHookForEvent(host, event))
- hooks[event] = kept
- }
- if host == "cursor" && config["version"] == nil {
- config["version"] = float64(1)
- }
- return validateHostHookConfig(host, config)
-}
-
-func InstallHostHooks(repo string, adapters []string) error {
- prepared, err := PrepareHostHooks(repo, adapters)
- if err != nil {
- return err
- }
- for _, path := range sortedKeys(prepared) {
- if err := atomicWrite(path, prepared[path]); err != nil {
- return err
- }
- }
- return nil
-}
-
-// PrepareHostHooks renders and validates every selected host document without
-// writing, allowing initialization to fail before entering its commit phase.
-func PrepareHostHooks(repo string, adapters []string) (map[string][]byte, error) {
- return prepareHostHooks(repo, adapters, false)
-}
-
-func PrepareHostHooksForUpdate(repo string, adapters []string, repair bool) (map[string][]byte, error) {
- return prepareHostHooks(repo, adapters, repair)
-}
-
-func prepareHostHooks(repo string, adapters []string, repair bool) (map[string][]byte, error) {
- prepared := map[string][]byte{}
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if !contains(adapters, host) {
- continue
- }
- path := hostHookConfigPath(repo, host)
- config, err := loadHookConfig(path)
- if err != nil {
- return nil, err
- }
- var installed map[string]any
- if repair || fileExists(filepath.Join(repo, ".product-loop", "hooks", host+".fragment.json")) {
- installed, err = loadInstalledHookEvents(repo, host)
- if err != nil {
- installed, err = loadCommittedInstalledHookEvents(repo, host)
- if err != nil {
- return nil, fmt.Errorf("prepare %s host hooks: cannot verify installed ownership: %w", host, err)
- }
- }
- }
- if err := mergeHostHookWithOwnership(config, host, installed, repair); err != nil {
- return nil, fmt.Errorf("prepare %s host hooks in %s: %w", host, path, err)
- }
- value, err := MarshalJSON(config)
- if err != nil {
- return nil, fmt.Errorf("serialize merged host hook configuration %s: %w", path, err)
- }
- if err := ValidateJSON("validate merged host hook configuration", path, value); err != nil {
- return nil, err
- }
- prepared[path] = value
- }
- return prepared, nil
-}
-
-func InstallHostHooksForUpdate(repo string, adapters []string, repair bool) error {
- prepared, err := PrepareHostHooksForUpdate(repo, adapters, repair)
- if err != nil {
- return err
- }
- for _, path := range sortedKeys(prepared) {
- if err := atomicWrite(path, prepared[path]); err != nil {
- return err
- }
- }
- return nil
-}
-
-func CheckHostHooks(repo string, adapters []string) error {
- return checkHostHooks(repo, adapters, func(host, event string) (any, error) {
- return desiredHostHookForEvent(host, event), nil
- })
-}
-
-// CheckInstalledHostHooks validates merged host settings against the committed
-// fragment from the installed release. Update preflight must use this boundary:
-// comparing an old, healthy hook with the incoming release template would
-// misclassify an intentional template migration as user drift.
-func CheckInstalledHostHooks(repo string, adapters []string) error {
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if !contains(adapters, host) {
- continue
- }
- installed, err := loadInstalledHookEvents(repo, host)
- if err != nil {
- installed, err = loadCommittedInstalledHookEvents(repo, host)
- if err != nil {
- return fmt.Errorf("cannot read installed %s hook fragment: %w", host, err)
- }
- }
- config, err := loadHookConfig(hostHookConfigPath(repo, host))
- if err != nil {
- return err
- }
- hooks, ok := config["hooks"].(map[string]any)
- if !ok {
- return fmt.Errorf("missing %s hooks", host)
- }
- for event, expected := range installed {
- entries, ok := hooks[event].([]any)
- if !ok {
- return fmt.Errorf("missing installed %s safety event %s", host, event)
- }
- matches := 0
- for _, entry := range entries {
- if containsBoatstackHook(entry) {
- matches++
- if !sameJSON(entry, expected) {
- return fmt.Errorf("drifted %s Boatstack safety hook", host)
- }
- }
- }
- if matches < 1 {
- return fmt.Errorf("expected an installed %s Boatstack safety hook for %s; found %d", host, event, matches)
- }
- }
- for event, raw := range hooks {
- if installed[event] == nil && containsBoatstackHook(raw) {
- return fmt.Errorf("drifted %s Boatstack safety hook on unowned event %s", host, event)
- }
- }
- }
- return nil
-}
-
-func checkHostHooks(repo string, adapters []string, expectedForEvent func(host, event string) (any, error)) error {
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if !contains(adapters, host) {
- continue
- }
- path := hostHookConfigPath(repo, host)
- config, err := loadHookConfig(path)
- if err != nil {
- return err
- }
- if err := validateHostHookConfig(host, config); err != nil {
- return err
- }
- hooks, ok := config["hooks"].(map[string]any)
- if !ok {
- return fmt.Errorf("missing %s hooks in %s", host, path)
- }
- for _, event := range hookEvents(host) {
- expectedEntry, err := expectedForEvent(host, event)
- if err != nil {
- return err
- }
- if err := validateBoatstackHookEntry(host, event, expectedEntry); err != nil {
- return err
- }
- entries, ok := hooks[event].([]any)
- if !ok {
- return fmt.Errorf("missing %s safety event %s in %s", host, event, path)
- }
- matches := 0
- for _, entry := range entries {
- if containsBoatstackHook(entry) {
- matches++
- if err := validateBoatstackHookEntry(host, event, entry); err != nil {
- return err
- }
- current, _ := json.Marshal(entry)
- expected, _ := json.Marshal(expectedEntry)
- if string(current) != string(expected) {
- return fmt.Errorf("drifted %s Boatstack safety hook", host)
- }
- }
- }
- if matches != 1 {
- return fmt.Errorf("expected exactly one %s Boatstack safety hook for %s; found %d", host, event, matches)
- }
- }
- }
- return nil
-}
diff --git a/boatstack/hooks_hydrate_test.go b/boatstack/hooks_hydrate_test.go
deleted file mode 100644
index f64ad8a..0000000
--- a/boatstack/hooks_hydrate_test.go
+++ /dev/null
@@ -1,400 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-func requireBash(t *testing.T) {
- t.Helper()
- if _, err := exec.LookPath("bash"); err != nil {
- t.Skip("bash unavailable")
- }
-}
-
-// runGuard executes the installed bash guard for a host with a canonical,
-// read-only event, returning its combined output and exit error.
-func runGuard(t *testing.T, repo, host string, env ...string) (string, error) {
- t.Helper()
- guard := filepath.Join(repo, ".product-loop", "hooks", "guard.sh")
- cmd := exec.Command("bash", guard, host)
- cmd.Dir = repo
- cmd.Stdin = strings.NewReader(`{"tool_name":"Bash","tool_input":{"command":"git status --short"}}`)
- cmd.Env = append(os.Environ(), env...)
- output, err := cmd.CombinedOutput()
- return string(output), err
-}
-
-// emptySharedSlot deletes the version-keyed shared runtime, modeling a teammate
-// who pulled a version bump (or cloned fresh) and holds the committed pointers
-// but no runtime bytes.
-func emptySharedSlot(t *testing.T, repo string) (binaryPath, manifestPath string) {
- t.Helper()
- var err error
- binaryPath, manifestPath, err = sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.RemoveAll(filepath.Dir(binaryPath)); err != nil {
- t.Fatal(err)
- }
- return binaryPath, manifestPath
-}
-
-// stageVerifiedHelper empties the slot and stages a tiny, checksum-consistent
-// helper + manifest in a backup directory, returning a shell command that
-// restores them into the slot — exactly what the real installer produces after
-// download and checksum verification. The staged helper answers the guard's
-// bootstrap-safety-hook exec by emitting a sentinel and allowing.
-func stageVerifiedHelper(t *testing.T, repo string) (binaryPath, manifestPath, restoreCommand string) {
- t.Helper()
- binaryPath, manifestPath = emptySharedSlot(t, repo)
- backupDir := t.TempDir()
- fakeHelper := []byte("#!/usr/bin/env bash\necho boatstack-guard-hydration-sentinel >&2\nexit 0\n")
- backupHelper := filepath.Join(backupDir, filepath.Base(binaryPath))
- backupManifest := filepath.Join(backupDir, filepath.Base(manifestPath))
- if err := os.WriteFile(backupHelper, fakeHelper, 0o755); err != nil {
- t.Fatal(err)
- }
- manifest := []byte(fmt.Sprintf(`{"binary_sha256":"%s"}`, SHA256Bytes(fakeHelper)))
- if err := os.WriteFile(backupManifest, manifest, 0o644); err != nil {
- t.Fatal(err)
- }
- restoreCommand = fmt.Sprintf("mkdir -p %q && cp -p %q %q && cp -p %q %q",
- filepath.Dir(binaryPath),
- backupHelper, binaryPath,
- backupManifest, manifestPath)
- return binaryPath, manifestPath, restoreCommand
-}
-
-// TestGuardAutoHydratesMissingSharedRuntimeThenProceeds is the headline
-// behavior: an absent slot self-heals through the verified hydrator and the
-// guard clears every gate and reaches exec — a teammate never sees a deny.
-func TestGuardAutoHydratesMissingSharedRuntimeThenProceeds(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- binaryPath, _, restore := stageVerifiedHelper(t, repo)
-
- output, err := runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND="+restore)
- if err != nil {
- t.Fatalf("guard did not proceed after auto-hydration: err=%v output=%s", err, output)
- }
- if strings.Contains(output, "shared runtime is missing") {
- t.Fatalf("guard reported a missing runtime despite successful hydration: %s", output)
- }
- if !strings.Contains(output, "boatstack-guard-hydration-sentinel") {
- t.Fatalf("guard did not exec the hydrated helper: %s", output)
- }
- if _, statErr := os.Stat(binaryPath); statErr != nil {
- t.Fatalf("shared slot was not populated: %v", statErr)
- }
-}
-
-// TestGuardFailsClosedWhenAutoHydrationFails proves hydration is purely
-// additive: a failed installer never falls open; the existing missing-slot deny
-// remains authoritative and now carries the exact self-heal command.
-func TestGuardFailsClosedWhenAutoHydrationFails(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- emptySharedSlot(t, repo)
-
- output, err := runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND=exit 1")
- if err == nil {
- t.Fatalf("guard did not fail closed after a failed hydration: %s", output)
- }
- if !strings.Contains(output, "shared runtime is missing") {
- t.Fatalf("guard did not emit the fail-closed deny: %s", output)
- }
- if !strings.Contains(output, "BOATSTACK_MODE=hydrate BOATSTACK_VERSION="+Version) {
- t.Fatalf("deny did not embed the pinned installer command: %s", output)
- }
-}
-
-// TestGuardSkipsAutoHydrationWhenDisabled proves the kill switch: with
-// BOATSTACK_AUTO_HYDRATE=0 the guard denies immediately and never runs the
-// hydrator.
-func TestGuardSkipsAutoHydrationWhenDisabled(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- emptySharedSlot(t, repo)
- sentinel := filepath.Join(t.TempDir(), "invoked")
- stub := fmt.Sprintf("touch %q", sentinel)
-
- output, err := runGuard(t, repo, "claude", "BOATSTACK_AUTO_HYDRATE=0", "BOATSTACK_HYDRATE_COMMAND="+stub)
- if err == nil {
- t.Fatalf("disabled guard did not deny: %s", output)
- }
- if !strings.Contains(output, "shared runtime is missing") {
- t.Fatalf("disabled guard did not emit the deny: %s", output)
- }
- if _, statErr := os.Stat(sentinel); statErr == nil {
- t.Fatal("auto-hydration ran despite BOATSTACK_AUTO_HYDRATE=0")
- }
-}
-
-// TestGuardAutoHydrationInvokesPinnedHydrator proves the hydrator is always
-// invoked with the worktree's pinned provenance — never a floating version.
-func TestGuardAutoHydrationInvokesPinnedHydrator(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- emptySharedSlot(t, repo)
- record := filepath.Join(t.TempDir(), "env.txt")
- stub := fmt.Sprintf(`printf '%%s\n' "$BOATSTACK_MODE" "$BOATSTACK_VERSION" "$BOATSTACK_REPO" > %q`, record)
-
- // The stub records env then leaves the slot empty, so the guard denies after;
- // we only assert on how the hydrator was invoked.
- runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND="+stub)
-
- data, err := os.ReadFile(record)
- if err != nil {
- t.Fatalf("hydrator was not invoked: %v", err)
- }
- lines := strings.Split(strings.TrimSpace(string(data)), "\n")
- if len(lines) < 3 {
- t.Fatalf("hydrator recorded incomplete provenance: %q", data)
- }
- if lines[0] != "hydrate" || lines[1] != Version {
- t.Fatalf("hydrator invoked with wrong provenance: mode=%q version=%q (want hydrate/%s)", lines[0], lines[1], Version)
- }
- gotRepo, _ := filepath.EvalSymlinks(lines[2])
- wantRepo, _ := filepath.EvalSymlinks(repo)
- if gotRepo != wantRepo {
- t.Fatalf("hydrator repo = %q, want %q", lines[2], repo)
- }
-}
-
-// TestGuardAutoHydrationSerializesConcurrentFirstUse proves the clone-wide lock:
-// two guards racing an absent slot invoke the hydrator at most once, and both
-// still proceed.
-// TestGuardAutoHydrationSerializesConcurrentFirstUse is a bounded conformance
-// test for the clone-wide serialization invariant: when many guards hit an empty
-// slot at once, exactly one hydrates and every guard proceeds. A start barrier
-// releases all guards together to force real contention on the mkdir lock. The
-// invariant holds for any interleaving because each losing guard waits for the
-// winner to release the lock before it inspects the slot, so no guard observes a
-// half-written runtime. Bounded: a fixed fan-out, a single round.
-func TestGuardAutoHydrationSerializesConcurrentFirstUse(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- binaryPath, _, restore := stageVerifiedHelper(t, repo)
- counter := filepath.Join(t.TempDir(), "count")
- stub := fmt.Sprintf("echo x >> %q && %s", counter, restore)
-
- const guards = 8
- start := make(chan struct{})
- var wg sync.WaitGroup
- outputs := make([]string, guards)
- errs := make([]error, guards)
- for i := 0; i < guards; i++ {
- wg.Add(1)
- go func(idx int) {
- defer wg.Done()
- <-start // release every guard together for genuine contention
- outputs[idx], errs[idx] = runGuard(t, repo, "claude", "BOATSTACK_HYDRATE_COMMAND="+stub)
- }(i)
- }
- close(start)
- wg.Wait()
-
- for i := range errs {
- if errs[i] != nil {
- t.Fatalf("guard %d did not proceed under contention: err=%v output=%s", i, errs[i], outputs[i])
- }
- }
- if _, statErr := os.Stat(binaryPath); statErr != nil {
- t.Fatalf("shared slot was not populated under contention: %v", statErr)
- }
- data, err := os.ReadFile(counter)
- if err != nil {
- t.Fatal(err)
- }
- if got := strings.Count(string(data), "x"); got != 1 {
- t.Fatalf("hydrator ran %d times under contention, want exactly 1", got)
- }
-}
-
-// hydrateLockPath returns the clone-wide hydrate lock the guard uses, derived
-// from the shared binary path: /boatstack/hydrate-.lock.
-func hydrateLockPath(t *testing.T, binaryPath string) string {
- t.Helper()
- // binaryPath = /boatstack/runtimes////boatstack-helper
- bsCommon := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(binaryPath)))))
- return filepath.Join(bsCommon, "hydrate-"+Version+".lock")
-}
-
-// TestGuardAutoHydrationWaiterAwaitsPeerCompletion is a bounded, deterministic
-// regression test for the exact failure mode that flaked in CI: a waiting guard
-// used to break as soon as the helper file appeared and then fail the manifest
-// gate ("unsafe or incomplete") while the peer was still mid-copy. Here a peer
-// holds the lock and hydrates non-atomically — it writes the helper, pauses, then
-// writes the manifest, then releases the lock, exactly the installer's copy order.
-// The waiting guard must not judge the slot until the peer releases the lock, so
-// it proceeds cleanly. Before the fix this test fails; after it, it passes on any
-// timing.
-func TestGuardAutoHydrationWaiterAwaitsPeerCompletion(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- binaryPath, manifestPath := emptySharedSlot(t, repo)
- lockDir := hydrateLockPath(t, binaryPath)
-
- // A peer already holds the clone-wide hydrate lock.
- if err := os.MkdirAll(lockDir, 0o755); err != nil {
- t.Fatal(err)
- }
-
- fakeHelper := []byte("#!/usr/bin/env bash\necho boatstack-guard-hydration-sentinel >&2\nexit 0\n")
- manifestBytes := []byte(fmt.Sprintf(`{"binary_sha256":"%s"}`, SHA256Bytes(fakeHelper)))
-
- peerDone := make(chan struct{})
- go func() {
- defer close(peerDone)
- // Let the guard reach its waiter loop while the slot is still empty.
- time.Sleep(300 * time.Millisecond)
- if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil {
- return
- }
- // The helper appears first — the non-atomic window that broke the old waiter.
- if err := os.WriteFile(binaryPath, fakeHelper, 0o755); err != nil {
- return
- }
- time.Sleep(1 * time.Second)
- // The manifest lands only now; the slot becomes complete.
- if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
- return
- }
- // Release the lock last, signaling completion.
- _ = os.Remove(lockDir)
- }()
-
- // The guard finds the lock held and the helper absent, so it enters the waiter
- // branch. It must wait for the peer to release the lock, then clear every gate.
- output, err := runGuard(t, repo, "claude")
- <-peerDone
- if err != nil {
- t.Fatalf("waiter judged a slot mid-hydration instead of awaiting the peer: err=%v output=%s", err, output)
- }
- if _, statErr := os.Stat(binaryPath); statErr != nil {
- t.Fatalf("shared slot was not populated after the peer finished: %v", statErr)
- }
-}
-
-// TestGuardAutoHydrationWaitsWhenSlotHalfWritten is a bounded, deterministic
-// regression test for the skip-path variant of the same failure mode. A guard
-// that judged readiness by the helper alone would, on a half-written slot (helper
-// present, manifest not yet), skip the hydrate/wait block entirely and deny at the
-// manifest gate — even while a peer held the lock and was about to finish. The
-// entry test now mirrors the gates (helper AND manifest), so such a guard joins
-// the lock and waits instead. Before the fix this test fails with the exact CI
-// error; after it, it passes.
-func TestGuardAutoHydrationWaitsWhenSlotHalfWritten(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- binaryPath, manifestPath := emptySharedSlot(t, repo)
- lockDir := hydrateLockPath(t, binaryPath)
-
- fakeHelper := []byte("#!/usr/bin/env bash\necho boatstack-guard-hydration-sentinel >&2\nexit 0\n")
- manifestBytes := []byte(fmt.Sprintf(`{"binary_sha256":"%s"}`, SHA256Bytes(fakeHelper)))
-
- // The slot is half-written — the helper is present but the manifest is not —
- // and a peer holds the hydrate lock because it is still mid-copy.
- if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(binaryPath, fakeHelper, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(lockDir, 0o755); err != nil {
- t.Fatal(err)
- }
-
- peerDone := make(chan struct{})
- go func() {
- defer close(peerDone)
- time.Sleep(1 * time.Second)
- if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
- return
- }
- _ = os.Remove(lockDir)
- }()
-
- // A guard seeing only the helper must not treat the slot as ready; it must join
- // the lock, wait for the peer to finish, then clear every gate.
- output, err := runGuard(t, repo, "claude")
- <-peerDone
- if err != nil {
- t.Fatalf("guard skipped hydration on a half-written slot instead of waiting: err=%v output=%s", err, output)
- }
-}
-
-func TestGuardWaitsForHydrationPublicationBeforeManifestValidation(t *testing.T) {
- requireBash(t)
- repo := runtimeTestRepo(t)
- binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- helperBytes := []byte("#!/usr/bin/env bash\nexit 0\n")
- manifestBytes := []byte(fmt.Sprintf(`{"binary_sha256":"%s"}`, SHA256Bytes(helperBytes)))
- if err := os.RemoveAll(filepath.Dir(binaryPath)); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(binaryPath, helperBytes, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(manifestPath, []byte("{}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockPath := hydrateLockPath(t, binaryPath)
- if err := os.MkdirAll(lockPath, 0o755); err != nil {
- t.Fatal(err)
- }
- defer os.RemoveAll(lockPath)
-
- type guardResult struct {
- output string
- err error
- }
- result := make(chan guardResult, 1)
- go func() {
- output, runErr := runGuard(t, repo, "claude")
- result <- guardResult{output: output, err: runErr}
- }()
- select {
- case early := <-result:
- t.Fatalf("guard validated before hydration publication: err=%v output=%s", early.err, early.output)
- case <-time.After(300 * time.Millisecond):
- }
-
- if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.Remove(lockPath); err != nil {
- t.Fatal(err)
- }
- select {
- case completed := <-result:
- if completed.err != nil {
- t.Fatalf("guard did not accept the published runtime: err=%v output=%s", completed.err, completed.output)
- }
- case <-time.After(15 * time.Second):
- t.Fatal("guard did not resume after hydration publication")
- }
-}
-
-func TestPowerShellGuardWaitsForPublicationBeforeManifestValidation(t *testing.T) {
- script := string(guardPowerShellScript())
- barrier := strings.Index(script, "for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $hydrateLock); $i++)")
- validation := strings.Index(script, "Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json")
- if barrier < 0 || validation < 0 || barrier >= validation {
- t.Fatalf("PowerShell guard lacks a pre-validation publication barrier: barrier=%d validation=%d", barrier, validation)
- }
-}
diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go
deleted file mode 100644
index 087ae9f..0000000
--- a/boatstack/hooks_test.go
+++ /dev/null
@@ -1,326 +0,0 @@
-package boatstack
-
-import (
- "context"
- "encoding/json"
- "errors"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestHostHookMergePreservesUnrelatedConfiguration(t *testing.T) {
- repo := t.TempDir()
- path := filepath.Join(repo, ".cursor", "hooks.json")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- initial := `{"version":1,"theme":"kept","hooks":{"beforeShellExecution":[{"command":"./existing-check.sh"}]}}`
- if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex", "gemini"}); err != nil {
- t.Fatal(err)
- }
- if err := CheckHostHooks(repo, []string{"cursor", "claude", "codex", "gemini"}); err != nil {
- t.Fatal(err)
- }
- value, _ := os.ReadFile(path)
- if !strings.Contains(string(value), `"theme": "kept"`) || !strings.Contains(string(value), "existing-check.sh") {
- t.Fatalf("hook merge discarded unrelated configuration: %s", value)
- }
- if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex", "gemini"}); err != nil {
- t.Fatalf("idempotent reinstall failed: %v", err)
- }
-}
-
-func TestHostHookMergeRejectsAmbiguousCollisionAndDrift(t *testing.T) {
- repo := t.TempDir()
- path := filepath.Join(repo, ".codex", "hooks.json")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- entry := desiredHostHook("codex")
- config := map[string]any{"hooks": map[string]any{"PreToolUse": []any{entry, entry}}}
- value, _ := MarshalJSON(config)
- if err := os.WriteFile(path, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := InstallHostHooks(repo, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
- t.Fatalf("expected ambiguous collision, got %v", err)
- }
- config = map[string]any{"hooks": map[string]any{"PreToolUse": []any{desiredHostHook("codex")}}}
- handler := config["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
- handler["timeout"] = float64(99)
- value, _ = MarshalJSON(config)
- if err := os.WriteFile(path, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CheckHostHooks(repo, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "drifted") {
- t.Fatalf("expected drifted fragment failure, got %v", err)
- }
-}
-
-func TestGeneratedHostHooksSatisfyHarnessShapes(t *testing.T) {
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- t.Run(host, func(t *testing.T) {
- for _, event := range hookEvents(host) {
- entry := desiredHostHookForEvent(host, event)
- if err := validateBoatstackHookEntry(host, event, entry); err != nil {
- t.Fatal(err)
- }
- if host == "claude" {
- handler := entry["hooks"].([]any)[0].(map[string]any)
- if handler["shell"] != "bash" || !strings.Contains(handler["command"].(string), "${CLAUDE_PROJECT_DIR}") {
- t.Fatalf("Claude hook does not use its documented project Bash harness: %#v", handler)
- }
- }
- if host == "codex" {
- handler := entry["hooks"].([]any)[0].(map[string]any)
- if stringValue(handler["commandWindows"]) == "" {
- t.Fatalf("Codex hook lacks commandWindows: %#v", handler)
- }
- }
- if host == "cursor" && event == "preToolUse" && !strings.Contains(stringValue(entry["matcher"]), "Write") {
- t.Fatalf("Cursor preToolUse does not supervise native writes: %#v", entry)
- }
- if host == "gemini" {
- handler := entry["hooks"].([]any)[0].(map[string]any)
- if entry["sequential"] != true || handler["timeout"] != 10000 {
- t.Fatalf("Gemini BeforeTool hook has an invalid fail-closed shape: %#v", entry)
- }
- }
- }
- })
- }
-}
-
-func TestHostHookValidationRejectsUnsupportedBoatstackFields(t *testing.T) {
- entry := desiredHostHook("claude")
- handler := entry["hooks"].([]any)[0].(map[string]any)
- handler["commandWindows"] = "unsupported"
- if err := validateBoatstackHookEntry("claude", "PreToolUse", entry); err == nil || !strings.Contains(err.Error(), "unsupported") {
- t.Fatalf("expected unsupported Claude field failure, got %v", err)
- }
-}
-
-func TestHostHookValidationRejectsWrongEventAndCursorVersion(t *testing.T) {
- codex := map[string]any{"hooks": map[string]any{"SessionEnd": []any{desiredHostHook("codex")}}}
- if err := validateHostHookConfig("codex", codex); err == nil || !strings.Contains(err.Error(), "unsupported event") {
- t.Fatalf("expected wrong Codex event failure, got %v", err)
- }
- cursor := map[string]any{"version": float64(2), "hooks": map[string]any{"beforeShellExecution": []any{desiredHostHook("cursor")}}}
- if err := validateHostHookConfig("cursor", cursor); err == nil || !strings.Contains(err.Error(), "version must be 1") {
- t.Fatalf("expected Cursor version failure, got %v", err)
- }
-}
-
-func TestInstalledHookValidationAllowsTemplateMigrationButRejectsUserDrift(t *testing.T) {
- repo := t.TempDir()
- adapters := []string{"claude"}
- if err := InstallHostHooks(repo, adapters); err != nil {
- t.Fatal(err)
- }
-
- fragment, err := hookFragmentJSON("claude")
- if err != nil {
- t.Fatal(err)
- }
- fragment = []byte(strings.ReplaceAll(string(fragment), `"timeout": 10`, `"timeout": 11`))
- fragmentPath := filepath.Join(repo, ".product-loop", "hooks", "claude.fragment.json")
- if err := os.MkdirAll(filepath.Dir(fragmentPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(fragmentPath, fragment, 0o644); err != nil {
- t.Fatal(err)
- }
-
- hookPath := filepath.Join(repo, ".claude", "settings.json")
- hookValue, err := os.ReadFile(hookPath)
- if err != nil {
- t.Fatal(err)
- }
- hookValue = []byte(strings.ReplaceAll(string(hookValue), `"timeout": 10`, `"timeout": 11`))
- if err := os.WriteFile(hookPath, hookValue, 0o644); err != nil {
- t.Fatal(err)
- }
-
- if err := CheckHostHooks(repo, adapters); err == nil || !strings.Contains(err.Error(), "drifted") {
- t.Fatalf("incoming template unexpectedly accepted the installed hook: %v", err)
- }
- if err := CheckInstalledHostHooks(repo, adapters); err != nil {
- t.Fatalf("healthy installed hook blocked template migration: %v", err)
- }
-
- hookValue = []byte(strings.ReplaceAll(string(hookValue), `"timeout": 11`, `"timeout": 99`))
- if err := os.WriteFile(hookPath, hookValue, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CheckInstalledHostHooks(repo, adapters); err == nil || !strings.Contains(err.Error(), "drifted") {
- t.Fatalf("user drift was not rejected against the installed fragment: %v", err)
- }
-}
-
-func TestMissingHelperLauncherIsSilentWhenDormantAndFailsClosedWhenActive(t *testing.T) {
- if _, err := exec.LookPath("bash"); err != nil {
- t.Skip("bash unavailable")
- }
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- path := filepath.Join(repo, ".product-loop", "hooks", "guard.sh")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, guardShellScript(), 0o755); err != nil {
- t.Fatal(err)
- }
- command := exec.Command("bash", path, "cursor")
- command.Dir = repo
- // Disable auto-hydration so this exercises the pure missing-slot deny without
- // reaching for the network; auto-hydration has its own dedicated subtests.
- command.Env = append(os.Environ(), "BOATSTACK_AUTO_HYDRATE=0")
- output, err := command.CombinedOutput()
- if err != nil || len(output) != 0 {
- t.Fatalf("dormant probe loaded the missing runtime: err=%v output=%s", err, output)
- }
-
- engageHookFixture(t, repo)
- command = exec.Command("bash", path, "cursor")
- command.Dir = repo
- command.Env = append(os.Environ(), "BOATSTACK_AUTO_HYDRATE=0")
- output, err = command.CombinedOutput()
- if err == nil || !strings.Contains(string(output), "shared runtime is missing") {
- t.Fatalf("active probe did not fail closed: err=%v output=%s", err, output)
- }
- // The deny is a one-line self-heal: it must embed the exact pinned installer.
- if !strings.Contains(string(output), "BOATSTACK_MODE=hydrate BOATSTACK_VERSION="+Version) {
- t.Fatalf("missing-slot deny did not embed the pinned installer command: %s", output)
- }
-}
-
-func TestDiagnoseHookAcceptsCanonicalEventsForEveryHost(t *testing.T) {
- repo := safetyTestRepo(t)
- previous := hookDiagnosticRunner
- defer func() { hookDiagnosticRunner = previous }()
- hookDiagnosticRunner = func(_ context.Context, _ string, host string, input []byte) ([]byte, error) {
- if len(input) == 0 {
- t.Fatal("diagnostic omitted canonical input")
- }
- if host == "cursor" {
- return []byte(`{"continue":true,"permission":"allow"}`), nil
- }
- if host == "gemini" {
- return []byte(`{"decision":"allow"}`), nil
- }
- return nil, nil
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- t.Run(host, func(t *testing.T) {
- diagnostic, err := DiagnoseHook(repo, host)
- if err != nil {
- t.Fatal(err)
- }
- if diagnostic.Host != host || diagnostic.ContractStatus != "PASS" || diagnostic.LiveEventObserved {
- t.Fatalf("unexpected diagnostic: %+v", diagnostic)
- }
- })
- }
-}
-
-func TestDiagnoseHookRejectsUnsupportedHost(t *testing.T) {
- repo := safetyTestRepo(t)
- if _, err := DiagnoseHook(repo, "other"); err == nil || !strings.Contains(err.Error(), "unsupported hook host") {
- t.Fatalf("unsupported host was not rejected: %v", err)
- }
-}
-
-func TestDiagnoseHookSupportsRepositoryPathsWithSpaces(t *testing.T) {
- repo := safetyTestRepo(t)
- renamed := repo + " with spaces"
- if err := os.Rename(repo, renamed); err != nil {
- t.Fatal(err)
- }
- previous := hookDiagnosticRunner
- defer func() { hookDiagnosticRunner = previous }()
- hookDiagnosticRunner = func(_ context.Context, observedRepo, _ string, _ []byte) ([]byte, error) {
- observedInfo, observedErr := os.Stat(observedRepo)
- wantedInfo, wantedErr := os.Stat(renamed)
- if observedErr != nil || wantedErr != nil || !os.SameFile(observedInfo, wantedInfo) {
- t.Fatalf("diagnostic repo = %q, want %q", observedRepo, renamed)
- }
- return []byte(`{"continue":true,"permission":"allow"}`), nil
- }
- if _, err := DiagnoseHook(renamed, "cursor"); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestHookDiagnosticRejectsMalformedAllowOutput(t *testing.T) {
- for _, test := range []struct {
- host, output string
- }{
- {"cursor", `{}`},
- {"cursor", `not-json`},
- {"claude", `{}`},
- {"codex", `unexpected`},
- {"gemini", `{}`},
- } {
- if err := validateCanonicalHookOutput(test.host, []byte(test.output)); err == nil {
- t.Fatalf("%s malformed output was accepted: %q", test.host, test.output)
- }
- }
-}
-
-func TestDiagnoseHookReportsGuardRuntimeFailures(t *testing.T) {
- repo := safetyTestRepo(t)
- previous := hookDiagnosticRunner
- defer func() { hookDiagnosticRunner = previous }()
- for _, message := range []string{"Boatstack shared runtime is missing", "Boatstack shared runtime checksum is invalid"} {
- hookDiagnosticRunner = func(_ context.Context, _ string, _ string, _ []byte) ([]byte, error) {
- return []byte(message), errors.New("exit status 2")
- }
- if _, err := DiagnoseHook(repo, "cursor"); err == nil || !strings.Contains(err.Error(), message) {
- t.Fatalf("runtime failure %q was not diagnosed: %v", message, err)
- }
- }
-}
-
-func TestGuardRejectsTamperedSharedRuntimeBeforeExecution(t *testing.T) {
- if _, err := exec.LookPath("bash"); err != nil {
- t.Skip("bash unavailable")
- }
- repo := runtimeTestRepo(t)
- binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(binaryPath, []byte("tampered"), 0o755); err != nil {
- t.Fatal(err)
- }
- path := filepath.Join(repo, ".product-loop", "hooks", "guard.sh")
- command := exec.Command("bash", path, "claude")
- command.Dir = repo
- command.Stdin = strings.NewReader(`{"tool_name":"Bash","tool_input":{"command":"git status --short"}}`)
- output, runErr := command.CombinedOutput()
- if runErr == nil || !strings.Contains(string(output), "checksum is invalid") {
- t.Fatalf("tampered shared helper was not denied before execution: err=%v output=%s", runErr, output)
- }
-}
-
-func TestHookFragmentsAreValidJSON(t *testing.T) {
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- value, err := hookFragmentJSON(host)
- if err != nil {
- t.Fatal(err)
- }
- var decoded map[string]any
- if err := json.Unmarshal(value, &decoded); err != nil {
- t.Fatalf("%s fragment is invalid JSON: %v", host, err)
- }
- if !strings.Contains(string(value), hookCommandMarker) {
- t.Fatalf("%s fragment lacks Boatstack marker", host)
- }
- }
-}
diff --git a/boatstack/hydrate_runtime_test.go b/boatstack/hydrate_runtime_test.go
deleted file mode 100644
index 6ecf005..0000000
--- a/boatstack/hydrate_runtime_test.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-// TestRunHydrateRuntimePopulatesSlotIdempotentlyWithoutTouchingCommittedState
-// models a teammate's clone after a version bump: the committed pointers exist
-// but both the shared slot and the worktree bin are empty. Hydration must
-// repopulate both from the running binary, be safe to repeat, and never rewrite
-// any committed generated file (the property that separates it from `update`).
-func TestRunHydrateRuntimePopulatesSlotIdempotentlyWithoutTouchingCommittedState(t *testing.T) {
- repo := runtimeTestRepo(t)
- binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.RemoveAll(filepath.Dir(binaryPath)); err != nil {
- t.Fatal(err)
- }
- if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil {
- t.Fatal(err)
- }
-
- before := readGeneratedLockBytes(t, repo)
-
- if err := RunHydrateRuntime(repo); err != nil {
- t.Fatalf("hydration failed: %v", err)
- }
- if _, err := os.Stat(binaryPath); err != nil {
- t.Fatalf("hydration did not populate the shared slot: %v", err)
- }
- if err := verifyLocalRuntime(repo); err != nil {
- t.Fatalf("hydration did not populate a verified worktree runtime: %v", err)
- }
- if err := Doctor(repo); err != nil {
- t.Fatalf("hydrated repository is unhealthy: %v", err)
- }
-
- // Idempotent: a second cold hydration from a fully populated state is a no-op.
- if err := RunHydrateRuntime(repo); err != nil {
- t.Fatalf("second hydration was not idempotent: %v", err)
- }
-
- if after := readGeneratedLockBytes(t, repo); string(before) != string(after) {
- t.Fatalf("hydration mutated committed generated.lock.json:\nbefore=%s\nafter=%s", before, after)
- }
-}
-
-// control-law: tracked-launcher-selects-only-the-pinned-runtime
-// Positive and relation conformance: detached hydration must publish the
-// Git-common bootstrap consumed by tracked launchers and the external shared
-// runtime consumed by supervision-aware worktree activation.
-func TestRunHydrateRuntimePopulatesDetachedBootstrapAndSharedSlots(t *testing.T) {
- t.Setenv(stateRootEnv, t.TempDir())
- invalidateWorkspaceCache()
- repo := runtimeTestRepo(t)
- result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: os.Args[0]})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach failed: %v %+v", err, result)
- }
- sharedBinary, _, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- bootstrapBinary, _, err := bootstrapRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- if filepath.Clean(sharedBinary) == filepath.Clean(bootstrapBinary) {
- t.Fatalf("detached shared and bootstrap slots unexpectedly alias: %s", sharedBinary)
- }
- for _, path := range []string{filepath.Dir(sharedBinary), filepath.Dir(bootstrapBinary), filepath.Join(repo, ".product-loop", "bin")} {
- if err := os.RemoveAll(path); err != nil {
- t.Fatal(err)
- }
- }
- before := readGeneratedLockBytes(t, repo)
-
- if err := RunHydrateRuntime(repo); err != nil {
- t.Fatalf("detached hydration failed: %v", err)
- }
- for name, path := range map[string]string{"Git-common bootstrap": bootstrapBinary, "detached shared runtime": sharedBinary} {
- if _, err := os.Stat(path); err != nil {
- t.Fatalf("%s was not populated: %v", name, err)
- }
- }
- if err := verifyLocalRuntime(repo); err != nil {
- t.Fatalf("detached hydration did not activate the local runtime: %v", err)
- }
- if after := readGeneratedLockBytes(t, repo); string(before) != string(after) {
- t.Fatalf("detached hydration mutated committed generated.lock.json")
- }
- if err := RunHydrateRuntime(repo); err != nil {
- t.Fatalf("second detached hydration was not idempotent: %v", err)
- }
-}
-
-// control-law: tracked-launcher-selects-only-the-pinned-runtime
-// Negative, bypass, and failure-state conformance: an unsafe detached shared
-// path must fail before publishing an admissible bootstrap or local runtime.
-func TestRunHydrateRuntimeRejectsUnsafeDetachedSharedSlotBeforeBootstrap(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink creation requires privileges not guaranteed on Windows CI")
- }
- t.Setenv(stateRootEnv, t.TempDir())
- invalidateWorkspaceCache()
- repo := runtimeTestRepo(t)
- result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: os.Args[0]})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach failed: %v %+v", err, result)
- }
- sharedBinary, _, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- bootstrapBinary, _, err := bootstrapRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{filepath.Dir(sharedBinary), filepath.Dir(bootstrapBinary), filepath.Join(repo, ".product-loop", "bin")} {
- if err := os.RemoveAll(path); err != nil {
- t.Fatal(err)
- }
- }
- if err := os.MkdirAll(filepath.Dir(filepath.Dir(sharedBinary)), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(t.TempDir(), filepath.Dir(sharedBinary)); err != nil {
- t.Fatal(err)
- }
-
- err = RunHydrateRuntime(repo)
- if err == nil || !strings.Contains(err.Error(), "symlink") {
- t.Fatalf("expected unsafe detached slot refusal, got %v", err)
- }
- for name, path := range map[string]string{
- "bootstrap runtime": bootstrapBinary,
- "worktree runtime": filepath.Join(repo, ".product-loop", "bin", helperName()),
- } {
- if _, statErr := os.Lstat(path); !os.IsNotExist(statErr) {
- t.Fatalf("failed hydration partially published %s: %v", name, statErr)
- }
- }
-}
-
-// TestRunHydrateRuntimeRefusesRunningVersusPinMismatch pins the incident-
-// prevention invariant: hydration must never populate a version-keyed slot with
-// a binary whose identity disagrees with the worktree's committed pin.
-func TestRunHydrateRuntimeRefusesRunningVersusPinMismatch(t *testing.T) {
- repo := runtimeTestRepo(t)
- if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json")
- value, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- var lock map[string]any
- if err := json.Unmarshal(value, &lock); err != nil {
- t.Fatal(err)
- }
- lock["boatstack_version"] = "v99.0.0"
- value, err = MarshalJSON(lock)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(lockPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunHydrateRuntime(repo); err == nil || !strings.Contains(err.Error(), "does not match this worktree's pin") {
- t.Fatalf("expected a provenance refusal, got %v", err)
- }
-}
-
-func readGeneratedLockBytes(t *testing.T, repo string) []byte {
- t.Helper()
- value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "generated.lock.json"))
- if err != nil {
- t.Fatal(err)
- }
- return value
-}
diff --git a/boatstack/init.go b/boatstack/init.go
deleted file mode 100644
index f513c66..0000000
--- a/boatstack/init.go
+++ /dev/null
@@ -1,1029 +0,0 @@
-package boatstack
-
-import (
- "bufio"
- "encoding/json"
- "fmt"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
-)
-
-type InitOptions struct {
- Repo string
- BinaryPath string
- IntegrationChoice string
- Yes bool
- Update bool
- Repair bool
- AllowDowngrade bool
- Input io.Reader
- Output io.Writer
-}
-
-var (
- initDoctor = Doctor
- initCheckpoint = func(string) error { return nil }
-)
-
-func gitOutput(repo string, arguments ...string) string {
- command := exec.Command("git", append([]string{"-C", repo}, arguments...)...)
- value, err := command.Output()
- if err != nil {
- return ""
- }
- return strings.TrimSpace(string(value))
-}
-
-func ResolveRepository(path string) (string, error) {
- if path == "" {
- path = "."
- }
- absolute, err := filepath.Abs(path)
- if err != nil {
- return "", err
- }
- root := gitOutput(absolute, "rev-parse", "--show-toplevel")
- if root == "" {
- return "", fmt.Errorf("Boatstack must be initialized inside a Git repository")
- }
- return root, nil
-}
-
-func detectTestCommand(repo string) string {
- if fileExists(filepath.Join(repo, "scripts", "check.sh")) {
- return "bash scripts/check.sh"
- }
- packagePath := filepath.Join(repo, "package.json")
- if value, err := os.ReadFile(packagePath); err == nil {
- var packageJSON struct {
- Scripts map[string]string `json:"scripts"`
- }
- if json.Unmarshal(value, &packageJSON) == nil && strings.TrimSpace(packageJSON.Scripts["test"]) != "" {
- switch {
- case fileExists(filepath.Join(repo, "pnpm-lock.yaml")):
- return "pnpm test"
- case fileExists(filepath.Join(repo, "yarn.lock")):
- return "yarn test"
- case fileExists(filepath.Join(repo, "bun.lock")), fileExists(filepath.Join(repo, "bun.lockb")):
- return "bun test"
- default:
- return "npm test"
- }
- }
- }
- for _, candidate := range []struct{ path, command string }{
- {"go.mod", "go test ./..."}, {"Cargo.toml", "cargo test"}, {"Makefile", "make test"},
- } {
- if fileExists(filepath.Join(repo, candidate.path)) {
- return candidate.command
- }
- }
- pyprojectUsesPytest := false
- if value, err := os.ReadFile(filepath.Join(repo, "pyproject.toml")); err == nil {
- pyprojectUsesPytest = strings.Contains(strings.ToLower(string(value)), "pytest")
- }
- pythonProject := fileExists(filepath.Join(repo, "pytest.ini")) ||
- fileExists(filepath.Join(repo, "conftest.py")) || pyprojectUsesPytest
- if pythonProject {
- switch {
- case fileExists(filepath.Join(repo, "uv.lock")):
- return "uv run pytest"
- case fileExists(filepath.Join(repo, "poetry.lock")):
- return "poetry run pytest"
- default:
- return "python -m pytest"
- }
- }
- return ""
-}
-
-func fileExists(path string) bool {
- info, err := os.Stat(path)
- return err == nil && info.Mode().IsRegular()
-}
-
-func detectContext(repo string) []string {
- paths := []string{}
- for _, candidate := range []string{"README.md", "AGENTS.md", "CLAUDE.md", "docs/architecture/", "docs/decisions/"} {
- if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(strings.TrimSuffix(candidate, "/")))); err == nil {
- paths = append(paths, candidate)
- }
- }
- return paths
-}
-
-func DetectHosts(repo string) []string {
- hosts := []string{}
- checks := []struct {
- name string
- paths []string
- commands []string
- }{
- {"cursor", []string{".cursor"}, []string{"cursor", "cursor-agent"}},
- {"claude", []string{".claude", "CLAUDE.md"}, []string{"claude"}},
- {"codex", []string{".agents", "AGENTS.md"}, []string{"codex"}},
- }
- for _, check := range checks {
- detected := false
- for _, path := range check.paths {
- if _, err := os.Stat(filepath.Join(repo, path)); err == nil {
- detected = true
- }
- }
- for _, command := range check.commands {
- if _, err := lookPath(command); err == nil {
- detected = true
- }
- }
- if detected {
- hosts = append(hosts, check.name)
- }
- }
- if strings.Contains(gitOutput(repo, "remote", "get-url", "origin"), "github.com") || fileExists(filepath.Join(repo, ".github")) {
- hosts = append(hosts, "github")
- }
- return hosts
-}
-
-func defaultConfig(repo, testCommand string) ProjectConfig {
- branch := strings.TrimPrefix(gitOutput(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"), "origin/")
- if branch == "" {
- branch = gitOutput(repo, "branch", "--show-current")
- }
- if branch == "" {
- branch = "main"
- }
- return ProjectConfig{
- SchemaVersion: 1,
- Project: Project{
- Name: filepath.Base(repo), DefaultBranch: branch, Context: detectContext(repo),
- Commands: map[string]string{"test": testCommand},
- },
- Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true},
- Workspace: Workspace{Enabled: true, Mode: "worktree", Cleanup: "confirm", CleanupAfter: "merge"},
- Adapters: []string{"cursor", "claude", "codex", "github"},
- Integrations: map[string]IntegrationState{
- "gstack": {Requested: false, Version: GStackRef},
- "spec-kit": {Requested: false, Version: SpecKitVersion},
- },
- }
-}
-
-func promptLine(reader *bufio.Reader, output io.Writer, prompt string) (string, error) {
- fmt.Fprint(output, prompt)
- value, err := reader.ReadString('\n')
- if err != nil && err != io.EOF {
- return "", err
- }
- return strings.TrimSpace(value), nil
-}
-
-func requestInstallationRepair(options *InitOptions, result InstallationRepairResult, reader *bufio.Reader) error {
- fmt.Fprintf(options.Output, "Boatstack found recoverable drift in Boatstack-owned control state (%s -> %s, %s):\n", result.InstalledVersion, result.TargetVersion, result.Direction)
- for _, item := range result.Items {
- if item.Classification == RepairOwnedDrifted {
- fmt.Fprintf(options.Output, " %s: %s\n", item.Path, item.Reason)
- }
- }
- fmt.Fprintln(options.Output, "Repair package: "+result.PackageFingerprint)
- fmt.Fprintln(options.Output, "The repair will remain in this fresh update branch and its update PR.")
- if options.Yes {
- return fmt.Errorf("recoverable Boatstack-owned drift requires explicit repair authority\nNEXT=%s", installationRepairRetryCommand(result.TargetVersion))
- }
- answer, err := promptLine(reader, options.Output, "Repair Boatstack-owned state and continue the update? [y/N] ")
- if err != nil {
- return err
- }
- if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" {
- return fmt.Errorf("update left unchanged\nNEXT=%s", installationRepairRetryCommand(result.TargetVersion))
- }
- options.Repair = true
- return nil
-}
-
-func installationRepairRetryCommand(version string) string {
- if runtime.GOOS == "windows" {
- return `$env:BOATSTACK_MODE="update"; $env:BOATSTACK_VERSION="` + version + `"; $env:BOATSTACK_REPO=(Get-Location).Path; $env:BOATSTACK_YES="1"; $env:BOATSTACK_REPAIR="1"; irm https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.ps1 | iex`
- }
- return `BOATSTACK_MODE=update BOATSTACK_VERSION=` + version + ` BOATSTACK_REPO="$PWD" BOATSTACK_YES=1 BOATSTACK_REPAIR=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.sh)"`
-}
-
-func copyHelper(source, repo string) (string, string, error) {
- if source == "" {
- var err error
- source, err = os.Executable()
- if err != nil {
- return "", "", err
- }
- }
- value, err := os.ReadFile(source)
- if err != nil {
- return "", "", err
- }
- destination := filepath.Join(repo, ".product-loop", "bin", helperName())
- if err := atomicWriteMode(destination, value, 0o755); err != nil {
- return "", "", err
- }
- return destination, SHA256Bytes(value), nil
-}
-
-func installControllerLocalRuntime(root, source string, integrations map[string]IntegrationState) (string, string, error) {
- binaryPath, binaryHash, err := copyHelper(source, root)
- if err != nil {
- return "", "", err
- }
- if err := writeInstallLock(root, binaryPath, binaryHash, integrations); err != nil {
- return "", "", err
- }
- return binaryPath, binaryHash, nil
-}
-
-func writeInstallLock(repo, binaryPath, binaryHash string, integrations map[string]IntegrationState) error {
- value, err := buildInstallLock(repo, binaryPath, binaryHash, integrations)
- if err != nil {
- return err
- }
- return atomicWriteMode(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), value, 0o644)
-}
-
-func buildInstallLock(repo, binaryPath, binaryHash string, integrations map[string]IntegrationState) ([]byte, error) {
- relativeBinaryPath, err := repositoryRelativePath(repo, binaryPath)
- if err != nil {
- return nil, fmt.Errorf("invalid Boatstack helper path: %w", err)
- }
- lock := map[string]any{
- "schema_version": 1,
- "boatstack_version": Version,
- "source_commit": SourceCommit,
- "platform": runtime.GOOS + "/" + runtime.GOARCH,
- "binary_path": relativeBinaryPath,
- "binary_sha256": binaryHash,
- "release_checksums_sha256": ChecksumsSHA256,
- "integrations": integrations,
- }
- value, err := MarshalJSON(lock)
- if err != nil {
- return nil, err
- }
- lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- if err := ValidateJSON("validate generated install lock", lockPath, value); err != nil {
- return nil, err
- }
- return value, nil
-}
-
-func readInstalledIntegrations(repo string, config ProjectConfig) (map[string]IntegrationState, error) {
- value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"))
- if err != nil {
- return nil, fmt.Errorf("missing previous local install lock: %w", err)
- }
- var lock struct {
- Integrations map[string]IntegrationState `json:"integrations"`
- }
- lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- if err := DecodeJSON("load previous local install lock", lockPath, value, &lock); err != nil {
- return nil, err
- }
- if len(lock.Integrations) > 0 {
- return lock.Integrations, nil
- }
- states := map[string]IntegrationState{}
- for name, configured := range config.Integrations {
- configured.Status = "preserved"
- configured.Detail = "selection preserved during Boatstack core update"
- states[name] = configured
- }
- return states, nil
-}
-
-func updateChangedPaths(repo string) []string {
- seen := map[string]bool{}
- for _, arguments := range [][]string{{"diff", "--name-only"}, {"ls-files", "--others", "--exclude-standard"}} {
- for _, path := range strings.Split(gitOutput(repo, arguments...), "\n") {
- path = strings.TrimSpace(path)
- if path != "" {
- seen[filepath.ToSlash(path)] = true
- }
- }
- }
- return sortedKeys(seen)
-}
-
-func checkUpdateDiffScope(repo string, currentFiles map[string][]byte, previous map[string]string, config ProjectConfig) ([]string, error) {
- ownership := newUpdateOwnershipProjection(config, currentFiles, previous)
- changed := updateChangedPaths(repo)
- unexpected := []string{}
- for _, path := range changed {
- if err := ownership.verify(repo, path); err != nil {
- unexpected = append(unexpected, path)
- }
- }
- if len(unexpected) > 0 {
- return changed, fmt.Errorf("update touched non-Boatstack paths: %s", strings.Join(unexpected, ", "))
- }
- return changed, nil
-}
-
-func runDetachedOnlyUpdate(options InitOptions, topology ConfigurationTopology) error {
- if !options.Update {
- return fmt.Errorf("detached-only installation is created with attach, not init")
- }
- config, rawConfig, err := LoadConfig(topology.ControllerSourcePath)
- if err != nil {
- return err
- }
- bundle, err := BuildExportBundle(topology.ControllerSourcePath, config, rawConfig, "boatstack")
- if err != nil {
- return err
- }
- helperSource := options.BinaryPath
- if helperSource == "" {
- helperSource, err = os.Executable()
- if err != nil {
- return err
- }
- }
- before := updateChangedPaths(topology.RepoRoot)
- if err := writeExport(topology.ControllerBundleRoot, bundle.Files, nil); err != nil {
- return fmt.Errorf("refresh detached controller bundle: %w", err)
- }
- states, err := readInstalledIntegrations(topology.ControllerBundleRoot, config)
- if err != nil {
- states = config.Integrations
- }
- if _, err := installDetachedRuntime(topology.RepoRoot, helperSource); err != nil {
- return fmt.Errorf("refresh detached shared runtime: %w", err)
- }
- if _, _, err := installControllerLocalRuntime(topology.ControllerBundleRoot, helperSource, states); err != nil {
- return fmt.Errorf("refresh detached controller helper: %w", err)
- }
- if err := CheckExport(topology.ControllerBundleRoot, bundle.Files); err != nil {
- return err
- }
- after := updateChangedPaths(topology.RepoRoot)
- if strings.Join(before, "\n") != strings.Join(after, "\n") {
- return fmt.Errorf("detached-only update changed repository paths")
- }
- fmt.Fprintf(options.Output, "PASS: detached-only Boatstack controller updated to %s; repository files were unchanged.\n", Version)
- return nil
-}
-
-func RunInit(options InitOptions) (returnErr error) {
- if options.Input == nil {
- options.Input = os.Stdin
- }
- if options.Output == nil {
- options.Output = os.Stdout
- }
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return err
- }
- topology, err := RequireManagedConfiguration(repo)
- if err != nil {
- return err
- }
- if topology.Shape == ConfigShapeDetachedOnly {
- return runDetachedOnlyUpdate(options, topology)
- }
- reader := bufio.NewReader(options.Input)
- configPath := topology.RepositorySourcePath
- configExists := fileExists(configPath)
- installed := fileExists(filepath.Join(repo, ".product-loop", "generated.lock.json")) || fileExists(filepath.Join(repo, ".product-loop", "bin", helperName()))
- if installed && !options.Update {
- return fmt.Errorf("Boatstack is already installed; use update, or invoke the verified installer with --repair when owned control state prevents updating")
- }
- if options.Update && !configExists {
- return fmt.Errorf("Boatstack update requires an existing .boatstack-project.json")
- }
- var config ProjectConfig
- var rawConfig []byte
- var migrationFrom, migrationTo int
- var migrationChanged bool
- if configExists {
- var err error
- rawConfig, err = os.ReadFile(configPath)
- if err != nil {
- return err
- }
- var upgraded []byte
- upgraded, migrationFrom, migrationTo, migrationChanged, err = MigrateConfigBytes(rawConfig)
- if err != nil {
- return fmt.Errorf("failed to migrate project config: %w", err)
- }
- if migrationChanged {
- rawConfig = upgraded
- }
- if err := DecodeJSON("load project configuration", configPath, rawConfig, &config); err != nil {
- return fmt.Errorf("existing Boatstack config is invalid: %w", err)
- }
- if err := ValidateConfig(config); err != nil {
- return fmt.Errorf("existing Boatstack config is invalid: %w", err)
- }
- } else {
- testCommand := detectTestCommand(repo)
- if testCommand == "" {
- if options.Yes {
- return fmt.Errorf("no test command could be detected; rerun interactively or create .boatstack-project.json")
- }
- testCommand, err = promptLine(reader, options.Output, "No test command was detected. Enter the real project test command: ")
- if err != nil || testCommand == "" {
- return fmt.Errorf("a real project test command is required")
- }
- }
- config = defaultConfig(repo, testCommand)
- }
- var repairResult InstallationRepairResult
- if options.Update {
- repairResult, err = ClassifyInstallationRepair(repo, config.Adapters, options.AllowDowngrade)
- if err != nil {
- return err
- }
- if repairResult.Direction == "DOWNGRADE" && (!options.Repair || !options.AllowDowngrade) {
- return fmt.Errorf("Boatstack %s to %s is a downgrade; rerun with both --repair and --allow-downgrade after reviewing the target release", repairResult.InstalledVersion, repairResult.TargetVersion)
- }
- if repairResult.VerificationStatus == "BLOCKED" {
- return fmt.Errorf("Boatstack update cannot safely repair this installation: %s", strings.Join(repairResult.Blockers, "; "))
- }
- if repairResult.VerificationStatus == "REPAIR_AVAILABLE" && !options.Repair {
- if err := requestInstallationRepair(&options, repairResult, reader); err != nil {
- return err
- }
- }
- if err := ValidateUpdateWorkspaceForRepair(repo, config, repairResult, options.Repair); err != nil {
- return err
- }
- }
- var preservedStates map[string]IntegrationState
- if options.Update {
- preservedStates, err = readInstalledIntegrations(repo, config)
- if err != nil && options.Repair && len(config.Integrations) > 0 {
- preservedStates = config.Integrations
- err = nil
- }
- if err != nil {
- return err
- }
- }
-
- detected := DetectHosts(repo)
- if len(detected) == 0 {
- fmt.Fprintln(options.Output, "Detected host signals: none; installing all thin adapters for portability.")
- } else {
- fmt.Fprintf(options.Output, "Detected host signals: %s. Installing portable Cursor, Claude, Codex, and GitHub adapters.\n", strings.Join(detected, ", "))
- }
-
- choice := options.IntegrationChoice
- if options.Update && choice != "" {
- return fmt.Errorf("Boatstack update preserves existing integrations; change integrations separately")
- }
- if !options.Update && choice == "" {
- if options.Yes {
- choice = "core"
- } else {
- fmt.Fprintln(options.Output, "\nOptional integrations:")
- fmt.Fprintln(options.Output, " core Boatstack only; no external runtimes")
- fmt.Fprintln(options.Output, " gstack product/design/engineering/DX review lenses; requires Git, Bun, and a supported host")
- fmt.Fprintln(options.Output, " spec-kit specification/plan/task/checklist generation; requires uv and a managed Python environment")
- fmt.Fprintln(options.Output, " both install both optional integrations")
- choice, err = promptLine(reader, options.Output, "Choose [core]: ")
- if err != nil {
- return err
- }
- if choice == "" {
- choice = "core"
- }
- }
- }
- if !options.Update {
- wantGStack, wantSpecKit, choiceErr := RequestedIntegrations(choice)
- if choiceErr != nil {
- return choiceErr
- }
- config.Integrations = map[string]IntegrationState{
- "gstack": {Requested: wantGStack, Version: GStackRef},
- "spec-kit": {Requested: wantSpecKit, Version: SpecKitVersion},
- }
- rawConfig, err = MarshalJSON(config)
- if err != nil {
- return err
- }
- }
- previousGenerated := previousFiles(repo)
- var repairGeneratedLock []byte
- if options.Update && options.Repair && len(previousGenerated) == 0 {
- var recoverErr error
- repairGeneratedLock, previousGenerated, recoverErr = committedGeneratedProvenance(repo)
- if recoverErr != nil {
- return recoverErr
- }
- }
- bundle, err := BuildExportBundle(configPath, config, embeddedConfigBytes(rawConfig), "boatstack")
- if err != nil {
- return err
- }
- var controllerBundle map[string][]byte
- var controllerRawConfig []byte
- if topology.Shape == ConfigShapeHybrid {
- controllerConfig := config
- controllerRawConfig = rawConfig
- if topology.Authority != ConfigAuthorityRepository {
- controllerConfig, controllerRawConfig, err = LoadConfig(topology.ControllerSourcePath)
- if err != nil {
- return fmt.Errorf("load independent detached configuration: %w", err)
- }
- }
- controller, buildErr := BuildExportBundle(topology.ControllerSourcePath, controllerConfig, controllerRawConfig, "boatstack")
- if buildErr != nil {
- return buildErr
- }
- controllerBundle = controller.Files
- }
- if err := ValidateJSON("validate project configuration before initialization", configPath, rawConfig); err != nil {
- return err
- }
- if _, err := PrepareHostHooksForUpdate(repo, config.Adapters, options.Update && options.Repair); err != nil {
- return err
- }
- if problems := ExportCollisions(repo, bundle.Files); len(problems) > 0 {
- if options.Update && options.Repair {
- allowed := repairOwnedPaths(repairResult)
- remaining := []string{}
- for _, problem := range problems {
- if !allowed[problem] {
- remaining = append(remaining, problem)
- }
- }
- problems = remaining
- }
- if len(problems) > 0 {
- return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", "))
- }
- }
- paths := sortedKeys(bundle.Files)
- fmt.Fprintf(options.Output, "\nBoatstack will generate %d paths:\n", len(paths))
- for _, path := range paths {
- fmt.Fprintln(options.Output, " "+path)
- }
- for _, path := range HostHookPaths(config.Adapters) {
- fmt.Fprintln(options.Output, " "+path+" (merge inert Boatstack engagement probe; preserve existing settings)")
- }
- for _, path := range executionInterceptorPaths(config.Adapters) {
- fmt.Fprintln(options.Output, " "+path+" (replace only the marker-bounded Boatstack interceptor)")
- }
- if !configExists {
- fmt.Fprintln(options.Output, " .boatstack-project.json (editable repository facts)")
- }
- if !options.Yes {
- answer, promptErr := promptLine(reader, options.Output, "Write these files? [y/N] ")
- if promptErr != nil {
- return promptErr
- }
- if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" {
- return fmt.Errorf("installation cancelled before writing files")
- }
- }
- helperSource := options.BinaryPath
- if helperSource == "" {
- helperSource, err = os.Executable()
- if err != nil {
- return err
- }
- }
- // Write-boundary provenance guard: never stamp this process's version onto a
- // foreign binary. When an explicit -binary is installed it must self-report the
- // identity we are about to record in the slot path and locks; otherwise the
- // runtime would be mislabeled and fail-close the shared cache. A self-install
- // (no -binary) matches by construction, so the check runs only for -binary.
- if options.BinaryPath != "" {
- version, sourceCommit, identityErr := readBinaryIdentity(helperSource)
- if identityErr != nil {
- return fmt.Errorf("cannot verify the helper binary before install: %w", identityErr)
- }
- if version != Version || sourceCommit != SourceCommit {
- return fmt.Errorf("refusing to install a version-mismatched helper: %s reports %s (%s) but this process is %s (%s); run the %s binary's own update or init", helperSource, version, sourceCommit, Version, SourceCommit, version)
- }
- }
- if options.Update && options.Repair {
- currentRepair, classifyErr := ClassifyInstallationRepair(repo, config.Adapters, options.AllowDowngrade)
- if classifyErr != nil {
- return classifyErr
- }
- if currentRepair.PackageFingerprint != repairResult.PackageFingerprint {
- return fmt.Errorf("Boatstack-owned repair state changed after preview; inspect the new repair-status before retrying")
- }
- backup, backupErr := writeInstallationRepairBackup(repo, repairResult)
- if backupErr != nil {
- return fmt.Errorf("create Boatstack repair backup: %w", backupErr)
- }
- repairResult.BackupPath = backup
- fmt.Fprintf(options.Output, "Repair package %s backed up at %s.\n", repairResult.PackageFingerprint, backup)
- }
- snapshot, err := beginRepositorySnapshot(repo)
- if err != nil {
- return err
- }
- defer func() {
- if returnErr != nil {
- if rollbackErr := snapshot.rollback(); rollbackErr != nil {
- returnErr = fmt.Errorf("%v; initialization rollback failed: %w", returnErr, rollbackErr)
- }
- }
- }()
- if _, err := installCommandRuntime(helperSource, repo, config.Integrations); err != nil {
- return fmt.Errorf("cannot install the repository-family Boatstack runtime: %w", err)
- }
- var states map[string]IntegrationState
- if options.Update {
- states = preservedStates
- } else {
- states, err = InstallIntegrations(choice, repo, config.Adapters)
- }
- if err != nil {
- return err
- }
- helperValue, err := os.ReadFile(helperSource)
- if err != nil {
- return fmt.Errorf("read Boatstack helper before initialization commit: %w", err)
- }
- prospectiveBinaryPath := filepath.Join(repo, ".product-loop", "bin", helperName())
- if _, err := buildInstallLock(repo, prospectiveBinaryPath, SHA256Bytes(helperValue), states); err != nil {
- return err
- }
- if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil {
- return err
- }
- if err := initCheckpoint("config-written"); err != nil {
- return fmt.Errorf("initialization checkpoint config-written: %w", err)
- }
- if len(repairGeneratedLock) > 0 {
- lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json")
- if err := rejectSymlinkComponents(repo, lockPath); err != nil {
- return err
- }
- if err := atomicWrite(lockPath, repairGeneratedLock); err != nil {
- return fmt.Errorf("restore generated provenance inside repair transaction: %w", err)
- }
- }
- var writeErr error
- if options.Update && options.Repair {
- writeErr = WriteExportForRepair(repo, bundle.Files, repairOwnedPaths(repairResult))
- } else {
- writeErr = WriteExport(repo, bundle.Files)
- }
- if writeErr != nil {
- return writeErr
- }
- // Hybrid installations have two projections. Generate each from its declared
- // source; never copy the repository bundle across the authority boundary.
- if topology.Shape == ConfigShapeHybrid {
- if err := writeExport(topology.ControllerBundleRoot, controllerBundle, nil); err != nil {
- return fmt.Errorf("refresh detached controller bundle: %w", err)
- }
- if topology.Authority == ConfigAuthorityRepository {
- if err := atomicWriteMode(topology.ControllerSourcePath, controllerRawConfig, 0o644); err != nil {
- return fmt.Errorf("refresh repository-authoritative detached source: %w", err)
- }
- stateRoot, stateErr := detachedStateRoot()
- if stateErr != nil {
- return stateErr
- }
- binding, bindingErr := loadBinding(stateRoot, topology.RepoID)
- if bindingErr != nil {
- return bindingErr
- }
- binding.SchemaVersion = detachedSchemaVersion
- binding.ConfigAuthority = ConfigAuthorityRepository
- binding.ConfigSHA256 = SHA256Bytes(controllerRawConfig)
- binding.CreatedByVersion = Version
- bindingRaw, marshalErr := MarshalJSON(binding)
- if marshalErr != nil {
- return marshalErr
- }
- if err := atomicWrite(bindingPath(stateRoot, topology.RepoID), bindingRaw); err != nil {
- return fmt.Errorf("commit detached configuration binding: %w", err)
- }
- invalidateWorkspaceCache()
- }
- }
- if err := initCheckpoint("export-written"); err != nil {
- return fmt.Errorf("initialization checkpoint export-written: %w", err)
- }
- if err := InstallHostHooksForUpdate(repo, config.Adapters, options.Update && options.Repair); err != nil {
- return err
- }
- if err := initCheckpoint("hooks-written"); err != nil {
- return fmt.Errorf("initialization checkpoint hooks-written: %w", err)
- }
- if err := InstallExecutionInterceptors(repo, config.Adapters); err != nil {
- return err
- }
- if err := initCheckpoint("interceptors-written"); err != nil {
- return fmt.Errorf("initialization checkpoint interceptors-written: %w", err)
- }
- binaryPath, binaryHash, err := copyHelper(helperSource, repo)
- if err != nil {
- return err
- }
- if err := initCheckpoint("helper-written"); err != nil {
- return fmt.Errorf("initialization checkpoint helper-written: %w", err)
- }
- if _, err := installCommandRuntime(helperSource, repo, states); err != nil {
- return fmt.Errorf("cannot finalize the repository-family Boatstack runtime: %w", err)
- }
- if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil {
- return err
- }
- if topology.Shape == ConfigShapeHybrid {
- if _, _, err := installControllerLocalRuntime(topology.ControllerBundleRoot, helperSource, states); err != nil {
- return fmt.Errorf("refresh detached controller helper: %w", err)
- }
- }
- if err := initCheckpoint("install-lock-written"); err != nil {
- return fmt.Errorf("initialization checkpoint install-lock-written: %w", err)
- }
- if err := CheckExport(repo, bundle.Files); err != nil {
- return err
- }
- if err := CheckHostHooks(repo, config.Adapters); err != nil {
- return err
- }
- if err := initDoctor(repo); err != nil {
- return fmt.Errorf("post-install smoke check failed: %w", err)
- }
- if options.Update {
- changed, scopeErr := checkUpdateDiffScope(repo, bundle.Files, previousGenerated, config)
- if scopeErr != nil {
- return scopeErr
- }
- fmt.Fprintf(options.Output, "\nPASS: Boatstack updated to %s on a dedicated infrastructure branch.\n", Version)
- if migrationChanged {
- fmt.Fprintf(options.Output, "PASS: migrated .boatstack-project.json from schema version %d to %d.\n", migrationFrom, migrationTo)
- } else {
- fmt.Fprintln(options.Output, "PASS: no product files changed.")
- }
- fmt.Fprintln(options.Output, "Changed Boatstack paths:")
- for _, path := range changed {
- fmt.Fprintln(options.Output, " "+path)
- }
- } else {
- fmt.Fprintln(options.Output, "\nPASS: Boatstack core installed without a language runtime.")
- }
- fmt.Fprintln(options.Output, "PASS: generated engagement probes verified for installed hosts.")
- fmt.Fprintln(options.Output, "Boatstack remains dormant until an explicit command or a verified active delivery engages it.")
- fmt.Fprintln(options.Output, "External systems remain responsible for repository and infrastructure safety while Boatstack is dormant.")
- keys := sortedKeys(states)
- for _, name := range keys {
- state := states[name]
- fmt.Fprintf(options.Output, " %s: %s — %s\n", name, state.Status, state.Detail)
- }
- if options.Update {
- fmt.Fprintln(options.Output, "\nReview the generated diff before publishing the update PR:")
- } else {
- fmt.Fprintln(options.Output, "\nBefore product work, commit Boatstack infrastructure in its own PR:")
- }
- stagePaths := append([]string{".boatstack-project.json"}, paths...)
- if options.Update {
- for path := range previousGenerated {
- stagePaths = append(stagePaths, path)
- }
- }
- stagePaths = append(stagePaths, HostHookPaths(config.Adapters)...)
- stagePaths = append(stagePaths, executionInterceptorPaths(config.Adapters)...)
- stageSet := map[string]bool{}
- for _, path := range stagePaths {
- stageSet[path] = true
- }
- stagePaths = sortedKeys(stageSet)
- fmt.Fprintln(options.Output, " git status --short")
- fmt.Fprintln(options.Output, " git add -- "+strings.Join(stagePaths, " "))
- if options.Update {
- fmt.Fprintf(options.Output, " git commit -m \"chore: update Boatstack to %s\"\n", Version)
- fmt.Fprintf(options.Output, " git push -u origin chore/update-boatstack-%s\n", Version)
- fmt.Fprintln(options.Output, "Do not publish until the human replies `open update PR`; never merge automatically.")
- } else {
- fmt.Fprintln(options.Output, " git commit -m \"chore: install Boatstack\"")
- fmt.Fprintln(options.Output, " git push -u origin chore/install-boatstack")
- }
- fmt.Fprintln(options.Output, "The verified runtime is shared by worktrees in this Git clone; each worktree inherits tracked launchers that activate its ignored .product-loop/bin/ files on first use.")
- fmt.Fprintln(options.Output, "A fresh clone inherits the tracked launcher; its first invocation hydrates the exact pinned runtime through the verified installer.")
- if options.Update {
- fmt.Fprintln(options.Output, "\nAfter the update PR is merged, reload Cursor, Codex, or Claude.")
- } else {
- fmt.Fprintln(options.Output, "\nAfter that PR is merged, reload Cursor, Codex, or Claude and start in Plan mode:")
- fmt.Fprintln(options.Output, " 1. Describe the product change, save the host plan as a durable file inside the repository, and pass its path to auto-plan with --plan .")
- }
- fmt.Fprintln(options.Output, "Host activation checklist:")
- fmt.Fprintln(options.Output, " Cursor: reload the window and confirm beforeShellExecution and beforeMCPExecution are paired with their after events, plus synchronous pre/post native-tool hooks; the hooks are defense in depth.")
- fmt.Fprintln(options.Output, " Claude Code: reload, then use /hooks to confirm Boatstack PreToolUse, PostToolUse, and PostToolUseFailure hooks are active (Bash is required).")
- fmt.Fprintln(options.Output, " Codex: trust this exact linked-worktree path, use /hooks to review and trust the Boatstack PreToolUse and PostToolUse hooks, then start a new task.")
- if contains(config.Adapters, "gemini") {
- fmt.Fprintln(options.Output, " Gemini CLI: reload and confirm the Boatstack BeforeTool and AfterTool hooks are active.")
- }
- fmt.Fprintln(options.Output, "Boatstack start command by host:")
- fmt.Fprintln(options.Output, " Claude Code: /auto-plan")
- fmt.Fprintln(options.Output, " Cursor: /auto-plan")
- fmt.Fprintln(options.Output, " Codex: $boatstack auto-plan")
- fmt.Fprintln(options.Output, "Return later with Claude Code or Cursor: /boatstack-next")
- fmt.Fprintln(options.Output, "Return later with Codex: $boatstack next")
- fmt.Fprintln(options.Output, "If Boatstack created .claude/skills during an active Claude Code session, reload Claude Code before using its slash commands.")
- if err := snapshot.commit(); err != nil {
- return fmt.Errorf("remove initialization rollback snapshot: %w", err)
- }
- return nil
-}
-
-const ExecutionBoundaryDX = `
-**Boatstack Execution Boundary:**
-Boatstack workflow control begins only when the user explicitly invokes Boatstack or the current worktree and branch own an active managed delivery. Installation, repository presence, native plan approval, and saved or approved Boatstack drafts do not activate it.
-1. On explicit Boatstack invocation, save the proposed plan as a durable file inside the repository and pass its path to auto-plan with ` + "`--plan `" + ` (Boatstack does not scan directories for plans; an out-of-repo path is rejected so the plan stays hash-current through build).
-2. Carry the selected feature slug through every status and planning call. After workspace-cut, continue only from its returned destination repository.
-3. Async task completion, conversation state, or an execution-mode transition never creates implementation authority. Only a current plan lock bound to this worktree and branch does.
-4. After explicit Boatstack invocation in a detached repository, use the workspace-resolved helper to query ` + "`detached-status --repo .`" + `. Configuration divergence never controls ordinary tools. If status returns ` + "`CONFIG_REBIND_REQUIRED`" + `, run its exact workspace-bound ` + "`config-rebind`" + ` preview and apply only the returned fingerprinted command.
-`
-
-const interceptorHeader = "\n"
-const interceptorFooter = "\n\n"
-
-func injectExecutionInterceptor(repo, file string) error {
- path := filepath.Join(repo, file)
- content, err := os.ReadFile(path)
- if err != nil {
- if os.IsNotExist(err) {
- content = []byte{}
- } else {
- return err
- }
- }
- text := string(content)
- start := strings.Index(text, interceptorHeader)
- end := strings.Index(text, interceptorFooter)
- injection := interceptorHeader + strings.TrimSpace(ExecutionBoundaryDX) + interceptorFooter
-
- if strings.Count(text, interceptorHeader) != strings.Count(text, interceptorFooter) || strings.Count(text, interceptorHeader) > 1 || (start >= 0 && end < start) {
- return fmt.Errorf("ambiguous Boatstack execution interceptor markers in %s; preserve the file and repair the marker boundary manually", path)
- }
- if start >= 0 && end > start {
- text = text[:start] + injection + text[end+len(interceptorFooter):]
- } else {
- text = strings.TrimSpace(text) + "\n\n" + injection
- }
- return os.WriteFile(path, []byte(strings.TrimSpace(text)+"\n"), 0o644)
-}
-
-func InstallExecutionInterceptors(repo string, adapters []string) error {
- for _, path := range executionInterceptorPaths(adapters) {
- if err := injectExecutionInterceptor(repo, path); err != nil {
- return err
- }
- }
- return nil
-}
-
-func RunUpdate(options InitOptions) error {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return err
- }
- if options.Input == nil {
- options.Input = os.Stdin
- }
- if options.Output == nil {
- options.Output = os.Stdout
- }
- topology, err := RequireManagedConfiguration(repo)
- if err != nil {
- return err
- }
- // Cross-version provenance guard. Each helper embeds its own generated bundle
- // and version constants, so a running helper cannot correctly install a
- // different version in-process — stamping the running version onto foreign
- // bytes is exactly the corruption that fail-closes the shared runtime. If a
- // passed -binary self-reports a different identity, hand the whole update to
- // that binary, which carries its own bundle and constants.
- if options.BinaryPath != "" {
- version, sourceCommit, identityErr := readBinaryIdentity(options.BinaryPath)
- if identityErr != nil {
- return fmt.Errorf("cannot verify the replacement helper before update: %w", identityErr)
- }
- if version != Version || sourceCommit != SourceCommit {
- return reexecUpdate(options.BinaryPath, options)
- }
- }
- if topology.Shape == ConfigShapeDetachedOnly {
- options.Update = true
- return RunInit(options)
- }
- configPath := topology.RepositorySourcePath
- config, rawConfig, configErr := LoadConfig(configPath)
- if configErr != nil {
- return configErr
- }
- preflight, classifyErr := ClassifyInstallationRepair(repo, config.Adapters, options.AllowDowngrade)
- if classifyErr != nil {
- return classifyErr
- }
- if preflight.VerificationStatus == "REPAIR_AVAILABLE" && !options.Repair {
- reader := bufio.NewReader(options.Input)
- options.Input = reader
- if err := requestInstallationRepair(&options, preflight, reader); err != nil {
- return err
- }
- }
- // Validate the complete update workspace before creating a durable attempt.
- // Invalid branch or diff state must not consume a retry or leave an identity
- // that collides with the later, correctly prepared operation.
- if err := ValidateUpdateWorkspaceForRepair(repo, config, preflight, options.Repair); err != nil {
- return err
- }
- branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current"))
- repairAuthority := fmt.Sprintf("repair=%t\x00allow-downgrade=%t", options.Repair, options.AllowDowngrade)
- packageFingerprint := SHA256Bytes([]byte(Version + "\x00" + SourceCommit + "\x00" + ChecksumsSHA256 + "\x00" + repairAuthority))
- receipt, err := PrepareOperation(OperationPrepareOptions{
- Repo: repo, Kind: "install-update", Scope: OperationScope{Worktree: filepath.Base(repo), HeadBranch: branch},
- Target: "boatstack-install:" + Version, PackageFingerprint: packageFingerprint,
- AuthorizationFingerprint: SHA256Bytes([]byte("update-request\x00" + branch + "\x00" + packageFingerprint + "\x00" + repairAuthority)),
- RetryClass: "ATOMIC_LOCAL", MaxAttempts: 2,
- ExpectedPostcondition: "the generated runtime, adapters, hooks, and preserved integration state match the pinned release",
- })
- if err != nil {
- return err
- }
- if receipt.State == OperationSucceeded {
- postconditionErr := verifyInstalledUpdatePostcondition(repo, configPath, config, rawConfig, preflight.PreservedIntegrations)
- if postconditionErr == nil {
- return nil
- }
- receipt, err = reconcileSucceededInstallUpdate(repo, receipt.OperationID,
- "the previously successful local update no longer matches its target postcondition",
- postconditionErr.Error())
- if err != nil {
- return err
- }
- }
- begin, err := BeginOperation(repo, receipt.OperationID, SHA256Bytes([]byte("install-update\x00"+packageFingerprint)), "boatstack-helper update")
- if err != nil {
- if begin.Receipt.State == OperationSucceeded {
- return verifyInstalledUpdatePostcondition(repo, configPath, config, rawConfig, preflight.PreservedIntegrations)
- }
- return err
- }
- if begin.Receipt.State == OperationSucceeded {
- return verifyInstalledUpdatePostcondition(repo, configPath, config, rawConfig, preflight.PreservedIntegrations)
- }
- options.Update = true
- if err := RunInit(options); err != nil {
- _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "RETRYABLE", "the atomic update transaction rolled back: "+err.Error(), "")
- return err
- }
- _, err = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "post-install doctor and generated projections passed", Version)
- return err
-}
-
-func verifyInstalledUpdatePostcondition(repo, configPath string, config ProjectConfig, rawConfig []byte, expectedIntegrations map[string]IntegrationState) error {
- bundle, err := BuildExportBundle(configPath, config, embeddedConfigBytes(rawConfig), "boatstack")
- if err != nil {
- return err
- }
- if err := CheckExport(repo, bundle.Files); err != nil {
- return err
- }
- if err := CheckHostHooks(repo, config.Adapters); err != nil {
- return err
- }
- for _, host := range config.Adapters {
- path := executionInterceptorPath(host)
- if path == "" {
- continue
- }
- items := classifyExecutionInterceptor(repo, host)
- if len(items) != 1 || items[0].Classification != RepairCurrent {
- return fmt.Errorf("target execution interceptor is not current: %s", path)
- }
- }
- if err := verifyGeneratedRuntime(repo); err != nil {
- return err
- }
- manifest, _, err := loadSharedRuntime(repo)
- if err != nil {
- return err
- }
- if err := verifyLocalRuntime(repo); err != nil {
- return err
- }
- installedIntegrations, err := readInstalledIntegrations(repo, config)
- if err != nil {
- return err
- }
- if expectedIntegrations == nil {
- expectedIntegrations = config.Integrations
- }
- if !sameJSON(installedIntegrations, expectedIntegrations) || !sameJSON(manifest.Integrations, expectedIntegrations) {
- return fmt.Errorf("installed integration state does not match the update postcondition")
- }
- return nil
-}
diff --git a/boatstack/init_test.go b/boatstack/init_test.go
deleted file mode 100644
index acd2298..0000000
--- a/boatstack/init_test.go
+++ /dev/null
@@ -1,288 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-func TestRuntimeFreeInit(t *testing.T) {
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- packageJSON := `{"scripts":{"test":"node --test"}}`
- if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(packageJSON), 0o644); err != nil {
- t.Fatal(err)
- }
- var output bytes.Buffer
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &output}); err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{
- ".boatstack-project.json", ".product-loop/project.json", ".product-loop/generated.lock.json",
- ".product-loop/bin/install.lock.json", ".cursor/commands/auto-plan.md", ".claude/skills/auto-plan/SKILL.md", ".product-loop/hooks/guard.sh",
- ".cursor/hooks.json", ".claude/settings.json", ".codex/hooks.json",
- } {
- if !fileExists(filepath.Join(repo, filepath.FromSlash(path))) {
- t.Fatalf("init did not create %s", path)
- }
- }
- binaryName := "boatstack-helper"
- if runtime.GOOS == "windows" {
- binaryName += ".exe"
- }
- if !fileExists(filepath.Join(repo, ".product-loop", "bin", binaryName)) {
- t.Fatal("init did not install the project-local helper")
- }
- if !strings.Contains(output.String(), "PASS: Boatstack core installed without a language runtime") {
- t.Fatalf("unexpected init output: %s", output.String())
- }
- for _, expected := range []string{"generated engagement probes verified", "remains dormant until an explicit command", "External systems remain responsible"} {
- if !strings.Contains(output.String(), expected) {
- t.Fatalf("init output is missing safety guidance %q: %s", expected, output.String())
- }
- }
- for _, expected := range []string{"commit Boatstack infrastructure in its own PR", ".boatstack-project.json", "git push -u origin chore/install-boatstack", "reload Cursor, Codex, or Claude"} {
- if !strings.Contains(output.String(), expected) {
- t.Fatalf("init output is missing %q: %s", expected, output.String())
- }
- }
- for _, expected := range []string{
- "Boatstack start command by host:",
- "Codex: $boatstack next",
- "Claude Code: /auto-plan",
- "Cursor: /auto-plan",
- "Codex: $boatstack auto-plan",
- "reload Claude Code before using its slash commands",
- "trust this exact linked-worktree path",
- "beforeShellExecution and beforeMCPExecution",
- } {
- if !strings.Contains(output.String(), expected) {
- t.Fatalf("init output is missing host start guidance %q: %s", expected, output.String())
- }
- }
- configValue, _ := os.ReadFile(filepath.Join(repo, ".boatstack-project.json"))
- if strings.Contains(string(configValue), `"status"`) {
- t.Fatal("machine-local integration status leaked into repository configuration")
- }
- if !strings.Contains(string(configValue), `"maintain_changelog": false`) {
- t.Fatal("fresh initialization did not default changelog maintenance off")
- }
- installValue, _ := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"))
- if !strings.Contains(string(installValue), `"binary_sha256"`) || !strings.Contains(string(installValue), `"integrations"`) {
- t.Fatal("local install lock did not record binary and integration state")
- }
- var lock installLock
- if err := json.Unmarshal(installValue, &lock); err != nil {
- t.Fatal(err)
- }
- wantBinaryPath := filepath.ToSlash(filepath.Join(".product-loop", "bin", binaryName))
- if lock.BinaryPath != wantBinaryPath {
- t.Fatalf("install lock binary_path = %q, want repository-relative %q", lock.BinaryPath, wantBinaryPath)
- }
-}
-
-func TestInitFreshThirdPartyPythonRepositoryWithValidConfig(t *testing.T) {
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- if err := os.WriteFile(filepath.Join(repo, "pyproject.toml"), []byte("[project]\nname = \"hatch-fixture\"\n[tool.pytest.ini_options]\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- config := testConfig()
- config.Project.Name = "hatch-fixture"
- config.Project.Commands["test"] = "python -m pytest"
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- configPath := filepath.Join(repo, ".boatstack-project.json")
- if err := os.WriteFile(configPath, raw, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}); err != nil {
- t.Fatal(err)
- }
- for _, relative := range []string{
- ".product-loop/project.json", ".product-loop/generated.lock.json",
- ".product-loop/bin/install.lock.json", ".cursor/commands/auto-plan.md",
- } {
- if !fileExists(filepath.Join(repo, filepath.FromSlash(relative))) {
- t.Fatalf("fresh third-party init did not create %s", relative)
- }
- }
- if err := Doctor(repo); err != nil {
- t.Fatalf("fresh third-party installation is not controller-ready: %v", err)
- }
-}
-
-func TestInitDoesNotResetAnExistingInstallation(t *testing.T) {
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"scripts":{"test":"node --test"}}`), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}); err != nil {
- t.Fatal(err)
- }
- configBefore, _ := os.ReadFile(filepath.Join(repo, ".boatstack-project.json"))
- err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}})
- if err == nil || !strings.Contains(err.Error(), "already installed") {
- t.Fatalf("existing installation was reinitialized: %v", err)
- }
- configAfter, _ := os.ReadFile(filepath.Join(repo, ".boatstack-project.json"))
- if !bytes.Equal(configBefore, configAfter) {
- t.Fatal("failed reinstall changed project configuration")
- }
-}
-
-func TestInitRollsBackRepositoryWhenPostInstallVerificationFails(t *testing.T) {
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- packagePath := filepath.Join(repo, "package.json")
- original := []byte(`{"scripts":{"test":"node --test"}}`)
- if err := os.WriteFile(packagePath, original, 0o640); err != nil {
- t.Fatal(err)
- }
- oldDoctor := initDoctor
- initDoctor = func(string) error { return fmt.Errorf("injected verification failure") }
- defer func() { initDoctor = oldDoctor }()
- err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}})
- if err == nil || !strings.Contains(err.Error(), "injected verification failure") {
- t.Fatalf("expected injected initialization failure, got %v", err)
- }
- value, readErr := os.ReadFile(packagePath)
- if readErr != nil || !bytes.Equal(value, original) {
- t.Fatalf("rollback did not restore original repository file: %v", readErr)
- }
- info, statErr := os.Stat(packagePath)
- if statErr != nil {
- t.Fatalf("rollback did not restore original file metadata: %v", statErr)
- }
- if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
- t.Fatalf("rollback did not restore original file mode: %v %#o", statErr, info.Mode().Perm())
- }
- for _, relative := range []string{".boatstack-project.json", ".product-loop", ".cursor", ".claude", ".codex"} {
- if _, statErr := os.Lstat(filepath.Join(repo, relative)); !os.IsNotExist(statErr) {
- t.Fatalf("partial installation state remains at %s: %v", relative, statErr)
- }
- }
-}
-
-func TestInitRollsBackAtEveryCommitStage(t *testing.T) {
- stages := []string{"config-written", "export-written", "hooks-written", "helper-written", "install-lock-written"}
- for _, stage := range stages {
- t.Run(stage, func(t *testing.T) {
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- original := []byte(`{"scripts":{"test":"node --test"}}`)
- packagePath := filepath.Join(repo, "package.json")
- if err := os.WriteFile(packagePath, original, 0o640); err != nil {
- t.Fatal(err)
- }
- oldCheckpoint := initCheckpoint
- initCheckpoint = func(current string) error {
- if current == stage {
- return fmt.Errorf("injected %s failure", stage)
- }
- return nil
- }
- defer func() { initCheckpoint = oldCheckpoint }()
- err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}})
- if err == nil || !strings.Contains(err.Error(), "injected "+stage+" failure") {
- t.Fatalf("expected injected %s failure, got %v", stage, err)
- }
- value, readErr := os.ReadFile(packagePath)
- if readErr != nil || !bytes.Equal(value, original) {
- t.Fatalf("rollback at %s did not restore original file: %v", stage, readErr)
- }
- info, statErr := os.Stat(packagePath)
- if statErr != nil {
- t.Fatalf("rollback at %s did not restore file metadata: %v", stage, statErr)
- }
- if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
- t.Fatalf("rollback at %s did not restore file mode: %v", stage, statErr)
- }
- for _, relative := range []string{".boatstack-project.json", ".product-loop", ".cursor", ".claude", ".codex"} {
- if _, statErr := os.Lstat(filepath.Join(repo, relative)); !os.IsNotExist(statErr) {
- t.Fatalf("partial state remains after %s at %s: %v", stage, relative, statErr)
- }
- }
- })
- }
-}
-
-func TestDetectTestCommandCoversCheckScriptAndPythonProjects(t *testing.T) {
- for name, setup := range map[string]struct {
- files map[string]string
- want string
- }{
- "check script": {files: map[string]string{"scripts/check.sh": "#!/bin/sh\n"}, want: "bash scripts/check.sh"},
- "uv pytest": {files: map[string]string{"pyproject.toml": "[tool.pytest.ini_options]\n", "uv.lock": ""}, want: "uv run pytest"},
- "poetry pytest": {files: map[string]string{"pyproject.toml": "[tool.pytest.ini_options]\n", "poetry.lock": ""}, want: "poetry run pytest"},
- "plain pytest": {files: map[string]string{"pytest.ini": "[pytest]\n"}, want: "python -m pytest"},
- } {
- t.Run(name, func(t *testing.T) {
- repo := t.TempDir()
- for relative, value := range setup.files {
- path := filepath.Join(repo, filepath.FromSlash(relative))
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte(value), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- if got := detectTestCommand(repo); got != setup.want {
- t.Fatalf("detectTestCommand() = %q, want %q", got, setup.want)
- }
- })
- }
- repo := t.TempDir()
- if err := os.WriteFile(filepath.Join(repo, "pyproject.toml"), []byte("[project]\nname = \"fixture\"\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if got := detectTestCommand(repo); got != "" {
- t.Fatalf("plain pyproject.toml invented a test command: %q", got)
- }
-}
-
-func TestGStackMissingPrerequisiteIsPartialNotCoreFailure(t *testing.T) {
- oldLookPath := lookPath
- defer func() { lookPath = oldLookPath }()
- lookPath = func(name string) (string, error) {
- if name == "bun" {
- return "", fmt.Errorf("missing")
- }
- return oldLookPath(name)
- }
- state := installGStack([]string{"codex"})
- if state.Status != "partial" || !strings.Contains(state.Detail, "bun") {
- t.Fatalf("expected honest partial integration result, got %#v", state)
- }
-}
-
-func TestRequestedIntegrationChoices(t *testing.T) {
- for choice, expected := range map[string][2]bool{
- "core": {false, false}, "gstack": {true, false}, "spec-kit": {false, true}, "both": {true, true},
- } {
- gstack, specKit, err := RequestedIntegrations(choice)
- if err != nil || [2]bool{gstack, specKit} != expected {
- t.Fatalf("choice %s: %v %v %v", choice, gstack, specKit, err)
- }
- }
-}
diff --git a/boatstack/init_transaction.go b/boatstack/init_transaction.go
deleted file mode 100644
index 1ada128..0000000
--- a/boatstack/init_transaction.go
+++ /dev/null
@@ -1,111 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
-)
-
-type repositorySnapshot struct {
- repo string
- backup string
- active bool
-}
-
-func beginRepositorySnapshot(repo string) (*repositorySnapshot, error) {
- backup, err := os.MkdirTemp("", "boatstack-init-rollback-*")
- if err != nil {
- return nil, fmt.Errorf("create initialization rollback snapshot: %w", err)
- }
- snapshot := &repositorySnapshot{repo: repo, backup: backup, active: true}
- if err := copyRepositoryTree(repo, backup); err != nil {
- _ = os.RemoveAll(backup)
- return nil, fmt.Errorf("snapshot repository before initialization: %w", err)
- }
- return snapshot, nil
-}
-
-func copyRepositoryTree(source, destination string) error {
- return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- relative, err := filepath.Rel(source, path)
- if err != nil {
- return err
- }
- if relative == ".git" || (relative != "." && filepath.Dir(relative) == ".git") {
- if entry.IsDir() {
- return filepath.SkipDir
- }
- return nil
- }
- if relative == "." {
- return nil
- }
- target := filepath.Join(destination, relative)
- info, err := entry.Info()
- if err != nil {
- return err
- }
- if entry.Type()&os.ModeSymlink != 0 {
- link, err := os.Readlink(path)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
- return err
- }
- return os.Symlink(link, target)
- }
- if entry.IsDir() {
- return os.MkdirAll(target, info.Mode().Perm())
- }
- if !info.Mode().IsRegular() {
- return fmt.Errorf("unsupported repository entry in initialization transaction: %s", path)
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
- return err
- }
- return os.WriteFile(target, value, info.Mode().Perm())
- })
-}
-
-func (snapshot *repositorySnapshot) rollback() error {
- if !snapshot.active {
- return nil
- }
- entries, err := os.ReadDir(snapshot.repo)
- if err != nil {
- return err
- }
- for _, entry := range entries {
- if entry.Name() == ".git" {
- continue
- }
- if err := os.RemoveAll(filepath.Join(snapshot.repo, entry.Name())); err != nil {
- return fmt.Errorf("remove partial initialization path %s: %w", entry.Name(), err)
- }
- }
- if err := copyRepositoryTree(snapshot.backup, snapshot.repo); err != nil {
- return fmt.Errorf("restore repository after initialization failure: %w", err)
- }
- snapshot.active = false
- return os.RemoveAll(snapshot.backup)
-}
-
-func (snapshot *repositorySnapshot) commit() error {
- if !snapshot.active {
- return nil
- }
- if err := os.RemoveAll(snapshot.backup); err != nil {
- return err
- }
- snapshot.active = false
- return nil
-}
diff --git a/boatstack/insight.go b/boatstack/insight.go
deleted file mode 100644
index a352304..0000000
--- a/boatstack/insight.go
+++ /dev/null
@@ -1,1110 +0,0 @@
-package boatstack
-
-// Independent insight captures.
-//
-// Boundary: confirmed conversational value map -> tracked repository artifact
-// Control law: insight bytes enter only the repository insight inbox, and
-// only when source and preview fingerprints match exactly
-// Authorized actor: explicit insight save/associate/bind/disposition commands
-// Required evidence: valid value-map lineage, current preview, safe repository path
-// Failure behavior: fail closed without changing an existing capture
-// Release condition: every deterministic validation succeeds
-// control-law: confirmed-insight-becomes-reviewable-repository-diff
-
-import (
- "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "html"
- "os"
- "path/filepath"
- "reflect"
- "sort"
- "strings"
- "time"
- "unicode"
-)
-
-const insightSchemaVersion = 1
-
-const (
- InsightUnclassified = "UNCLASSIFIED"
- InsightPendingDelivery = "PENDING_DELIVERY"
- InsightEvaluating = "EVALUATING"
- InsightNeedsEvidence = "NEEDS_EVIDENCE"
- InsightWaitingForTerminal = "WAITING_FOR_TERMINAL"
- InsightReadyToComplete = "READY_TO_COMPLETE"
-)
-
-var (
- insightNow = time.Now
- insightRandomRead = rand.Read
-)
-
-type InsightSource struct {
- Kind string `json:"kind"`
- Exact string `json:"exact"`
-}
-
-type InsightSourceIdentity struct {
- SHA256 string `json:"sha256"`
- Bytes int `json:"bytes"`
-}
-
-type InsightProjectedField struct {
- Text string `json:"text"`
- SourceRelationIDs []string `json:"source_relation_ids"`
-}
-
-type InsightValidationField struct {
- Text string `json:"text"`
- SourceRelationIDs []string `json:"source_relation_ids"`
- SuccessSignal string `json:"success_signal"`
-}
-
-type InsightSourceRelation struct {
- ID string `json:"id"`
- Subject string `json:"subject"`
- Relation string `json:"relation"`
- Object string `json:"object"`
- Excerpt string `json:"excerpt"`
-}
-
-type InsightRepositoryEvidence struct {
- ID string `json:"id"`
- ClaimID string `json:"claim_id"`
- Kind string `json:"kind"`
- Path string `json:"path"`
- Line int `json:"line"`
- Observation string `json:"observation"`
-}
-
-type InsightClaimAssessment struct {
- Claim InsightSourceRelation `json:"claim"`
- Status string `json:"status"`
- EvidenceIDs []string `json:"evidence_ids"`
-}
-
-type InsightResolvedUnknown struct {
- Unknown InsightProjectedField `json:"unknown"`
- Resolution string `json:"resolution"`
- EvidenceIDs []string `json:"evidence_ids"`
-}
-
-// InsightValueMapSnapshot mirrors the durable, human-confirmed portion of the
-// conversational Product Value Map. Value Map itself remains read-only and
-// writes nothing; this separately authorized snapshot is Boatstack input.
-type InsightValueMapSnapshot struct {
- Operator string `json:"operator"`
- Source InsightSourceIdentity `json:"source"`
- User InsightProjectedField `json:"user"`
- CurrentState InsightProjectedField `json:"current_state"`
- ValueGap InsightProjectedField `json:"value_gap"`
- DesiredOutcome InsightProjectedField `json:"desired_outcome"`
- ValueMechanism InsightProjectedField `json:"value_mechanism"`
- SmallestProof InsightValidationField `json:"smallest_proof"`
- Constraints []InsightProjectedField `json:"constraints"`
- Assessments []InsightClaimAssessment `json:"assessments"`
- Evidence []InsightRepositoryEvidence `json:"evidence"`
- Contradictions []InsightRepositoryEvidence `json:"contradictions"`
- Unknowns []InsightProjectedField `json:"unknowns"`
- ResolvedUnknowns []InsightResolvedUnknown `json:"resolved_unknowns"`
- FollowUpQuestions []string `json:"follow_up_questions"`
- Verdict struct {
- Status string `json:"status"`
- EvidenceGrade string `json:"evidence_grade"`
- Statement string `json:"statement"`
- } `json:"verdict"`
-}
-
-type InsightCaptureDraft struct {
- SchemaVersion int `json:"schema_version"`
- Source InsightSource `json:"source"`
- ValueMap InsightValueMapSnapshot `json:"value_map"`
- PrimaryTopic string `json:"primary_topic,omitempty"`
- RelatedTopics []string `json:"related_topics,omitempty"`
-}
-
-type InsightCapture struct {
- SchemaVersion int `json:"schema_version"`
- ID string `json:"id"`
- CapturedAt string `json:"captured_at"`
- PreviewNonce string `json:"preview_nonce"`
- PreviewFingerprint string `json:"preview_fingerprint"`
- Source InsightSource `json:"source"`
- ValueMap InsightValueMapSnapshot `json:"value_map"`
- PrimaryTopic string `json:"primary_topic,omitempty"`
- RelatedTopics []string `json:"related_topics,omitempty"`
-}
-
-type InsightCheckResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- PreviewNonce string `json:"preview_nonce"`
- PreviewFingerprint string `json:"preview_fingerprint"`
- SourceSHA256 string `json:"source_sha256"`
- RepositoryPath string `json:"repository_path"`
- Draft InsightCaptureDraft `json:"draft"`
-}
-
-type InsightBinding struct {
- Feature string `json:"feature"`
- Criteria []string `json:"criteria"`
-}
-
-type InsightEvaluation struct {
- State string `json:"state"`
- Reason string `json:"reason"`
- Feature string `json:"feature,omitempty"`
- Criteria []string `json:"criteria,omitempty"`
- Terminal string `json:"terminal,omitempty"`
-}
-
-type InsightDisposition struct {
- Outcome string `json:"outcome"`
- Reason string `json:"reason,omitempty"`
- DuplicateOf string `json:"duplicate_of,omitempty"`
-}
-
-type InsightEvent struct {
- SchemaVersion int `json:"schema_version"`
- ID string `json:"id"`
- Type string `json:"type"`
- RecordedAt string `json:"recorded_at"`
- PrimaryTopic string `json:"primary_topic,omitempty"`
- RelatedTopics []string `json:"related_topics,omitempty"`
- Binding *InsightBinding `json:"binding,omitempty"`
- Evaluation *InsightEvaluation `json:"evaluation,omitempty"`
- Disposition *InsightDisposition `json:"disposition,omitempty"`
-}
-
-type InsightView struct {
- Capture InsightCapture `json:"capture"`
- RepositoryPath string `json:"repository_path"`
- PrimaryTopic string `json:"primary_topic,omitempty"`
- RelatedTopics []string `json:"related_topics,omitempty"`
- Binding *InsightBinding `json:"binding,omitempty"`
- Evaluation InsightEvaluation `json:"evaluation"`
- Disposition *InsightDisposition `json:"disposition,omitempty"`
- Events []InsightEvent `json:"events,omitempty"`
-}
-
-type InsightFrontierRow struct {
- ID string `json:"id"`
- PrimaryTopic string `json:"primary_topic,omitempty"`
- State string `json:"state"`
- Reason string `json:"reason"`
- NextActor string `json:"next_actor"`
- NextAction string `json:"next_action"`
-}
-
-type InsightFrontierReport struct {
- SchemaVersion int `json:"schema_version"`
- Rows []InsightFrontierRow `json:"rows"`
-}
-
-func insightTimestamp() string {
- return insightNow().UTC().Truncate(time.Second).Format(time.RFC3339)
-}
-
-func insightRandomHex(bytes int) (string, error) {
- value := make([]byte, bytes)
- if _, err := insightRandomRead(value); err != nil {
- return "", err
- }
- return hex.EncodeToString(value), nil
-}
-
-func normalizeTopic(value string) (string, error) {
- value = strings.TrimSpace(value)
- if value == "" {
- return "", nil
- }
- if len(value) > 160 {
- return "", fmt.Errorf("feature topic exceeds 160 bytes")
- }
- for _, r := range value {
- if unicode.IsControl(r) {
- return "", fmt.Errorf("feature topic contains control characters")
- }
- }
- return value, nil
-}
-
-func normalizeTopics(primary string, related []string) (string, []string, error) {
- primary, err := normalizeTopic(primary)
- if err != nil {
- return "", nil, err
- }
- if len(related) > 20 {
- return "", nil, fmt.Errorf("at most 20 related feature topics are allowed")
- }
- seen := map[string]bool{}
- if primary != "" {
- seen[strings.ToLower(primary)] = true
- }
- result := make([]string, 0, len(related))
- for _, candidate := range related {
- candidate, err = normalizeTopic(candidate)
- if err != nil {
- return "", nil, err
- }
- if candidate == "" {
- return "", nil, fmt.Errorf("related feature topics must be non-empty")
- }
- key := strings.ToLower(candidate)
- if seen[key] {
- return "", nil, fmt.Errorf("feature topics must be unique")
- }
- seen[key] = true
- result = append(result, candidate)
- }
- sort.Strings(result)
- return primary, result, nil
-}
-
-func validateProjectedField(name string, field InsightProjectedField, claims map[string]bool) error {
- if strings.TrimSpace(field.Text) == "" || len(field.SourceRelationIDs) == 0 {
- return fmt.Errorf("value_map.%s requires text and source relation lineage", name)
- }
- for _, id := range field.SourceRelationIDs {
- if !claims[id] {
- return fmt.Errorf("value_map.%s references unknown source relation %s", name, id)
- }
- }
- return nil
-}
-
-func validateInsightDraft(draft InsightCaptureDraft) (InsightCaptureDraft, error) {
- if draft.SchemaVersion != insightSchemaVersion {
- return draft, fmt.Errorf("insight schema_version must be %d", insightSchemaVersion)
- }
- draft.Source.Kind = strings.TrimSpace(draft.Source.Kind)
- if draft.Source.Kind == "" || draft.Source.Exact == "" {
- return draft, fmt.Errorf("insight source kind and exact text are required")
- }
- primary, related, err := normalizeTopics(draft.PrimaryTopic, draft.RelatedTopics)
- if err != nil {
- return draft, err
- }
- draft.PrimaryTopic, draft.RelatedTopics = primary, related
- vm := &draft.ValueMap
- if vm.Operator != "product-value-projection" {
- return draft, fmt.Errorf("value_map.operator must be product-value-projection")
- }
- sourceBytes := []byte(draft.Source.Exact)
- if vm.Source.SHA256 != SHA256Bytes(sourceBytes) || vm.Source.Bytes != len(sourceBytes) {
- return draft, fmt.Errorf("value map source identity does not match the exact captured input")
- }
- claims := map[string]bool{}
- for _, assessment := range vm.Assessments {
- claim := assessment.Claim
- if strings.TrimSpace(claim.ID) == "" || claims[claim.ID] || strings.TrimSpace(claim.Excerpt) == "" {
- return draft, fmt.Errorf("value map assessments require unique claims with exact excerpts")
- }
- claims[claim.ID] = true
- switch assessment.Status {
- case "repo-supported", "repo-contradicted", "source-only":
- default:
- return draft, fmt.Errorf("value map assessment %s has invalid status", claim.ID)
- }
- }
- if len(claims) == 0 {
- return draft, fmt.Errorf("value map requires assessed source relations")
- }
- for name, field := range map[string]InsightProjectedField{
- "user": vm.User, "current_state": vm.CurrentState, "value_gap": vm.ValueGap,
- "desired_outcome": vm.DesiredOutcome, "value_mechanism": vm.ValueMechanism,
- } {
- if err := validateProjectedField(name, field, claims); err != nil {
- return draft, err
- }
- }
- if err := validateProjectedField("smallest_proof", InsightProjectedField{Text: vm.SmallestProof.Text, SourceRelationIDs: vm.SmallestProof.SourceRelationIDs}, claims); err != nil {
- return draft, err
- }
- if strings.TrimSpace(vm.SmallestProof.SuccessSignal) == "" {
- return draft, fmt.Errorf("value_map.smallest_proof requires an observable success signal")
- }
- for _, evidence := range append(append([]InsightRepositoryEvidence{}, vm.Evidence...), vm.Contradictions...) {
- if strings.TrimSpace(evidence.ID) == "" || !claims[evidence.ClaimID] || strings.TrimSpace(evidence.Path) == "" || evidence.Line < 1 || strings.TrimSpace(evidence.Observation) == "" {
- return draft, fmt.Errorf("value map repository evidence is incomplete or unbound")
- }
- if evidence.Kind != "supports" && evidence.Kind != "contradicts" {
- return draft, fmt.Errorf("value map repository evidence kind must support or contradict")
- }
- }
- switch vm.Verdict.Status {
- case "testable", "blocked":
- default:
- return draft, fmt.Errorf("value map verdict status must be testable or blocked")
- }
- switch vm.Verdict.EvidenceGrade {
- case "repo-grounded", "source-only", "contradicted", "insufficient":
- default:
- return draft, fmt.Errorf("value map evidence grade is invalid")
- }
- if strings.TrimSpace(vm.Verdict.Statement) == "" {
- return draft, fmt.Errorf("value map verdict statement is required")
- }
- return draft, nil
-}
-
-func requireInsightWorkspace(repoPath string) (string, WorkspaceContext, InsightPolicy, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return "", WorkspaceContext{}, InsightPolicy{}, err
- }
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return "", WorkspaceContext{}, InsightPolicy{}, err
- }
- config, _, err := LoadConfig(ctx.ProjectConfigPath())
- if err != nil {
- return "", WorkspaceContext{}, InsightPolicy{}, err
- }
- if config.Insights == nil || !config.Insights.Enabled {
- return "", WorkspaceContext{}, InsightPolicy{}, fmt.Errorf("insights are not enabled for this Boatstack project")
- }
- if err := validateInsightConfig(config.Insights); err != nil {
- return "", WorkspaceContext{}, InsightPolicy{}, err
- }
- return repo, ctx, *config.Insights, nil
-}
-
-func CheckInsightCapture(repoPath string, input []byte) (InsightCheckResult, error) {
- _, ctx, _, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return InsightCheckResult{}, err
- }
- var draft InsightCaptureDraft
- if err := DecodeJSON("check insight capture", "stdin", input, &draft); err != nil {
- return InsightCheckResult{}, err
- }
- draft, err = validateInsightDraft(draft)
- if err != nil {
- return InsightCheckResult{}, err
- }
- normalized, err := MarshalJSON(draft)
- if err != nil {
- return InsightCheckResult{}, err
- }
- nonce, err := insightRandomHex(16)
- if err != nil {
- return InsightCheckResult{}, err
- }
- fingerprint := SHA256Bytes(append(append([]byte{}, normalized...), []byte("\x00"+nonce)...))
- repositoryPath, err := insightRepositoryPath(ctx, insightCaptureID(fingerprint))
- if err != nil {
- return InsightCheckResult{}, err
- }
- return InsightCheckResult{
- SchemaVersion: insightSchemaVersion, VerificationStatus: "VERIFIED", PreviewNonce: nonce,
- PreviewFingerprint: fingerprint, SourceSHA256: draft.ValueMap.Source.SHA256, RepositoryPath: repositoryPath, Draft: draft,
- }, nil
-}
-
-func insightCaptureID(previewFingerprint string) string {
- return "ins-" + SHA256Bytes([]byte("insight-capture\x00" + previewFingerprint))[:24]
-}
-
-func insightCaptureDir(ctx WorkspaceContext, id string) (string, error) {
- id, err := safeCacheSegment(id, "insight id")
- if err != nil || !strings.HasPrefix(id, "ins-") {
- return "", fmt.Errorf("invalid insight id")
- }
- root, err := ctx.InsightDir()
- if err != nil {
- return "", err
- }
- path := filepath.Join(root, id)
- if err := rejectSymlinkComponents(ctx.RepoRoot, path); err != nil {
- return "", err
- }
- return path, nil
-}
-
-func insightRepositoryPath(ctx WorkspaceContext, id string) (string, error) {
- directory, err := insightCaptureDir(ctx, id)
- if err != nil {
- return "", err
- }
- relative, err := filepath.Rel(ctx.RepoRoot, directory)
- if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
- return "", fmt.Errorf("insight path escapes repository boundary")
- }
- return filepath.ToSlash(relative), nil
-}
-
-func insightMarkdownFence(value string) string {
- fence := "```"
- for strings.Contains(value, fence) {
- fence += "`"
- }
- return fence
-}
-
-func renderInsightMarkdown(capture InsightCapture) []byte {
- var b strings.Builder
- fmt.Fprintf(&b, "# Insight %s\n\n", capture.ID)
- fmt.Fprintf(&b, "- Captured: %s\n", capture.CapturedAt)
- fmt.Fprintf(&b, "- Source SHA-256: `%s`\n", capture.ValueMap.Source.SHA256)
- fmt.Fprintf(&b, "- Preview fingerprint: `%s`\n", capture.PreviewFingerprint)
- if capture.PrimaryTopic != "" {
- fmt.Fprintf(&b, "- Primary topic: %s\n", capture.PrimaryTopic)
- }
- if len(capture.RelatedTopics) > 0 {
- fmt.Fprintf(&b, "- Related topics: %s\n", strings.Join(capture.RelatedTopics, ", "))
- }
- b.WriteString("\n## Exact source\n\n")
- fence := insightMarkdownFence(capture.Source.Exact)
- fmt.Fprintf(&b, "%s\n%s\n%s\n", fence, capture.Source.Exact, fence)
- b.WriteString("\n## Product Value Map\n\n")
- fields := []struct{ label, value string }{
- {"User", capture.ValueMap.User.Text},
- {"Current state", capture.ValueMap.CurrentState.Text},
- {"Value gap", capture.ValueMap.ValueGap.Text},
- {"Desired outcome", capture.ValueMap.DesiredOutcome.Text},
- {"Mechanism", capture.ValueMap.ValueMechanism.Text},
- {"Smallest proof", capture.ValueMap.SmallestProof.Text},
- {"Success signal", capture.ValueMap.SmallestProof.SuccessSignal},
- }
- for _, field := range fields {
- fmt.Fprintf(&b, "### %s\n\n%s\n\n", field.label, html.EscapeString(field.value))
- }
- b.WriteString("## Verdict\n\n")
- fmt.Fprintf(&b, "- Status: `%s`\n- Evidence grade: `%s`\n\n%s\n", capture.ValueMap.Verdict.Status, capture.ValueMap.Verdict.EvidenceGrade, html.EscapeString(capture.ValueMap.Verdict.Statement))
- return []byte(b.String())
-}
-
-func SaveInsightCapture(repoPath string, input []byte, nonce, fingerprint string) (InsightView, error) {
- _, ctx, _, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return InsightView{}, err
- }
- var draft InsightCaptureDraft
- if err := DecodeJSON("save insight capture", "stdin", input, &draft); err != nil {
- return InsightView{}, err
- }
- draft, err = validateInsightDraft(draft)
- if err != nil {
- return InsightView{}, err
- }
- normalized, err := MarshalJSON(draft)
- if err != nil {
- return InsightView{}, err
- }
- nonce = strings.TrimSpace(nonce)
- fingerprint = strings.TrimSpace(fingerprint)
- if nonce == "" || fingerprint == "" || SHA256Bytes(append(append([]byte{}, normalized...), []byte("\x00"+nonce)...)) != fingerprint {
- return InsightView{}, fmt.Errorf("insight preview fingerprint does not match the exact capture")
- }
- id := insightCaptureID(fingerprint)
- directory, err := insightCaptureDir(ctx, id)
- if err != nil {
- return InsightView{}, err
- }
- if existing, loadErr := loadInsightCapture(ctx, id); loadErr == nil {
- if existing.PreviewFingerprint != fingerprint {
- return InsightView{}, fmt.Errorf("existing insight identity does not match the confirmed preview")
- }
- return showInsightWithContext(repoPath, ctx, id)
- } else if !os.IsNotExist(loadErr) {
- return InsightView{}, loadErr
- }
- capture := InsightCapture{
- SchemaVersion: insightSchemaVersion, ID: id, CapturedAt: insightTimestamp(), PreviewFingerprint: fingerprint,
- PreviewNonce: nonce,
- Source: draft.Source, ValueMap: draft.ValueMap, PrimaryTopic: draft.PrimaryTopic, RelatedTopics: draft.RelatedTopics,
- }
- value, err := MarshalJSON(capture)
- if err != nil {
- return InsightView{}, err
- }
- root, err := ctx.InsightDir()
- if err != nil {
- return InsightView{}, err
- }
- if err := rejectSymlinkComponents(ctx.RepoRoot, root); err != nil {
- return InsightView{}, err
- }
- if err := os.MkdirAll(root, 0o755); err != nil {
- return InsightView{}, err
- }
- temporary, err := os.MkdirTemp(root, ".insight-*")
- if err != nil {
- return InsightView{}, err
- }
- defer os.RemoveAll(temporary)
- if err := os.Chmod(temporary, 0o755); err != nil {
- return InsightView{}, err
- }
- if err := atomicWriteMode(filepath.Join(temporary, "capture.json"), value, 0o644); err != nil {
- return InsightView{}, err
- }
- if err := atomicWriteMode(filepath.Join(temporary, "insight.md"), renderInsightMarkdown(capture), 0o644); err != nil {
- return InsightView{}, err
- }
- if err := atomicWriteMode(filepath.Join(temporary, "events.jsonl"), []byte{}, 0o644); err != nil {
- return InsightView{}, err
- }
- if err := os.Rename(temporary, directory); err != nil {
- if existing, loadErr := loadInsightCapture(ctx, id); loadErr == nil && existing.PreviewFingerprint == fingerprint {
- return showInsightWithContext(repoPath, ctx, id)
- }
- return InsightView{}, err
- }
- return showInsightWithContext(repoPath, ctx, id)
-}
-
-func loadInsightCapture(ctx WorkspaceContext, id string) (InsightCapture, error) {
- directory, err := insightCaptureDir(ctx, id)
- if err != nil {
- return InsightCapture{}, err
- }
- path := filepath.Join(directory, "capture.json")
- value, err := os.ReadFile(path)
- if err != nil {
- return InsightCapture{}, err
- }
- var capture InsightCapture
- if err := DecodeJSON("load insight capture", path, value, &capture); err != nil {
- return InsightCapture{}, err
- }
- if capture.SchemaVersion != insightSchemaVersion || capture.ID != id || capture.PreviewNonce == "" || capture.PreviewFingerprint == "" {
- return InsightCapture{}, fmt.Errorf("insight capture %s is invalid", id)
- }
- draft, err := validateInsightDraft(InsightCaptureDraft{
- SchemaVersion: capture.SchemaVersion, Source: capture.Source, ValueMap: capture.ValueMap,
- PrimaryTopic: capture.PrimaryTopic, RelatedTopics: capture.RelatedTopics,
- })
- if err != nil {
- return InsightCapture{}, fmt.Errorf("insight capture %s is invalid: %w", id, err)
- }
- normalized, err := MarshalJSON(draft)
- if err != nil {
- return InsightCapture{}, err
- }
- wantFingerprint := SHA256Bytes(append(append([]byte{}, normalized...), []byte("\x00"+capture.PreviewNonce)...))
- if wantFingerprint != capture.PreviewFingerprint || insightCaptureID(wantFingerprint) != capture.ID {
- return InsightCapture{}, fmt.Errorf("insight capture %s fingerprint is invalid", id)
- }
- markdownPath := filepath.Join(directory, "insight.md")
- markdown, err := os.ReadFile(markdownPath)
- if err != nil {
- return InsightCapture{}, err
- }
- if string(markdown) != string(renderInsightMarkdown(capture)) {
- return InsightCapture{}, fmt.Errorf("insight capture %s human-readable projection is stale or modified", id)
- }
- return capture, nil
-}
-
-func loadInsightEvents(ctx WorkspaceContext, id string) ([]InsightEvent, error) {
- directory, err := insightCaptureDir(ctx, id)
- if err != nil {
- return nil, err
- }
- path := filepath.Join(directory, "events.jsonl")
- value, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- events := []InsightEvent{}
- for index, line := range strings.Split(strings.TrimSpace(string(value)), "\n") {
- if strings.TrimSpace(line) == "" {
- continue
- }
- var event InsightEvent
- if err := DecodeJSON("load insight event", fmt.Sprintf("%s:%d", path, index+1), []byte(line), &event); err != nil {
- return nil, err
- }
- if event.SchemaVersion != insightSchemaVersion || event.ID == "" || event.Type == "" || event.RecordedAt == "" {
- return nil, fmt.Errorf("insight event is invalid: %s:%d", path, index+1)
- }
- events = append(events, event)
- }
- return events, nil
-}
-
-func withInsightLock(ctx WorkspaceContext, id string, apply func() error) error {
- root, err := ctx.InsightDir()
- if err != nil {
- return err
- }
- lock := filepath.Join(root, ".locks", id+".lock")
- if err := rejectSymlinkComponents(ctx.RepoRoot, lock); err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil {
- return err
- }
- for attempt := 0; attempt < 100; attempt++ {
- file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if openErr == nil {
- _, _ = fmt.Fprintf(file, "%d %s\n", os.Getpid(), insightTimestamp())
- _ = file.Close()
- defer os.Remove(lock)
- return apply()
- }
- if !isLockContention(openErr, lock) {
- return openErr
- }
- if info, statErr := os.Stat(lock); statErr == nil && insightNow().Sub(info.ModTime()) > time.Minute {
- _ = os.Remove(lock)
- continue
- }
- time.Sleep(10 * time.Millisecond)
- }
- return fmt.Errorf("insight %s is busy", id)
-}
-
-func comparableInsightEvent(event InsightEvent) InsightEvent {
- event.ID, event.RecordedAt = "", ""
- return event
-}
-
-func appendInsightEvent(ctx WorkspaceContext, capture InsightCapture, event InsightEvent) (InsightEvent, error) {
- var accepted InsightEvent
- err := withInsightLock(ctx, capture.ID, func() error {
- events, err := loadInsightEvents(ctx, capture.ID)
- if err != nil {
- return err
- }
- candidate, err := MarshalJSON(comparableInsightEvent(event))
- if err != nil {
- return err
- }
- if len(events) > 0 {
- last, _ := MarshalJSON(comparableInsightEvent(events[len(events)-1]))
- if string(last) == string(candidate) {
- accepted = events[len(events)-1]
- return nil
- }
- }
- event.SchemaVersion = insightSchemaVersion
- event.RecordedAt = insightTimestamp()
- event.ID = "iev-" + SHA256Bytes([]byte(capture.ID + "\x00" + fmt.Sprint(len(events)) + "\x00" + string(candidate)))[:24]
- line, err := json.Marshal(event)
- if err != nil {
- return err
- }
- directory, err := insightCaptureDir(ctx, capture.ID)
- if err != nil {
- return err
- }
- path := filepath.Join(directory, "events.jsonl")
- existing, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- if len(existing) > 0 && existing[len(existing)-1] != '\n' {
- existing = append(existing, '\n')
- }
- existing = append(existing, line...)
- existing = append(existing, '\n')
- if err := atomicWriteMode(path, existing, 0o644); err != nil {
- return err
- }
- accepted = event
- return nil
- })
- return accepted, err
-}
-
-func applyInsightEvents(capture InsightCapture, events []InsightEvent) InsightView {
- view := InsightView{Capture: capture, PrimaryTopic: capture.PrimaryTopic, RelatedTopics: append([]string{}, capture.RelatedTopics...), Events: events}
- for _, event := range events {
- switch event.Type {
- case "associated":
- view.PrimaryTopic = event.PrimaryTopic
- view.RelatedTopics = append([]string{}, event.RelatedTopics...)
- case "bound":
- if event.Binding != nil {
- copy := *event.Binding
- copy.Criteria = append([]string{}, event.Binding.Criteria...)
- view.Binding = ©
- }
- case "evaluated":
- if event.Evaluation != nil {
- view.Evaluation = *event.Evaluation
- }
- case "dispositioned":
- if event.Disposition != nil {
- copy := *event.Disposition
- view.Disposition = ©
- }
- }
- }
- return view
-}
-
-func showInsightWithContext(repoPath string, ctx WorkspaceContext, id string) (InsightView, error) {
- capture, err := loadInsightCapture(ctx, id)
- if err != nil {
- return InsightView{}, err
- }
- events, err := loadInsightEvents(ctx, id)
- if err != nil {
- return InsightView{}, err
- }
- view := applyInsightEvents(capture, events)
- view.RepositoryPath, err = insightRepositoryPath(ctx, id)
- if err != nil {
- return InsightView{}, err
- }
- view.Evaluation = evaluateInsightView(repoPath, view)
- return view, nil
-}
-
-func ShowInsight(repoPath, id string) (InsightView, error) {
- _, ctx, _, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return InsightView{}, err
- }
- return showInsightWithContext(repoPath, ctx, id)
-}
-
-func ListInsights(repoPath string) ([]InsightView, error) {
- _, ctx, _, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return nil, err
- }
- root, err := ctx.InsightDir()
- if err != nil {
- return nil, err
- }
- entries, err := os.ReadDir(root)
- if os.IsNotExist(err) {
- return []InsightView{}, nil
- }
- if err != nil {
- return nil, err
- }
- views := []InsightView{}
- for _, entry := range entries {
- if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "ins-") {
- continue
- }
- view, err := showInsightWithContext(repoPath, ctx, entry.Name())
- if err != nil {
- return nil, err
- }
- views = append(views, view)
- }
- sort.Slice(views, func(i, j int) bool { return views[i].Capture.ID < views[j].Capture.ID })
- return views, nil
-}
-
-func ensureInsightOpen(view InsightView) error {
- if view.Disposition != nil {
- return fmt.Errorf("insight %s is already dispositioned as %s", view.Capture.ID, view.Disposition.Outcome)
- }
- return nil
-}
-
-func AssociateInsight(repoPath, id, primary string, related []string) (InsightView, error) {
- _, ctx, _, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return InsightView{}, err
- }
- view, err := showInsightWithContext(repoPath, ctx, id)
- if err != nil {
- return InsightView{}, err
- }
- if err := ensureInsightOpen(view); err != nil {
- return InsightView{}, err
- }
- primary, related, err = normalizeTopics(primary, related)
- if err != nil {
- return InsightView{}, err
- }
- if primary == "" {
- return InsightView{}, fmt.Errorf("a primary feature topic is required")
- }
- if _, err := appendInsightEvent(ctx, view.Capture, InsightEvent{Type: "associated", PrimaryTopic: primary, RelatedTopics: related}); err != nil {
- return InsightView{}, err
- }
- return showInsightWithContext(repoPath, ctx, id)
-}
-
-func planCriterionIDs(repo, feature string) (map[string]bool, error) {
- plan, err := LoadPlan(filepath.Join(planningFeatureDir(repo, feature), "plan.md"))
- if err != nil {
- return nil, err
- }
- criteria, ok := objectSlice(plan["acceptance_criteria"])
- if !ok {
- return nil, fmt.Errorf("feature %s has no valid acceptance criteria", feature)
- }
- result := map[string]bool{}
- for _, criterion := range criteria {
- if id := strings.TrimSpace(stringValue(criterion["id"])); id != "" {
- result[id] = true
- }
- }
- return result, nil
-}
-
-func BindInsight(repoPath, id, feature string, criteria []string) (InsightView, error) {
- repo, ctx, _, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return InsightView{}, err
- }
- view, err := showInsightWithContext(repo, ctx, id)
- if err != nil {
- return InsightView{}, err
- }
- if err := ensureInsightOpen(view); err != nil {
- return InsightView{}, err
- }
- if strings.TrimSpace(view.PrimaryTopic) == "" {
- return InsightView{}, fmt.Errorf("insight requires a primary feature topic before delivery binding")
- }
- feature = strings.TrimSpace(feature)
- if !featureSlugPattern.MatchString(feature) {
- return InsightView{}, fmt.Errorf("invalid managed feature id")
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return InsightView{}, fmt.Errorf("managed feature %s is unavailable: %w", feature, err)
- }
- if err := checkDeliveryPlanLock(repo, feature, state); err != nil {
- return InsightView{}, err
- }
- available, err := planCriterionIDs(repo, feature)
- if err != nil {
- return InsightView{}, err
- }
- if len(criteria) == 0 {
- return InsightView{}, fmt.Errorf("at least one acceptance criterion is required")
- }
- seen := map[string]bool{}
- normalized := make([]string, 0, len(criteria))
- for _, criterion := range criteria {
- criterion = strings.TrimSpace(criterion)
- if criterion == "" || !available[criterion] {
- return InsightView{}, fmt.Errorf("feature %s has no current acceptance criterion %s", feature, criterion)
- }
- if !seen[criterion] {
- seen[criterion] = true
- normalized = append(normalized, criterion)
- }
- }
- sort.Strings(normalized)
- if _, err := appendInsightEvent(ctx, view.Capture, InsightEvent{Type: "bound", Binding: &InsightBinding{Feature: feature, Criteria: normalized}}); err != nil {
- return InsightView{}, err
- }
- return showInsightWithContext(repo, ctx, id)
-}
-
-func containsCriterion(values []string, target string) bool {
- for _, value := range values {
- if value == target {
- return true
- }
- }
- return false
-}
-
-func evaluateInsightView(repo string, view InsightView) InsightEvaluation {
- if strings.TrimSpace(view.PrimaryTopic) == "" {
- return InsightEvaluation{State: InsightUnclassified, Reason: "The capture needs a human-confirmed primary feature topic."}
- }
- if view.Binding == nil {
- return InsightEvaluation{State: InsightPendingDelivery, Reason: "The primary feature topic is not bound to a managed delivery."}
- }
- binding := *view.Binding
- result := InsightEvaluation{Feature: binding.Feature, Criteria: append([]string{}, binding.Criteria...)}
- state, err := LoadDeliveryState(repo, binding.Feature)
- if err != nil {
- result.State, result.Reason = InsightNeedsEvidence, "The bound managed delivery is missing or unreadable."
- return result
- }
- if err := checkDeliveryPlanLock(repo, binding.Feature, state); err != nil {
- result.State, result.Reason = InsightNeedsEvidence, "The bound managed delivery no longer has a current plan lock."
- return result
- }
- available, err := planCriterionIDs(repo, binding.Feature)
- if err != nil {
- result.State, result.Reason = InsightNeedsEvidence, "The bound feature plan cannot be inspected."
- return result
- }
- for _, criterion := range binding.Criteria {
- if !available[criterion] {
- result.State, result.Reason = InsightNeedsEvidence, "A bound acceptance criterion is stale or missing."
- return result
- }
- }
- relevant := map[int]bool{}
- for _, criterion := range binding.Criteria {
- found := false
- for index, slice := range state.Slices {
- if containsCriterion(slice.AcceptanceCriteria, criterion) {
- relevant[index], found = true, true
- }
- }
- if !found {
- result.State, result.Reason = InsightNeedsEvidence, "A bound acceptance criterion is not assigned to a delivery slice."
- return result
- }
- }
- for index := range relevant {
- if state.Slices[index].Status != StatusPublished {
- result.State, result.Reason = InsightEvaluating, "The mapped delivery slice has not completed its test, review, and publication gates."
- return result
- }
- }
- terminal := resolveDeliveryTerminal(repo, binding.Feature)
- result.Terminal = string(terminal)
- for index := range relevant {
- prState := strings.ToUpper(strings.TrimSpace(state.Slices[index].PRState))
- if prState == "CLOSED" || prState == "PUBLISHED_CLOSED" {
- result.State, result.Reason = InsightNeedsEvidence, "A mapped delivery pull request closed without satisfying the terminal goal."
- return result
- }
- }
- if terminal == TerminalPublished {
- result.State, result.Reason = InsightReadyToComplete, "Mapped acceptance evidence passed and the delivery pull request is published."
- return result
- }
- for index := range relevant {
- prState := strings.ToUpper(strings.TrimSpace(state.Slices[index].PRState))
- if prState != "MERGED" && prState != "PUBLISHED_MERGED" {
- result.State, result.Reason = InsightWaitingForTerminal, "Mapped acceptance evidence passed; the delivery is waiting for a merged pull request."
- return result
- }
- }
- result.State, result.Reason = InsightReadyToComplete, "Mapped acceptance evidence passed and the delivery pull request is merged."
- return result
-}
-
-func EvaluateInsight(repoPath, id string) (InsightEvaluation, error) {
- view, err := ShowInsight(repoPath, id)
- if err != nil {
- return InsightEvaluation{}, err
- }
- return view.Evaluation, nil
-}
-
-func reconcileInsightsForFeature(repoPath, feature string) {
- _, ctx, policy, err := requireInsightWorkspace(repoPath)
- if err != nil || !policy.EvaluateOnPR {
- return
- }
- views, err := ListInsights(repoPath)
- if err != nil {
- return
- }
- for _, view := range views {
- if view.Binding == nil || view.Binding.Feature != feature || view.Disposition != nil {
- continue
- }
- evaluation := evaluateInsightView(repoPath, view)
- if len(view.Events) > 0 {
- last := view.Events[len(view.Events)-1]
- if last.Type == "evaluated" && last.Evaluation != nil && reflect.DeepEqual(*last.Evaluation, evaluation) {
- continue
- }
- }
- _, _ = appendInsightEvent(ctx, view.Capture, InsightEvent{Type: "evaluated", Evaluation: &evaluation})
- }
-}
-
-func DisposeInsight(repoPath, id, outcome, reason, duplicateOf string) (InsightView, error) {
- _, ctx, _, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return InsightView{}, err
- }
- view, err := showInsightWithContext(repoPath, ctx, id)
- if err != nil {
- return InsightView{}, err
- }
- if err := ensureInsightOpen(view); err != nil {
- return InsightView{}, err
- }
- outcome = strings.ToLower(strings.TrimSpace(outcome))
- reason = strings.TrimSpace(reason)
- duplicateOf = strings.TrimSpace(duplicateOf)
- switch outcome {
- case "completed":
- if view.Evaluation.State != InsightReadyToComplete && reason == "" {
- return InsightView{}, fmt.Errorf("early completion requires a recorded reason")
- }
- case "deferred", "rejected":
- if reason == "" {
- return InsightView{}, fmt.Errorf("%s disposition requires a reason", outcome)
- }
- case "duplicate":
- if reason == "" || duplicateOf == "" || duplicateOf == id {
- return InsightView{}, fmt.Errorf("duplicate disposition requires a reason and another capture id")
- }
- if _, err := loadInsightCapture(ctx, duplicateOf); err != nil {
- return InsightView{}, fmt.Errorf("duplicate target is unavailable: %w", err)
- }
- default:
- return InsightView{}, fmt.Errorf("insight outcome must be completed, deferred, rejected, or duplicate")
- }
- disposition := &InsightDisposition{Outcome: outcome, Reason: reason, DuplicateOf: duplicateOf}
- if _, err := appendInsightEvent(ctx, view.Capture, InsightEvent{Type: "dispositioned", Disposition: disposition}); err != nil {
- return InsightView{}, err
- }
- return showInsightWithContext(repoPath, ctx, id)
-}
-
-func InsightFrontier(repoPath string) (InsightFrontierReport, error) {
- _, _, policy, err := requireInsightWorkspace(repoPath)
- if err != nil {
- return InsightFrontierReport{}, err
- }
- if !policy.PendingFrontier {
- return InsightFrontierReport{}, fmt.Errorf("insights.pending_frontier is not enabled")
- }
- views, err := ListInsights(repoPath)
- if err != nil {
- return InsightFrontierReport{}, err
- }
- report := InsightFrontierReport{SchemaVersion: insightSchemaVersion, Rows: []InsightFrontierRow{}}
- for _, view := range views {
- if view.Disposition != nil {
- continue
- }
- row := InsightFrontierRow{ID: view.Capture.ID, PrimaryTopic: view.PrimaryTopic, State: view.Evaluation.State, Reason: view.Evaluation.Reason}
- switch view.Evaluation.State {
- case InsightUnclassified:
- row.NextActor, row.NextAction = "human", "confirm a primary feature topic"
- case InsightPendingDelivery:
- row.NextActor, row.NextAction = "human", "bind the primary topic to a managed delivery"
- case InsightEvaluating:
- row.NextActor, row.NextAction = "delivery", "continue the mapped Boatstack delivery"
- case InsightNeedsEvidence:
- row.NextActor, row.NextAction = "human", "review the named evidence gap"
- case InsightWaitingForTerminal:
- row.NextActor, row.NextAction = "delivery", "wait for the configured delivery terminal"
- case InsightReadyToComplete:
- row.NextActor, row.NextAction = "human", "confirm insight completion"
- }
- report.Rows = append(report.Rows, row)
- }
- rank := map[string]int{InsightReadyToComplete: 0, InsightNeedsEvidence: 1, InsightUnclassified: 2, InsightPendingDelivery: 3, InsightEvaluating: 4, InsightWaitingForTerminal: 5}
- sort.Slice(report.Rows, func(i, j int) bool {
- if rank[report.Rows[i].State] == rank[report.Rows[j].State] {
- return report.Rows[i].ID < report.Rows[j].ID
- }
- return rank[report.Rows[i].State] < rank[report.Rows[j].State]
- })
- return report, nil
-}
-
-func FormatInsightFrontier(report InsightFrontierReport) string {
- if len(report.Rows) == 0 {
- return "Insight frontier: no pending captures.\n"
- }
- var b strings.Builder
- b.WriteString("Insight frontier\n")
- for _, row := range report.Rows {
- fmt.Fprintf(&b, "- %s [%s] %s — next: %s (%s)\n", row.ID, row.State, row.Reason, row.NextAction, row.NextActor)
- }
- return b.String()
-}
diff --git a/boatstack/insight_conformance_test.go b/boatstack/insight_conformance_test.go
deleted file mode 100644
index 87410f0..0000000
--- a/boatstack/insight_conformance_test.go
+++ /dev/null
@@ -1,480 +0,0 @@
-package boatstack
-
-// Boundary conformance for independent insights.
-// control-law: confirmed-insight-becomes-reviewable-repository-diff
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func configureInsights(t *testing.T, repo string, terminal DeliveryTerminal) WorkspaceContext {
- t.Helper()
- stateRoot := t.TempDir()
- t.Setenv("BOATSTACK_STATE_ROOT", stateRoot)
- config := testConfig()
- config.Insights = &InsightPolicy{
- Enabled: true, CaptureMode: "manual", ValueMap: "required", SuggestFeatures: true,
- EvaluateOnPR: true, PendingFrontier: true, CompletionMode: "human_confirmed",
- }
- if terminal != "" {
- config.Delivery = &DeliveryPolicy{Terminal: string(terminal)}
- }
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- configPath := filepath.Join(t.TempDir(), "project.json")
- if err := os.WriteFile(configPath, value, 0o600); err != nil {
- t.Fatal(err)
- }
- if _, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}); err != nil {
- t.Fatal(err)
- }
- return WorkspaceFor(repo)
-}
-
-func configureEmbeddedInsights(t *testing.T, repo string, terminal DeliveryTerminal) WorkspaceContext {
- t.Helper()
- ctx := embeddedWorkspace(repo)
- config := testConfig()
- config.Insights = &InsightPolicy{
- Enabled: true, CaptureMode: "manual", ValueMap: "required", SuggestFeatures: true,
- EvaluateOnPR: true, PendingFrontier: true, CompletionMode: "human_confirmed",
- }
- if terminal != "" {
- config.Delivery = &DeliveryPolicy{Terminal: string(terminal)}
- }
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(ctx.ProjectConfigPath()), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(ctx.ProjectConfigPath(), value, 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- return ctx
-}
-
-func syntheticInsightDraft(t *testing.T, exact, primary string) []byte {
- t.Helper()
- relation := InsightSourceRelation{ID: "R-1", Subject: "operator", Relation: "needs", Object: "observable progress", Excerpt: "needs observable progress"}
- field := InsightProjectedField{Text: "A user needs observable progress.", SourceRelationIDs: []string{"R-1"}}
- draft := InsightCaptureDraft{
- SchemaVersion: insightSchemaVersion,
- Source: InsightSource{Kind: "pasted", Exact: exact},
- PrimaryTopic: primary,
- RelatedTopics: []string{"Reporting"},
- }
- draft.ValueMap.Operator = "product-value-projection"
- draft.ValueMap.Source = InsightSourceIdentity{SHA256: SHA256Bytes([]byte(exact)), Bytes: len([]byte(exact))}
- draft.ValueMap.User = field
- draft.ValueMap.CurrentState = field
- draft.ValueMap.ValueGap = field
- draft.ValueMap.DesiredOutcome = field
- draft.ValueMap.ValueMechanism = field
- draft.ValueMap.SmallestProof = InsightValidationField{Text: field.Text, SourceRelationIDs: field.SourceRelationIDs, SuccessSignal: "The pending state is visible."}
- draft.ValueMap.Assessments = []InsightClaimAssessment{{Claim: relation, Status: "source-only", EvidenceIDs: []string{}}}
- draft.ValueMap.Constraints = []InsightProjectedField{}
- draft.ValueMap.Evidence = []InsightRepositoryEvidence{}
- draft.ValueMap.Contradictions = []InsightRepositoryEvidence{}
- draft.ValueMap.Unknowns = []InsightProjectedField{}
- draft.ValueMap.ResolvedUnknowns = []InsightResolvedUnknown{}
- draft.ValueMap.FollowUpQuestions = []string{}
- draft.ValueMap.Verdict.Status = "testable"
- draft.ValueMap.Verdict.EvidenceGrade = "source-only"
- draft.ValueMap.Verdict.Statement = "This is a testable source-only value hypothesis."
- value, err := MarshalJSON(draft)
- if err != nil {
- t.Fatal(err)
- }
- return value
-}
-
-func saveSyntheticInsight(t *testing.T, repo string, input []byte) InsightView {
- t.Helper()
- check, err := CheckInsightCapture(repo, input)
- if err != nil {
- t.Fatal(err)
- }
- view, err := SaveInsightCapture(repo, input, check.PreviewNonce, check.PreviewFingerprint)
- if err != nil {
- t.Fatal(err)
- }
- return view
-}
-
-// Positive, relation, bypass, replay, and independent-identity conformance:
-// confirmation creates only tracked repository artifacts and no detached data.
-func TestInsightCaptureRepositoryBoundary(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-capture.git")
- ctx := configureInsights(t, repo, TerminalMerged)
- input := syntheticInsightDraft(t, "A vague external observation.", "Message association")
- root, err := ctx.InsightDir()
- if err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(root); !os.IsNotExist(err) {
- t.Fatalf("insight check precondition changed: %v", err)
- }
- check, err := CheckInsightCapture(repo, input)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(root); !os.IsNotExist(err) {
- t.Fatal("read-only insight check created durable state")
- }
- if !strings.HasPrefix(check.RepositoryPath, "docs/insights/ins-") {
- t.Fatalf("preview did not disclose its repository target: %+v", check)
- }
- first, err := SaveInsightCapture(repo, input, check.PreviewNonce, check.PreviewFingerprint)
- if err != nil {
- t.Fatal(err)
- }
- replayed, err := SaveInsightCapture(repo, input, check.PreviewNonce, check.PreviewFingerprint)
- if err != nil || replayed.Capture.ID != first.Capture.ID {
- t.Fatalf("confirmed preview replay was not idempotent: %v %+v", err, replayed)
- }
- second := saveSyntheticInsight(t, repo, input)
- if second.Capture.ID == first.Capture.ID || second.Capture.ValueMap.Source.SHA256 != first.Capture.ValueMap.Source.SHA256 {
- t.Fatal("identical independent captures did not retain distinct ids and equal source fingerprints")
- }
- directory, err := insightCaptureDir(ctx, first.Capture.ID)
- if err != nil {
- t.Fatal(err)
- }
- resolvedRepo, err := ResolveRepository(repo)
- if err != nil {
- t.Fatal(err)
- }
- relativeDirectory, err := filepath.Rel(resolvedRepo, directory)
- if err != nil || relativeDirectory == ".." || strings.HasPrefix(relativeDirectory, ".."+string(filepath.Separator)) {
- t.Fatalf("capture did not become a repository artifact: %s", directory)
- }
- if first.RepositoryPath != filepath.ToSlash(filepath.Join("docs", "insights", first.Capture.ID)) {
- t.Fatalf("capture did not report its PR-ready path: %s", first.RepositoryPath)
- }
- status := gitPorcelain(t, repo)
- for _, name := range []string{"capture.json", "insight.md", "events.jsonl"} {
- want := filepath.ToSlash(filepath.Join("docs", "insights", first.Capture.ID, name))
- if !strings.Contains(status, want) {
- t.Fatalf("save did not create a tracked diff for %s: %s", want, status)
- }
- }
- if _, err := os.Stat(filepath.Join(ctx.controlRoot, "insights")); !os.IsNotExist(err) {
- t.Fatalf("insight data leaked into detached control state: %v", err)
- }
-}
-
-// Negative and failure-state conformance: stale bytes fail without a partial
-// artifact, while embedded and detached supervision both use the same repo path.
-func TestInsightCaptureRejectsStaleInputAndSupportsEmbeddedMode(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-negative.git")
- ctx := configureInsights(t, repo, TerminalMerged)
- input := syntheticInsightDraft(t, "Original exact bytes.", "Import quality")
- check, err := CheckInsightCapture(repo, input)
- if err != nil {
- t.Fatal(err)
- }
- tampered := syntheticInsightDraft(t, "Changed exact bytes.", "Import quality")
- if _, err := SaveInsightCapture(repo, tampered, check.PreviewNonce, check.PreviewFingerprint); err == nil {
- t.Fatal("stale preview accepted changed source bytes")
- }
- root, _ := ctx.InsightDir()
- entries, err := os.ReadDir(root)
- if err != nil && !os.IsNotExist(err) {
- t.Fatal(err)
- }
- for _, entry := range entries {
- if strings.HasPrefix(entry.Name(), "ins-") {
- t.Fatal("rejected save left a partial capture")
- }
- }
-
- embedded := detachedTestRepo(t, "https://example.invalid/insight-embedded.git")
- configureEmbeddedInsights(t, embedded, TerminalPublished)
- embeddedView := saveSyntheticInsight(t, embedded, input)
- if !strings.HasPrefix(filepath.FromSlash(embeddedView.RepositoryPath), filepath.Join("docs", "insights")) {
- t.Fatalf("embedded capture did not use the repository inbox: %+v", embeddedView)
- }
-}
-
-func installInsightDelivery(t *testing.T, repo string, ctx WorkspaceContext, feature string, terminal DeliveryTerminal) {
- t.Helper()
- directory := ctx.FeatureDir(feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- plan := map[string]any{
- "feature_id": feature,
- "acceptance_criteria": []any{map[string]any{"id": "AC-1", "description": "The visible outcome is observed."}},
- }
- writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true)
- lock := []byte("{\"schema_version\":1}\n")
- if err := os.WriteFile(filepath.Join(directory, "plan.lock.json"), lock, 0o600); err != nil {
- t.Fatal(err)
- }
- state := DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: SHA256Bytes(lock), ActiveIndex: 0,
- RepairCounters: map[string]int{}, Goal: string(terminal),
- Slices: []DeliverySlice{{ID: "delivery", Title: "Feature delivery", AcceptanceCriteria: []string{"AC-1"}, Status: StatusBuild}},
- }
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
-}
-
-// Relation conformance: capture -> topic -> delivery criterion -> PR lifecycle
-// -> readiness, while related topics never become completion gates.
-func TestInsightEvaluationAndHumanDisposition(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-evaluation.git")
- ctx := configureInsights(t, repo, TerminalMerged)
- feature := "observable-progress"
- installInsightDelivery(t, repo, ctx, feature, TerminalMerged)
- view := saveSyntheticInsight(t, repo, syntheticInsightDraft(t, "Show pending work.", "Progress visibility"))
- if view.Evaluation.State != InsightPendingDelivery {
- t.Fatalf("unexpected initial evaluation: %+v", view.Evaluation)
- }
- runGit(t, repo, "add", filepath.FromSlash(view.RepositoryPath))
- view, err := AssociateInsight(repo, view.Capture.ID, "Progress visibility", []string{"Secondary topic", "Reporting"})
- if err != nil {
- t.Fatal(err)
- }
- if status := gitPorcelain(t, repo); !strings.Contains(status, "AM "+filepath.ToSlash(filepath.Join(view.RepositoryPath, "events.jsonl"))) {
- t.Fatalf("association did not become a repository diff: %s", status)
- }
- view, err = BindInsight(repo, view.Capture.ID, feature, []string{"AC-1"})
- if err != nil {
- t.Fatal(err)
- }
- if view.Evaluation.State != InsightEvaluating {
- t.Fatalf("bound build should be evaluating: %+v", view.Evaluation)
- }
- if _, err := DisposeInsight(repo, view.Capture.ID, "completed", "", ""); err == nil {
- t.Fatal("non-ready completion without a reason was accepted")
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- state.Slices[0].Status = StatusPublished
- state.Slices[0].PRState = "OPEN"
- state.ActiveIndex = 1
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- evaluation, err := EvaluateInsight(repo, view.Capture.ID)
- if err != nil || evaluation.State != InsightWaitingForTerminal {
- t.Fatalf("open PR should wait for merged terminal: %v %+v", err, evaluation)
- }
- state.Slices[0].PRState = "MERGED"
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- evaluation, _ = EvaluateInsight(repo, view.Capture.ID)
- if evaluation.State != InsightReadyToComplete {
- t.Fatalf("merged evidence should be ready: %+v", evaluation)
- }
- completed, err := DisposeInsight(repo, view.Capture.ID, "completed", "", "")
- if err != nil || completed.Disposition == nil || completed.Disposition.Outcome != "completed" {
- t.Fatalf("human completion failed: %v %+v", err, completed)
- }
-}
-
-func TestInsightFrontierAndDuplicatePreserveCaptures(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-frontier.git")
- configureInsights(t, repo, TerminalPublished)
- unclassified := saveSyntheticInsight(t, repo, syntheticInsightDraft(t, "First observation.", ""))
- original := saveSyntheticInsight(t, repo, syntheticInsightDraft(t, "Second observation.", "Search"))
- duplicate := saveSyntheticInsight(t, repo, syntheticInsightDraft(t, "Second observation repeated.", "Search"))
- if _, err := DisposeInsight(repo, duplicate.Capture.ID, "duplicate", "Same underlying observation.", original.Capture.ID); err != nil {
- t.Fatal(err)
- }
- report, err := InsightFrontier(repo)
- if err != nil {
- t.Fatal(err)
- }
- if len(report.Rows) != 2 || report.Rows[0].ID != unclassified.Capture.ID || report.Rows[0].State != InsightUnclassified {
- t.Fatalf("unexpected frontier ordering or duplicate filtering: %+v", report.Rows)
- }
- views, err := ListInsights(repo)
- if err != nil || len(views) != 3 {
- t.Fatalf("duplicate disposition removed an independent capture: %v %+v", err, views)
- }
-}
-
-func TestInsightEvaluationFailureAndPublishedTerminalStates(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-published.git")
- ctx := configureInsights(t, repo, TerminalPublished)
- feature := "published-insight"
- installInsightDelivery(t, repo, ctx, feature, TerminalPublished)
- view := saveSyntheticInsight(t, repo, syntheticInsightDraft(t, "A published delivery may satisfy this insight.", ""))
- if view.Evaluation.State != InsightUnclassified {
- t.Fatalf("capture without a primary topic should be unclassified: %+v", view.Evaluation)
- }
- view, err := AssociateInsight(repo, view.Capture.ID, "Published insight", []string{"Related only"})
- if err != nil || view.Evaluation.State != InsightPendingDelivery {
- t.Fatalf("associated capture should wait for delivery: %v %+v", err, view.Evaluation)
- }
- if _, err := BindInsight(repo, view.Capture.ID, feature, []string{"AC-stale"}); err == nil {
- t.Fatal("missing acceptance criterion was accepted")
- }
- view, err = BindInsight(repo, view.Capture.ID, feature, []string{"AC-1"})
- if err != nil || view.Evaluation.State != InsightEvaluating {
- t.Fatalf("valid binding should evaluate: %v %+v", err, view.Evaluation)
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- state.Slices[0].Status = StatusPublished
- state.Slices[0].PRState = "CLOSED"
- state.ActiveIndex = 1
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- evaluation, err := EvaluateInsight(repo, view.Capture.ID)
- if err != nil || evaluation.State != InsightNeedsEvidence {
- t.Fatalf("closed PR should need evidence: %v %+v", err, evaluation)
- }
- state.Slices[0].PRState = "OPEN"
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- reconcileInsightsForFeature(repo, feature)
- view, err = ShowInsight(repo, view.Capture.ID)
- if err != nil || view.Evaluation.State != InsightReadyToComplete || view.Disposition != nil {
- t.Fatalf("published terminal should be ready but never complete automatically: %v %+v", err, view)
- }
- foundEvaluation := false
- for _, event := range view.Events {
- foundEvaluation = foundEvaluation || event.Type == "evaluated"
- }
- if !foundEvaluation {
- t.Fatal("PR reconciliation did not record an evaluation event")
- }
- directory, err := insightCaptureDir(ctx, view.Capture.ID)
- if err != nil {
- t.Fatal(err)
- }
- before, err := os.ReadFile(filepath.Join(directory, "events.jsonl"))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := InsightFrontier(repo); err != nil {
- t.Fatal(err)
- }
- after, err := os.ReadFile(filepath.Join(directory, "events.jsonl"))
- if err != nil || string(before) != string(after) {
- t.Fatalf("read-only frontier changed insight events: %v", err)
- }
-}
-
-func TestInsightCaptureRejectsSymlinkEscape(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-symlink.git")
- ctx := configureInsights(t, repo, TerminalPublished)
- root, err := ctx.InsightDir()
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(root, 0o700); err != nil {
- t.Fatal(err)
- }
- id := "ins-0123456789abcdef01234567"
- if err := os.Symlink(t.TempDir(), filepath.Join(root, id)); err != nil {
- t.Fatal(err)
- }
- if _, err := ShowInsight(repo, id); err == nil || !strings.Contains(strings.ToLower(err.Error()), "symlink") {
- t.Fatalf("symlinked capture escaped repository storage checks: %v", err)
- }
-
- escapeRepo := detachedTestRepo(t, "https://example.invalid/insight-root-symlink.git")
- configureInsights(t, escapeRepo, TerminalPublished)
- if err := os.MkdirAll(filepath.Join(escapeRepo, "docs"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(t.TempDir(), filepath.Join(escapeRepo, "docs", "insights")); err != nil {
- t.Fatal(err)
- }
- input := syntheticInsightDraft(t, "A root symlink must not receive this insight.", "Safety")
- check, err := CheckInsightCapture(escapeRepo, input)
- if err == nil {
- _, err = SaveInsightCapture(escapeRepo, input, check.PreviewNonce, check.PreviewFingerprint)
- }
- if err == nil || !strings.Contains(strings.ToLower(err.Error()), "symlink") {
- t.Fatalf("repository inbox symlink escape was not rejected: %v", err)
- }
-}
-
-// Failure-state conformance: the machine capture and human projection are one
-// immutable object. Tampering with either makes the capture unreadable instead
-// of silently presenting a different PR artifact.
-func TestInsightHumanProjectionIsFingerprintBound(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-projection.git")
- ctx := configureInsights(t, repo, TerminalPublished)
- view := saveSyntheticInsight(t, repo, syntheticInsightDraft(t, "Show this exact source in review.", "Review intake"))
- directory, err := insightCaptureDir(ctx, view.Capture.ID)
- if err != nil {
- t.Fatal(err)
- }
- markdownPath := filepath.Join(directory, "insight.md")
- markdown, err := os.ReadFile(markdownPath)
- if err != nil || !strings.Contains(string(markdown), "Show this exact source in review.") {
- t.Fatalf("human projection omitted the exact source: %v", err)
- }
- if err := os.WriteFile(markdownPath, append(markdown, []byte("\nchanged outside Boatstack\n")...), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := ShowInsight(repo, view.Capture.ID); err == nil || !strings.Contains(err.Error(), "projection") {
- t.Fatalf("modified human projection was accepted: %v", err)
- }
-}
-
-// Bypass conformance: only the insight transition may edit tracked insight
-// artifacts. Read and Git staging remain available for review and publication.
-func TestInsightArtifactGuardBlocksRawWritesButAllowsReview(t *testing.T) {
- repo := detachedTestRepo(t, "https://example.invalid/insight-guard.git")
- configureInsights(t, repo, TerminalPublished)
- path := "docs/insights/ins-example/capture.json"
- for _, command := range []string{
- "printf bad > " + path,
- "rm " + path,
- "sed -i.bak s/a/b/ " + path,
- } {
- findings := ClassifyCommand(repo, command)
- if len(findings) == 0 || findings[0].Source != "insight-state" {
- t.Fatalf("raw insight mutation escaped the guard: %q %+v", command, findings)
- }
- }
- if findings := ClassifyCommand(repo, "git add "+path); len(findings) != 0 {
- t.Fatalf("Git staging of a reviewed insight diff was denied: %+v", findings)
- }
- if findings := ClassifyCommand(repo, "git diff -- "+path); len(findings) != 0 {
- t.Fatalf("review of an insight diff was denied: %+v", findings)
- }
- findings := ClassifyTool(repo, "write_file", map[string]any{"file_path": path, "content": "bad"})
- protected := false
- for _, finding := range findings {
- protected = protected || finding.Source == "insight-state"
- }
- if !protected {
- t.Fatalf("direct file tool escaped insight ownership: %+v", findings)
- }
-}
-
-func TestInsightConfigValidation(t *testing.T) {
- config := testConfig()
- config.Insights = &InsightPolicy{Enabled: true, CaptureMode: "automatic"}
- if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "capture_mode") {
- t.Fatalf("invalid automatic capture was accepted: %v", err)
- }
- config.Insights = nil
- if err := ValidateConfig(config); err != nil {
- t.Fatalf("absent insight configuration changed compatibility: %v", err)
- }
-}
diff --git a/boatstack/installation_repair.go b/boatstack/installation_repair.go
deleted file mode 100644
index 60119f8..0000000
--- a/boatstack/installation_repair.go
+++ /dev/null
@@ -1,527 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strings"
- "time"
-)
-
-const (
- RepairCurrent = "CURRENT"
- RepairOwnedStale = "OWNED_STALE"
- RepairOwnedDrifted = "OWNED_DRIFTED"
- RepairUserOwned = "USER_OWNED"
- RepairAmbiguous = "AMBIGUOUS"
- RepairUnsafe = "UNSAFE"
-)
-
-type InstallationRepairItem struct {
- Path string `json:"path"`
- Host string `json:"host,omitempty"`
- Event string `json:"event,omitempty"`
- Classification string `json:"classification"`
- Reason string `json:"reason"`
- CurrentSHA256 string `json:"current_sha256,omitempty"`
-}
-
-func currentFileHash(path string) string {
- hash, err := SHA256File(path)
- if err != nil {
- return ""
- }
- return hash
-}
-
-func unsafeRepairPath(repo, path string) bool {
- if err := rejectSymlinkComponents(repo, path); err != nil {
- return true
- }
- info, err := os.Lstat(path)
- return err == nil && info.Mode()&os.ModeSymlink != 0
-}
-
-type InstallationRepairResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- InstalledVersion string `json:"installed_version,omitempty"`
- TargetVersion string `json:"target_version"`
- Direction string `json:"direction"`
- HeadBranch string `json:"head_branch"`
- StartingHeadCommit string `json:"starting_head_commit"`
- Items []InstallationRepairItem `json:"items"`
- PreservedIntegrations map[string]IntegrationState `json:"preserved_integrations,omitempty"`
- PackageFingerprint string `json:"package_fingerprint"`
- BackupPath string `json:"backup_path,omitempty"`
- Blockers []string `json:"blockers,omitempty"`
- NextOperation string `json:"next_operation"`
-}
-
-func installedVersion(repo string) (string, error) {
- var failures []string
- type candidate struct {
- label string
- value []byte
- err error
- }
- localPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- localValue, localErr := os.ReadFile(localPath)
- committedValue, committedErr := exec.Command("git", "-C", repo, "show", "HEAD:.product-loop/generated.lock.json").Output()
- for _, candidate := range []candidate{
- {label: "install.lock.json", value: localValue, err: localErr},
- {label: "committed generated.lock.json", value: committedValue, err: committedErr},
- } {
- if candidate.err != nil {
- continue
- }
- var identity struct {
- BoatstackVersion string `json:"boatstack_version"`
- SourceCommit string `json:"source_commit"`
- Runtime struct {
- SourceCommit string `json:"source_commit"`
- } `json:"runtime"`
- }
- if err := json.Unmarshal(candidate.value, &identity); err != nil {
- failures = append(failures, candidate.label+" is malformed")
- continue
- }
- if strings.TrimSpace(identity.BoatstackVersion) == "" {
- failures = append(failures, candidate.label+" has no version")
- continue
- }
- sourceCommit := strings.TrimSpace(identity.SourceCommit)
- if sourceCommit == "" {
- sourceCommit = strings.TrimSpace(identity.Runtime.SourceCommit)
- }
- if sourceCommit == "" || strings.EqualFold(sourceCommit, "unknown") {
- failures = append(failures, candidate.label+" has invalid source commit")
- continue
- }
- version, err := normalizedVersion(identity.BoatstackVersion)
- if err != nil {
- failures = append(failures, candidate.label+" has invalid version")
- continue
- }
- return version, nil
- }
- detail := strings.Join(failures, "; ")
- if detail != "" {
- detail = ": " + detail
- }
- return "", fmt.Errorf("installed Boatstack version cannot be established from owned provenance%s", detail)
-}
-
-func updateDirection(installed, target string) (string, error) {
- comparison, err := compareVersions(installed, target)
- if err != nil {
- return "", err
- }
- switch {
- case comparison < 0:
- return "UPGRADE", nil
- case comparison > 0:
- return "DOWNGRADE", nil
- default:
- return "SAME_VERSION", nil
- }
-}
-
-func sameJSON(left, right any) bool {
- a, errA := json.Marshal(left)
- b, errB := json.Marshal(right)
- return errA == nil && errB == nil && string(a) == string(b)
-}
-
-func decodeInstalledHookEvents(path string, value []byte, host string) (map[string]any, error) {
- var fragment struct {
- SchemaVersion int `json:"schema_version"`
- Host string `json:"host"`
- Events map[string]any `json:"events"`
- }
- if err := DecodeJSON("load installed host hook fragment", path, value, &fragment); err != nil {
- return nil, err
- }
- if fragment.SchemaVersion != 1 || fragment.Host != host || len(fragment.Events) == 0 {
- return nil, fmt.Errorf("invalid installed %s hook fragment identity", host)
- }
- return fragment.Events, nil
-}
-
-func loadInstalledHookEvents(repo, host string) (map[string]any, error) {
- path := filepath.Join(repo, ".product-loop", "hooks", host+".fragment.json")
- value, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- return decodeInstalledHookEvents(path, value, host)
-}
-
-func loadCommittedInstalledHookEvents(repo, host string) (map[string]any, error) {
- relative := filepath.ToSlash(filepath.Join(".product-loop", "hooks", host+".fragment.json"))
- expected, ok := previousFiles(repo)[relative]
- if !ok {
- return nil, fmt.Errorf("installed %s hook fragment has no generated provenance", host)
- }
- value, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output()
- if err != nil || SHA256Bytes(value) != expected {
- return nil, fmt.Errorf("committed %s hook fragment does not match generated provenance", host)
- }
- return decodeInstalledHookEvents("HEAD:"+relative, value, host)
-}
-
-func classifyHookState(repo, host string) []InstallationRepairItem {
- path := hostHookConfigPath(repo, host)
- currentHash := currentFileHash(path)
- relative, _ := filepath.Rel(repo, path)
- relative = filepath.ToSlash(relative)
- if unsafeRepairPath(repo, path) {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host configuration uses a symlinked path"}}
- }
- if _, err := os.Lstat(path); os.IsNotExist(err) {
- _, fragmentErr := loadInstalledHookEvents(repo, host)
- if fragmentErr != nil {
- _, fragmentErr = loadCommittedInstalledHookEvents(repo, host)
- }
- if fragmentErr == nil {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairOwnedStale, Reason: "missing installed host configuration can be reconstructed from its ownership fragment"}}
- }
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairAmbiguous, Reason: "host configuration and its ownership fragment are both missing"}}
- } else if err != nil {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host configuration path cannot be inspected"}}
- }
- config, configErr := loadHookConfig(path)
- if configErr != nil {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host configuration is not valid JSON", CurrentSHA256: currentHash}}
- }
- hooks, ok := config["hooks"].(map[string]any)
- if !ok {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host hooks field is not an object", CurrentSHA256: currentHash}}
- }
- installed, installedErr := loadInstalledHookEvents(repo, host)
- if installedErr != nil {
- installed, installedErr = loadCommittedInstalledHookEvents(repo, host)
- }
- if installedErr != nil {
- exactTarget := true
- for _, event := range hookEvents(host) {
- entries, entriesOK := hooks[event].([]any)
- matches := 0
- if entriesOK {
- for _, entry := range entries {
- if containsBoatstackHook(entry) {
- matches++
- exactTarget = exactTarget && sameJSON(entry, desiredHostHookForEvent(host, event))
- }
- }
- }
- exactTarget = exactTarget && entriesOK && matches == 1
- }
- for event, raw := range hooks {
- if !contains(hookEvents(host), event) && containsBoatstackHook(raw) {
- exactTarget = false
- }
- }
- if exactTarget {
- fragmentPath := filepath.ToSlash(filepath.Join(".product-loop", "hooks", host+".fragment.json"))
- return []InstallationRepairItem{{Path: fragmentPath, Host: host, Classification: RepairOwnedDrifted, Reason: "missing ownership fragment can be reconstructed from exact target hooks"}}
- }
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairAmbiguous, Reason: "installed hook ownership fragment is missing and current hooks do not exactly match the target", CurrentSHA256: currentHash}}
- }
- desiredEvents := map[string]bool{}
- for _, event := range hookEvents(host) {
- desiredEvents[event] = true
- }
- items := []InstallationRepairItem{}
- for event, raw := range hooks {
- entries, ok := raw.([]any)
- if !ok {
- if containsBoatstackHook(raw) {
- items = append(items, InstallationRepairItem{Path: relative, Host: host, Event: event, Classification: RepairUnsafe, Reason: "Boatstack hook event is not a list", CurrentSHA256: currentHash})
- }
- continue
- }
- ownedEntries := []any{}
- for _, entry := range entries {
- if containsBoatstackHook(entry) {
- ownedEntries = append(ownedEntries, entry)
- }
- }
- if len(ownedEntries) == 0 {
- continue
- }
- classification := RepairOwnedDrifted
- reason := "Boatstack-marked hook differs from its installed ownership fragment"
- allInstalled := installed[event] != nil
- allDesired := desiredEvents[event]
- for _, entry := range ownedEntries {
- allInstalled = allInstalled && sameJSON(entry, installed[event])
- allDesired = allDesired && sameJSON(entry, desiredHostHookForEvent(host, event))
- }
- switch {
- case allDesired && len(ownedEntries) == 1:
- classification, reason = RepairCurrent, "hook matches the target release"
- case allInstalled:
- classification, reason = RepairOwnedStale, "hook exactly matches installed provenance and can be migrated"
- case len(ownedEntries) > 1:
- classification, reason = RepairAmbiguous, "multiple non-identical Boatstack-marked hooks require review"
- }
- items = append(items, InstallationRepairItem{Path: relative, Host: host, Event: event, Classification: classification, Reason: reason, CurrentSHA256: currentHash})
- }
- return items
-}
-
-func classifyExecutionInterceptor(repo, host string) []InstallationRepairItem {
- relative := executionInterceptorPath(host)
- if relative == "" {
- return nil
- }
- path := filepath.Join(repo, relative)
- if unsafeRepairPath(repo, path) {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "execution interceptor uses a symlinked path"}}
- }
- value, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return nil
- }
- if err != nil {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "execution interceptor file cannot be read"}}
- }
- text := string(value)
- starts := strings.Count(text, interceptorHeader)
- ends := strings.Count(text, interceptorFooter)
- if starts == 0 && ends == 0 {
- return nil
- }
- if starts != 1 || ends != 1 || strings.Index(text, interceptorFooter) < strings.Index(text, interceptorHeader) {
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairAmbiguous, Reason: "execution interceptor markers are incomplete or duplicated", CurrentSHA256: SHA256Bytes(value)}}
- }
- expected := interceptorHeader + strings.TrimSpace(ExecutionBoundaryDX) + interceptorFooter
- start := strings.Index(text, interceptorHeader)
- end := strings.Index(text, interceptorFooter) + len(interceptorFooter)
- classification, reason := RepairOwnedStale, "marker-bounded Boatstack interceptor can be migrated"
- if text[start:end] == expected {
- classification, reason = RepairCurrent, "marker-bounded Boatstack interceptor matches the target release"
- }
- return []InstallationRepairItem{{Path: relative, Host: host, Classification: classification, Reason: reason, CurrentSHA256: SHA256Bytes(value)}}
-}
-
-func ClassifyInstallationRepair(repoPath string, adapters []string, allowDowngrade bool) (InstallationRepairResult, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return InstallationRepairResult{}, err
- }
- target, err := normalizedVersion(Version)
- if err != nil {
- return InstallationRepairResult{}, err
- }
- result := InstallationRepairResult{
- SchemaVersion: 1, VerificationStatus: "VERIFIED", TargetVersion: target, NextOperation: "update",
- HeadBranch: strings.TrimSpace(gitOutput(repo, "branch", "--show-current")), StartingHeadCommit: strings.TrimSpace(gitOutput(repo, "rev-parse", "HEAD")),
- }
- installed, versionErr := installedVersion(repo)
- if versionErr != nil {
- result.Direction = "UNKNOWN"
- result.Items = append(result.Items, InstallationRepairItem{Path: ".product-loop/bin/install.lock.json", Classification: RepairAmbiguous, Reason: versionErr.Error()})
- } else {
- result.InstalledVersion = installed
- result.Direction, err = updateDirection(installed, target)
- if err != nil {
- return InstallationRepairResult{}, err
- }
- if result.Direction == "DOWNGRADE" && !allowDowngrade {
- result.Blockers = append(result.Blockers, "downgrades require both --repair and --allow-downgrade")
- }
- }
- installLockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- if unsafeRepairPath(repo, installLockPath) {
- result.Items = append(result.Items, InstallationRepairItem{Path: ".product-loop/bin/install.lock.json", Classification: RepairUnsafe, Reason: "install provenance uses a symlinked path"})
- } else if provenanceErr := CheckExistingInstallProvenance(repo); provenanceErr != nil {
- classification := RepairOwnedDrifted
- if strings.Contains(strings.ToLower(provenanceErr.Error()), "unsafe") || strings.Contains(strings.ToLower(provenanceErr.Error()), "symlink") {
- classification = RepairUnsafe
- }
- if result.InstalledVersion == "" {
- classification = RepairAmbiguous
- }
- result.Items = append(result.Items, InstallationRepairItem{
- Path: ".product-loop/bin/install.lock.json", Classification: classification,
- Reason: "local helper provenance needs reconstruction: " + provenanceErr.Error(),
- CurrentSHA256: currentFileHash(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")),
- })
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if contains(adapters, host) {
- result.Items = append(result.Items, classifyHookState(repo, host)...)
- result.Items = append(result.Items, classifyExecutionInterceptor(repo, host)...)
- }
- }
- previous := previousFiles(repo)
- if len(previous) == 0 {
- classification := RepairOwnedDrifted
- if result.InstalledVersion == "" {
- classification = RepairAmbiguous
- }
- result.Items = append(result.Items, InstallationRepairItem{Path: ".product-loop/generated.lock.json", Classification: classification, Reason: "generated ownership provenance needs reconstruction", CurrentSHA256: currentFileHash(filepath.Join(repo, ".product-loop", "generated.lock.json"))})
- } else {
- for relative, expected := range previous {
- absolute := filepath.Join(repo, filepath.FromSlash(relative))
- if unsafeRepairPath(repo, absolute) {
- result.Items = append(result.Items, InstallationRepairItem{Path: filepath.ToSlash(relative), Classification: RepairUnsafe, Reason: "generated ownership path uses a symlink"})
- continue
- }
- value, readErr := os.ReadFile(absolute)
- if readErr == nil && SHA256Bytes(value) == expected {
- continue
- }
- classification := RepairOwnedStale
- reason := "owned generated file is missing and can be reconstructed"
- if readErr == nil {
- classification, reason = RepairOwnedDrifted, "installer-owned generated file differs from installed provenance"
- }
- result.Items = append(result.Items, InstallationRepairItem{Path: filepath.ToSlash(relative), Classification: classification, Reason: reason, CurrentSHA256: currentFileHash(filepath.Join(repo, filepath.FromSlash(relative)))})
- }
- }
- config, _, configErr := LoadConfig(WorkspaceFor(repo).SourceConfigPath())
- if configErr == nil {
- states, stateErr := readInstalledIntegrations(repo, config)
- if stateErr == nil {
- result.PreservedIntegrations = states
- } else if len(config.Integrations) > 0 {
- result.PreservedIntegrations = config.Integrations
- }
- }
- sort.Slice(result.Items, func(i, j int) bool {
- if result.Items[i].Path == result.Items[j].Path {
- return result.Items[i].Event < result.Items[j].Event
- }
- return result.Items[i].Path < result.Items[j].Path
- })
- needsRepair := false
- for _, item := range result.Items {
- switch item.Classification {
- case RepairOwnedDrifted:
- needsRepair = true
- case RepairUserOwned, RepairAmbiguous, RepairUnsafe:
- result.Blockers = append(result.Blockers, item.Path+": "+item.Reason)
- }
- }
- if len(result.Blockers) > 0 {
- result.VerificationStatus = "BLOCKED"
- result.NextOperation = "resolve_blocker"
- } else if needsRepair {
- result.VerificationStatus = "REPAIR_AVAILABLE"
- result.NextOperation = "update_with_repair"
- }
- fingerprintValue, _ := json.Marshal(struct {
- Installed string `json:"installed"`
- Target string `json:"target"`
- Direction string `json:"direction"`
- Branch string `json:"branch"`
- Head string `json:"head"`
- Items []InstallationRepairItem `json:"items"`
- }{result.InstalledVersion, result.TargetVersion, result.Direction, result.HeadBranch, result.StartingHeadCommit, result.Items})
- result.PackageFingerprint = SHA256Bytes(fingerprintValue)
- return result, nil
-}
-
-func repairOwnedPaths(result InstallationRepairResult) map[string]bool {
- paths := map[string]bool{}
- for _, item := range result.Items {
- if item.Classification == RepairOwnedStale || item.Classification == RepairOwnedDrifted {
- paths[item.Path] = true
- }
- }
- return paths
-}
-
-func writeInstallationRepairBackup(repo string, result InstallationRepairResult) (string, error) {
- // Repair backups capture this worktree's pre-repair working-tree bytes. Two
- // worktrees repairing to the same version share a package fingerprint but hold
- // different pre-repair content, so a clone-shared directory would let one
- // clobber the other's restore point. Key the backup and its receipt by the
- // per-worktree Git directory instead, and keep them co-located.
- gitDir, err := worktreeGitDir(repo)
- if err != nil {
- return "", err
- }
- directory := filepath.Join(gitDir, "boatstack", "repair-backups", result.PackageFingerprint)
- if err := rejectSymlinkComponents(gitDir, directory); err != nil {
- return "", err
- }
- if err := os.MkdirAll(directory, 0o700); err != nil {
- return "", err
- }
- for relative := range repairOwnedPaths(result) {
- source := filepath.Join(repo, filepath.FromSlash(relative))
- value, readErr := os.ReadFile(source)
- if os.IsNotExist(readErr) {
- continue
- }
- if readErr != nil {
- return "", readErr
- }
- target := filepath.Join(directory, filepath.FromSlash(relative))
- if err := atomicWriteMode(target, value, 0o600); err != nil {
- return "", err
- }
- }
- manifest, err := MarshalJSON(map[string]any{
- "schema_version": 1, "created_at": time.Now().UTC().Format(time.RFC3339),
- "package_fingerprint": result.PackageFingerprint, "items": result.Items,
- })
- if err != nil {
- return "", err
- }
- if err := atomicWriteMode(filepath.Join(directory, "repair.json"), manifest, 0o600); err != nil {
- return "", err
- }
- receipt := result
- receipt.BackupPath = filepath.ToSlash(filepath.Join("boatstack", "repair-backups", result.PackageFingerprint))
- receiptValue, err := MarshalJSON(receipt)
- if err != nil {
- return "", err
- }
- version, err := safeCacheSegment(result.TargetVersion, "repair target version")
- if err != nil {
- return "", err
- }
- receiptPath := filepath.Join(gitDir, "boatstack", "updates", version, "repair.json")
- if err := atomicWriteMode(receiptPath, receiptValue, 0o600); err != nil {
- return "", err
- }
- return directory, nil
-}
-
-func loadInstallationRepairReceipt(repo, version string) (*InstallationRepairResult, error) {
- gitDir, err := worktreeGitDir(repo)
- if err != nil {
- return nil, err
- }
- segment, err := safeCacheSegment(version, "repair target version")
- if err != nil {
- return nil, err
- }
- path := filepath.Join(gitDir, "boatstack", "updates", segment, "repair.json")
- value, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- var result InstallationRepairResult
- if err := DecodeJSON("load installation repair receipt", path, value, &result); err != nil {
- return nil, err
- }
- if result.SchemaVersion != 1 || result.TargetVersion != version || result.PackageFingerprint == "" || result.BackupPath == "" {
- return nil, fmt.Errorf("installation repair receipt identity is invalid")
- }
- if result.HeadBranch != strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) || result.StartingHeadCommit != strings.TrimSpace(gitOutput(repo, "rev-parse", "HEAD")) {
- return nil, nil
- }
- return &result, nil
-}
diff --git a/boatstack/installation_repair_test.go b/boatstack/installation_repair_test.go
deleted file mode 100644
index 819b18f..0000000
--- a/boatstack/installation_repair_test.go
+++ /dev/null
@@ -1,507 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "encoding/json"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
- "time"
-)
-
-func TestOwnedRetiredHookEventMigratesWithoutRepair(t *testing.T) {
- retired := desiredHostHookForEvent("cursor", "beforeShellExecution")
- installed := map[string]any{"retiredCursorEvent": retired}
- config := map[string]any{
- "version": float64(1),
- "hooks": map[string]any{
- "retiredCursorEvent": []any{retired, map[string]any{"command": "./keep-user-hook.sh"}},
- },
- }
- if err := mergeHostHookWithOwnership(config, "cursor", installed, false); err != nil {
- t.Fatal(err)
- }
- hooks := config["hooks"].(map[string]any)
- retiredEntries := hooks["retiredCursorEvent"].([]any)
- if len(retiredEntries) != 1 || containsBoatstackHook(retiredEntries) {
- t.Fatalf("retired owned hook was not removed without disturbing user hook: %#v", retiredEntries)
- }
- for _, event := range hookEvents("cursor") {
- if !containsBoatstackHook(hooks[event]) {
- t.Fatalf("target event %s was not installed", event)
- }
- }
-}
-
-func TestExactInstalledHookDuplicatesAreDeduplicated(t *testing.T) {
- entry := desiredHostHookForEvent("cursor", "beforeShellExecution")
- config := map[string]any{"version": float64(1), "hooks": map[string]any{"beforeShellExecution": []any{entry, entry}}}
- if err := mergeHostHookWithOwnership(config, "cursor", map[string]any{"beforeShellExecution": entry}, false); err != nil {
- t.Fatal(err)
- }
- entries := config["hooks"].(map[string]any)["beforeShellExecution"].([]any)
- if len(entries) != 1 {
- t.Fatalf("verified duplicate hooks were not deduplicated: %#v", entries)
- }
-}
-
-func TestDriftedOwnedHookRequiresRepairAndFingerprintIsStable(t *testing.T) {
- installedEntry := desiredHostHookForEvent("cursor", "beforeShellExecution")
- drifted := desiredHostHookForEvent("cursor", "beforeShellExecution")
- drifted["timeout"] = float64(99)
- config := map[string]any{"version": float64(1), "hooks": map[string]any{"beforeShellExecution": []any{drifted}}}
- if err := mergeHostHookWithOwnership(config, "cursor", map[string]any{"beforeShellExecution": installedEntry}, false); err == nil || !strings.Contains(err.Error(), "--repair") {
- t.Fatalf("drifted hook did not require repair: %v", err)
- }
- if err := mergeHostHookWithOwnership(config, "cursor", map[string]any{"beforeShellExecution": installedEntry}, true); err != nil {
- t.Fatal(err)
- }
- entries := config["hooks"].(map[string]any)["beforeShellExecution"].([]any)
- if len(entries) != 1 || !sameJSON(entries[0], desiredHostHookForEvent("cursor", "beforeShellExecution")) {
- t.Fatalf("repair did not replace the exact owned entry: %#v", entries)
- }
-}
-
-func driftCursorHook(t *testing.T, repo string) []byte {
- t.Helper()
- path := filepath.Join(repo, ".cursor", "hooks.json")
- before, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- var config map[string]any
- if err := json.Unmarshal(before, &config); err != nil {
- t.Fatal(err)
- }
- entries := config["hooks"].(map[string]any)["beforeShellExecution"].([]any)
- entries[0].(map[string]any)["timeout"] = float64(99)
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, value, 0o644); err != nil {
- t.Fatal(err)
- }
- return value
-}
-
-func TestUpdateRepairPromptAndNonInteractiveRecovery(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- drifted := driftCursorHook(t, repo)
-
- var noninteractive bytes.Buffer
- err := RunInit(InitOptions{Repo: repo, Update: true, Yes: true, Input: strings.NewReader(""), Output: &noninteractive})
- retryMarker := "BOATSTACK_REPAIR=1"
- installerMarker := "v0.5.0/install.sh"
- if runtime.GOOS == "windows" {
- retryMarker = "BOATSTACK_REPAIR"
- installerMarker = "v0.5.0/install.ps1"
- }
- if err == nil || !strings.Contains(err.Error(), retryMarker) || !strings.Contains(err.Error(), installerMarker) {
- t.Fatalf("noninteractive update did not return one repair action: %v\n%s", err, noninteractive.String())
- }
- if !strings.Contains(noninteractive.String(), "Repair package:") {
- t.Fatalf("repair fingerprint was not displayed: %s", noninteractive.String())
- }
- current, _ := os.ReadFile(filepath.Join(repo, ".cursor", "hooks.json"))
- if !bytes.Equal(current, drifted) {
- t.Fatal("failed update changed the drifted file")
- }
-
- var declined bytes.Buffer
- err = RunInit(InitOptions{Repo: repo, Update: true, Input: strings.NewReader("\n"), Output: &declined})
- if err == nil || !strings.Contains(declined.String(), "Repair Boatstack-owned state and continue the update? [y/N]") {
- t.Fatalf("interactive update did not default to a visible repair refusal: %v\n%s", err, declined.String())
- }
-}
-
-func TestRepairCompletesAndWritesGitCommonBackup(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- driftCursorHook(t, repo)
- var output bytes.Buffer
- if err := RunInit(InitOptions{Repo: repo, Update: true, Repair: true, Yes: true, Input: strings.NewReader(""), Output: &output}); err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(output.String(), "Repair package") || !strings.Contains(output.String(), "repair-backups") {
- t.Fatalf("repair backup was not reported: %s", output.String())
- }
- if err := CheckHostHooks(repo, []string{"cursor"}); err != nil {
- t.Fatalf("repaired hooks do not match target: %v", err)
- }
-}
-
-func TestInteractiveRepairAuthorityIsBoundBeforeOperationLease(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- driftCursorHook(t, repo)
- if err := RunUpdate(InitOptions{Repo: repo, Input: strings.NewReader("y\ny\n"), Output: &bytes.Buffer{}}); err != nil {
- t.Fatal(err)
- }
- receipts, err := operationReceipts(repo)
- if err != nil {
- t.Fatal(err)
- }
- want := SHA256Bytes([]byte(Version + "\x00" + SourceCommit + "\x00" + ChecksumsSHA256 + "\x00repair=true\x00allow-downgrade=false"))
- found := false
- for _, receipt := range receipts {
- if receipt.Kind == "install-update" && receipt.PackageFingerprint == want && receipt.State == OperationSucceeded {
- found = true
- }
- }
- if !found {
- t.Fatalf("interactive repair did not create a repair-bound terminal operation: %#v", receipts)
- }
-}
-
-func TestRepairRejectsMixedUserAndOwnedHookEdits(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- driftCursorHook(t, repo)
- path := filepath.Join(repo, ".cursor", "hooks.json")
- value, _ := os.ReadFile(path)
- var config map[string]any
- if err := json.Unmarshal(value, &config); err != nil {
- t.Fatal(err)
- }
- config["new_user_setting"] = "do-not-package"
- value, _ = MarshalJSON(config)
- if err := os.WriteFile(path, value, 0o644); err != nil {
- t.Fatal(err)
- }
- project, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- result, err := ClassifyInstallationRepair(repo, project.Adapters, false)
- if err != nil {
- t.Fatal(err)
- }
- if err := ValidateUpdateWorkspaceForRepair(repo, project, result, true); err == nil || !strings.Contains(err.Error(), "non-repairable changes") {
- t.Fatalf("mixed user and owned edits entered repair: %v", err)
- }
-}
-
-func TestRepairNeverOverwritesPartialInterceptorBoundary(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- path := filepath.Join(repo, "CLAUDE.md")
- value, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte(strings.Replace(string(value), interceptorFooter, "", 1)), 0o644); err != nil {
- t.Fatal(err)
- }
- project, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- result, err := ClassifyInstallationRepair(repo, project.Adapters, false)
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(strings.Join(result.Blockers, " "), "markers") {
- t.Fatalf("partial interceptor was not blocked: %#v", result)
- }
-}
-
-func TestRepairRejectsSymlinkedOwnedPath(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink fixture requires Unix permissions")
- }
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- path := filepath.Join(repo, ".cursor", "hooks.json")
- target := filepath.Join(t.TempDir(), "outside.json")
- value, _ := os.ReadFile(path)
- if err := os.WriteFile(target, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.Remove(path); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(target, path); err != nil {
- t.Fatal(err)
- }
- project, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- result, err := ClassifyInstallationRepair(repo, project.Adapters, false)
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(strings.Join(result.Blockers, " "), "symlink") {
- t.Fatalf("symlinked owned path was not blocked: %#v", result)
- }
-}
-
-func TestDowngradeRequiresRepairAndSeparateAuthority(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.6.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- result, err := ClassifyInstallationRepair(repo, []string{"cursor"}, false)
- if err != nil {
- t.Fatal(err)
- }
- if result.Direction != "DOWNGRADE" || result.VerificationStatus != "BLOCKED" || !strings.Contains(strings.Join(result.Blockers, " "), "--allow-downgrade") {
- t.Fatalf("downgrade was not independently blocked: %#v", result)
- }
- result, err = ClassifyInstallationRepair(repo, []string{"cursor"}, true)
- if err != nil || result.Direction != "DOWNGRADE" || result.VerificationStatus == "BLOCKED" {
- t.Fatalf("explicit downgrade projection was not available: %#v %v", result, err)
- }
-}
-
-func TestRepairPreservesIntegrationFallbackWhenInstallLockIsMissing(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- if err := os.Remove(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")); err != nil {
- t.Fatal(err)
- }
- config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- result, err := ClassifyInstallationRepair(repo, config.Adapters, false)
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "REPAIR_AVAILABLE" || len(result.PreservedIntegrations) == 0 {
- t.Fatalf("missing install lock was not recoverable from config: %#v", result)
- }
-}
-
-func TestRepairFallsBackToCommittedPinWhenLocalProvenanceIsInvalid(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- for name, value := range map[string][]byte{
- "development identity": []byte(`{"boatstack_version":"dev","source_commit":"unknown"}`),
- "unknown source": []byte(`{"boatstack_version":"v0.4.0","source_commit":"unknown"}`),
- "malformed identity": []byte("{\n"),
- } {
- t.Run(name, func(t *testing.T) {
- repo, _ := updateInstalledRepo(t)
- lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- if err := os.WriteFile(lockPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- // Recovery authority comes from the committed pin, not a potentially
- // drifted generated file in the worktree.
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "generated.lock.json"), []byte("{\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- installed, err := installedVersion(repo)
- if err != nil || installed != "v0.4.0" {
- t.Fatalf("committed pin did not recover version identity: version=%q err=%v", installed, err)
- }
- config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- result, err := ClassifyInstallationRepair(repo, config.Adapters, false)
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "REPAIR_AVAILABLE" || result.InstalledVersion != "v0.4.0" {
- t.Fatalf("invalid local provenance was not safely repairable: %#v", result)
- }
- })
- }
-}
-
-func TestDetachedUpdateRepairsInvalidLocalProvenanceEndToEnd(t *testing.T) {
- now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- t.Setenv(stateRootEnv, t.TempDir())
- invalidateWorkspaceCache()
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), []byte(`{"boatstack_version":"dev","source_commit":"unknown"}`), 0o644); err != nil {
- t.Fatal(err)
- }
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- if err := RunUpdate(InitOptions{Repo: repo, Repair: true, Yes: true, Input: strings.NewReader(""), Output: &bytes.Buffer{}}); err != nil {
- t.Fatal(err)
- }
- receipts, err := operationReceipts(repo)
- if err != nil {
- t.Fatal(err)
- }
- found := false
- for _, receipt := range receipts {
- if receipt.Kind == "install-update" && receipt.State == OperationSucceeded {
- found = true
- }
- }
- if !found {
- t.Fatalf("detached repair did not persist a successful update operation: %#v", receipts)
- }
- directory, err := WorkspaceFor(repo).OperationDir()
- if err != nil {
- t.Fatal(err)
- }
- if strings.HasPrefix(directory, repo+string(filepath.Separator)) {
- t.Fatalf("detached update receipt entered the repository: %s", directory)
- }
-}
-
-func TestDetachedUpdateReconcilesSucceededReceiptAfterCommittedPinIsRestored(t *testing.T) {
- now := time.Date(2026, 8, 1, 13, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- t.Setenv(stateRootEnv, t.TempDir())
- invalidateWorkspaceCache()
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- options := InitOptions{Repo: repo, Repair: true, Yes: true, Input: strings.NewReader(""), Output: &bytes.Buffer{}}
- if err := RunUpdate(options); err != nil {
- t.Fatal(err)
- }
- receipts, err := operationReceipts(repo)
- if err != nil || len(receipts) == 0 {
- t.Fatalf("first update receipt missing: %#v %v", receipts, err)
- }
- var updateID string
- for _, receipt := range receipts {
- if receipt.Kind == "install-update" && receipt.State == OperationSucceeded {
- updateID = receipt.OperationID
- }
- }
- if updateID == "" {
- t.Fatalf("successful update receipt missing: %#v", receipts)
- }
-
- // Simulate an operator discarding the generated update diff while ignored
- // runtime state and the detached terminal receipt survive.
- runGit(t, repo, "restore", ".")
- if status := gitPorcelain(t, repo); status != "" {
- t.Fatalf("fixture did not restore a clean old-pin worktree: %s", status)
- }
- if err := verifyGeneratedRuntime(repo); err == nil {
- t.Fatal("restored old pin unexpectedly matched the target runtime")
- }
-
- options.Input = strings.NewReader("")
- options.Output = &bytes.Buffer{}
- if err := RunUpdate(options); err != nil {
- t.Fatalf("clean retry did not reconcile the stale terminal receipt: %v", err)
- }
- if err := verifyGeneratedRuntime(repo); err != nil {
- t.Fatalf("clean retry did not regenerate the target pin: %v", err)
- }
- reconciled, err := loadOperation(repo, updateID)
- if err != nil || reconciled.State != OperationSucceeded || reconciled.Attempt != 1 || reconciled.Observation.Status != "SUCCEEDED" {
- t.Fatalf("reconciled update did not finish as a fresh bounded attempt: %#v %v", reconciled, err)
- }
-}
-
-func TestUpdatePostconditionVerifierIsReadOnlyWhenCurrent(t *testing.T) {
- now := time.Date(2026, 8, 1, 13, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- options := InitOptions{Repo: repo, Yes: true, Input: strings.NewReader(""), Output: &bytes.Buffer{}}
- if err := RunUpdate(options); err != nil {
- t.Fatal(err)
- }
- receipts, err := operationReceipts(repo)
- if err != nil {
- t.Fatal(err)
- }
- var before OperationReceipt
- for _, receipt := range receipts {
- if receipt.Kind == "install-update" {
- before = receipt
- }
- }
- configPath := WorkspaceFor(repo).SourceConfigPath()
- config, rawConfig, err := LoadConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- preflight, err := ClassifyInstallationRepair(repo, config.Adapters, false)
- if err != nil {
- t.Fatal(err)
- }
- if err := verifyInstalledUpdatePostcondition(repo, configPath, config, rawConfig, preflight.PreservedIntegrations); err != nil {
- t.Fatalf("current update postcondition did not verify: %v", err)
- }
- after, err := loadOperation(repo, before.OperationID)
- if err != nil || after.Attempt != before.Attempt || after.UpdatedAt != before.UpdatedAt {
- t.Fatalf("current terminal receipt was needlessly reopened: before=%#v after=%#v err=%v", before, after, err)
- }
-}
-
-func TestRepairReconstructsCorruptGeneratedProvenance(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json")
- if err := os.WriteFile(lockPath, []byte("{\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, Update: true, Repair: true, Yes: true, Output: &bytes.Buffer{}}); err != nil {
- t.Fatal(err)
- }
- if len(previousFiles(repo)) == 0 {
- t.Fatal("repair did not reconstruct generated provenance")
- }
-}
-
-func TestRepairReconstructsCorruptHookFragmentFromCommittedProvenance(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- fragmentPath := filepath.Join(repo, ".product-loop", "hooks", "cursor.fragment.json")
- if err := os.WriteFile(fragmentPath, []byte("{\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, Update: true, Repair: true, Yes: true, Output: &bytes.Buffer{}}); err != nil {
- t.Fatal(err)
- }
- if _, err := loadInstalledHookEvents(repo, "cursor"); err != nil {
- t.Fatalf("repair did not reconstruct hook fragment: %v", err)
- }
-}
diff --git a/boatstack/integrations.go b/boatstack/integrations.go
deleted file mode 100644
index 98c34cb..0000000
--- a/boatstack/integrations.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
-)
-
-var (
- lookPath = exec.LookPath
- homeDir = os.UserHomeDir
- runExternal = func(directory, name string, arguments ...string) error {
- command := exec.Command(name, arguments...)
- command.Dir = directory
- command.Stdout = os.Stdout
- command.Stderr = os.Stderr
- command.Stdin = os.Stdin
- return command.Run()
- }
- externalOutput = func(directory, name string, arguments ...string) (string, error) {
- command := exec.Command(name, arguments...)
- command.Dir = directory
- value, err := command.Output()
- return strings.TrimSpace(string(value)), err
- }
-)
-
-func RequestedIntegrations(choice string) (bool, bool, error) {
- switch strings.ToLower(strings.TrimSpace(choice)) {
- case "", "core", "core-only", "none":
- return false, false, nil
- case "gstack":
- return true, false, nil
- case "spec-kit", "speckit":
- return false, true, nil
- case "both":
- return true, true, nil
- default:
- return false, false, fmt.Errorf("integrations must be core, gstack, spec-kit, or both")
- }
-}
-
-func installGStack(adapters []string) IntegrationState {
- state := IntegrationState{Requested: true, Status: "partial", Version: GStackRef}
- for _, prerequisite := range []string{"git", "bun", "bash"} {
- if _, err := lookPath(prerequisite); err != nil {
- state.Detail = fmt.Sprintf("gstack skipped: %s is required by its official installer", prerequisite)
- return state
- }
- }
- if runtime.GOOS == "windows" {
- if _, err := lookPath("node"); err != nil {
- state.Detail = "gstack skipped: native Windows requires Node plus Bun and Git Bash or WSL"
- return state
- }
- }
- home, err := homeDir()
- if err != nil {
- state.Detail = "gstack skipped: cannot resolve the user home directory"
- return state
- }
- installRoot := filepath.Join(home, ".claude", "skills", "gstack")
- _, claudeDetected := lookPath("claude")
- _, codexDetected := lookPath("codex")
- if claudeDetected != nil && codexDetected == nil {
- installRoot = filepath.Join(home, ".codex", "skills", "gstack")
- }
- if info, statErr := os.Stat(installRoot); statErr == nil && info.IsDir() {
- if _, gitErr := os.Stat(filepath.Join(installRoot, ".git")); gitErr != nil {
- state.Detail = "gstack skipped: its target directory exists but is not a Git checkout"
- return state
- }
- if err := runExternal(installRoot, "git", "fetch", "--depth", "1", "origin", GStackRef); err != nil {
- state.Detail = "gstack update failed while fetching the pinned revision"
- return state
- }
- if err := runExternal(installRoot, "git", "checkout", "--detach", GStackRef); err != nil {
- state.Detail = "gstack update failed while checking out the pinned revision"
- return state
- }
- } else {
- if err := os.MkdirAll(filepath.Dir(installRoot), 0o755); err != nil {
- state.Detail = "gstack skipped: cannot create its skill directory"
- return state
- }
- if err := runExternal("", "git", "clone", "--no-checkout", "https://github.com/garrytan/gstack.git", installRoot); err != nil {
- state.Detail = "gstack clone failed"
- return state
- }
- if err := runExternal(installRoot, "git", "fetch", "--depth", "1", "origin", GStackRef); err != nil {
- state.Detail = "gstack clone could not fetch the pinned revision"
- return state
- }
- if err := runExternal(installRoot, "git", "checkout", "--detach", GStackRef); err != nil {
- state.Detail = "gstack clone could not check out the pinned revision"
- return state
- }
- }
-
- hosts := []string{}
- if contains(adapters, "claude") {
- if _, err := lookPath("claude"); err == nil {
- hosts = append(hosts, "claude")
- }
- }
- if contains(adapters, "codex") {
- if _, err := lookPath("codex"); err == nil {
- hosts = append(hosts, "codex")
- }
- }
- if len(hosts) == 0 {
- state.Detail = "gstack source installed, but no officially supported Claude or Codex host was detected; Cursor remains available through Boatstack core"
- return state
- }
- for _, host := range hosts {
- if err := runExternal(installRoot, "bash", "setup", "--host", host, "--prefix"); err != nil {
- state.Detail = fmt.Sprintf("gstack setup failed for %s; Boatstack core remains installed", host)
- return state
- }
- }
- state.Status = "installed"
- state.Detail = "gstack installed with namespaced /gstack-* commands"
- return state
-}
-
-func specKitExecutable() (string, error) {
- if path, err := lookPath("specify"); err == nil {
- return path, nil
- }
- uv, err := lookPath("uv")
- if err != nil {
- return "", fmt.Errorf("uv is required to install the optional Spec Kit integration")
- }
- if err := runExternal("", uv, "tool", "install", "specify-cli", "--from", "git+https://github.com/github/spec-kit.git@"+SpecKitVersion); err != nil {
- return "", fmt.Errorf("Spec Kit installation failed: %w", err)
- }
- if path, err := lookPath("specify"); err == nil {
- return path, nil
- }
- binDirectory, err := externalOutput("", uv, "tool", "dir", "--bin")
- if err != nil || binDirectory == "" {
- return "", fmt.Errorf("Spec Kit installed but specify is not available on PATH")
- }
- name := "specify"
- if runtime.GOOS == "windows" {
- name += ".exe"
- }
- path := filepath.Join(binDirectory, name)
- if _, err := os.Stat(path); err != nil {
- return "", fmt.Errorf("Spec Kit installed but specify is not available at %s", path)
- }
- return path, nil
-}
-
-func specKitHosts(adapters []string) []string {
- hosts := []string{}
- for _, pair := range []struct{ adapter, integration string }{
- {"cursor", "cursor-agent"}, {"codex", "codex"}, {"claude", "claude"},
- } {
- if contains(adapters, pair.adapter) {
- hosts = append(hosts, pair.integration)
- }
- }
- return hosts
-}
-
-func installSpecKit(repo string, adapters []string) IntegrationState {
- state := IntegrationState{Requested: true, Status: "partial", Version: SpecKitVersion}
- specify, err := specKitExecutable()
- if err != nil {
- state.Detail = err.Error()
- return state
- }
- hosts := specKitHosts(adapters)
- if len(hosts) == 0 {
- state.Detail = "Spec Kit installed, but no Cursor, Codex, or Claude adapter was selected"
- return state
- }
- if _, err := os.Stat(filepath.Join(repo, ".specify")); os.IsNotExist(err) {
- script := "sh"
- if runtime.GOOS == "windows" {
- script = "ps"
- }
- arguments := []string{"init", "--here", "--force", "--integration", hosts[0], "--ignore-agent-tools", "--script", script}
- if hosts[0] == "codex" {
- arguments = append(arguments, "--integration-options=--skills")
- }
- if err := runExternal(repo, specify, arguments...); err != nil {
- state.Detail = "Spec Kit project initialization failed; Boatstack core remains installed"
- return state
- }
- hosts = hosts[1:]
- }
- for _, host := range hosts {
- if err := runExternal(repo, specify, "integration", "install", host); err != nil {
- state.Detail = fmt.Sprintf("Spec Kit installed, but its %s integration failed", host)
- return state
- }
- }
- state.Status = "installed"
- state.Detail = "Spec Kit installed as an artifact generator; Boatstack retains approval and build authority"
- return state
-}
-
-func InstallIntegrations(choice, repo string, adapters []string) (map[string]IntegrationState, error) {
- wantGStack, wantSpecKit, err := RequestedIntegrations(choice)
- if err != nil {
- return nil, err
- }
- states := map[string]IntegrationState{
- "gstack": {Requested: false, Status: "not_selected", Version: GStackRef},
- "spec-kit": {Requested: false, Status: "not_selected", Version: SpecKitVersion},
- }
- if wantGStack {
- states["gstack"] = installGStack(adapters)
- }
- if wantSpecKit {
- states["spec-kit"] = installSpecKit(repo, adapters)
- }
- return states, nil
-}
diff --git a/boatstack/internal/deliverycontrol/advise.go b/boatstack/internal/deliverycontrol/advise.go
deleted file mode 100644
index 49b587b..0000000
--- a/boatstack/internal/deliverycontrol/advise.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package deliverycontrol
-
-// Advice is the oracle's recommendation from a state: the lowest-cost next
-// control to take toward the goal, and the remaining cost from here. It is
-// purely advisory — a projection of the shortest path's first step — and is
-// Unresolved (with no recommendation) whenever the oracle cannot resolve the
-// endpoints, so a caller never acts on a fabricated route.
-type Advice struct {
- From StateID
- Goal StateID
- NextTransition TransitionID
- NextTo StateID
- NextCostClass TransitionCostClass
- RemainingCost int
- Resolution Resolution
-}
-
-// Advise returns the recommended next move from a state toward a goal. When the
-// start already equals the goal, the advice is Resolved with no next move (the
-// walk is complete).
-func (g *Graph) Advise(from, goal StateID) Advice {
- advice := Advice{From: from, Goal: goal, Resolution: Unresolved}
- path := g.ShortestFlow(from, goal)
- if path.Resolution != Resolved {
- return advice
- }
- advice.Resolution = Resolved
- advice.RemainingCost = path.Cost
- if len(path.Edges) > 0 {
- first := path.Edges[0]
- advice.NextTransition = first.Transition
- advice.NextTo = first.To
- advice.NextCostClass = first.CostClass
- }
- return advice
-}
-
-// IsLegalMove reports whether a transition is a legal out-edge from a state in
-// this graph — i.e. taking it now would advance rather than hit friction. The
-// controller uses this to distinguish a productive move from a friction move
-// without re-deriving the state machine's guards.
-func (g *Graph) IsLegalMove(from StateID, transition TransitionID) bool {
- for _, edge := range g.Out(from) {
- if edge.Transition == transition {
- return true
- }
- }
- return false
-}
diff --git a/boatstack/internal/deliverycontrol/advise_test.go b/boatstack/internal/deliverycontrol/advise_test.go
deleted file mode 100644
index d9cab64..0000000
--- a/boatstack/internal/deliverycontrol/advise_test.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package deliverycontrol
-
-import "testing"
-
-func TestCheckPasses(t *testing.T) {
- result := Check()
- if !result.OK {
- t.Fatalf("static flow check failed: registry=%v live=%v", result.RegistryIssues, result.Liveness.Live)
- }
-}
-
-func TestAdviseRecommendsFirstOracleStep(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
-
- advice := g.Advise(StateBuild, StatePublished)
- if advice.Resolution != Resolved {
- t.Fatalf("expected resolved advice, got %s", advice.Resolution)
- }
- if advice.NextTransition != "delivery.record_gate_test" {
- t.Errorf("next move from BUILD = %s, want delivery.record_gate_test", advice.NextTransition)
- }
- if advice.NextTo != StateTestPassed {
- t.Errorf("next state = %s, want TEST_PASSED", advice.NextTo)
- }
- if advice.RemainingCost != 3 {
- t.Errorf("remaining cost = %d, want 3", advice.RemainingCost)
- }
-}
-
-func TestAdviseSameStateHasNoNextMove(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
- advice := g.Advise(StatePublished, StatePublished)
- if advice.Resolution != Resolved || advice.NextTransition != "" || advice.RemainingCost != 0 {
- t.Errorf("goal-reached advice should be resolved with no move: %+v", advice)
- }
-}
-
-func TestAdviseUnresolved(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
- if advice := g.Advise(StateID("BOGUS"), StatePublished); advice.Resolution != Unresolved || advice.NextTransition != "" {
- t.Errorf("unknown state must not recommend a move: %+v", advice)
- }
-}
-
-func TestIsLegalMove(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
- if !g.IsLegalMove(StateReviewPassed, "delivery.publish") {
- t.Error("publish should be legal from REVIEW_PASSED")
- }
- if g.IsLegalMove(StateBuild, "delivery.publish") {
- t.Error("publish must not be legal from BUILD (that is friction)")
- }
-}
diff --git a/boatstack/internal/deliverycontrol/coding.go b/boatstack/internal/deliverycontrol/coding.go
deleted file mode 100644
index 84a6529..0000000
--- a/boatstack/internal/deliverycontrol/coding.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package deliverycontrol
-
-// CodingSignal is one recorded unit of coding effort — the work of writing a fix,
-// distinct from navigating the delivery flow. It is telemetry only: coding effort
-// is never modeled as a graph, never optimized, and never gated. Keeping it in its
-// own record type is what guarantees J_coding can never leak into J_flow or
-// regret, per the J = J_flow + J_coding decomposition.
-type CodingSignal struct {
- Sequence int `json:"sequence"`
- Units int `json:"units"`
- Note string `json:"note,omitempty"`
-}
-
-// CodingEffort is the tally of coding signals in a session: the summed units
-// (J_coding) and how many signals produced it. It stands beside J_flow in a
-// report; the two figures are never added together.
-type CodingEffort struct {
- JCoding int `json:"j_coding"`
- Signals int `json:"signals"`
-}
-
-// TallyCoding sums coding signals into J_coding. A signal with non-positive units
-// counts as a single unit, so a bare "coding work happened" marker still
-// registers exactly one unit of effort.
-func TallyCoding(signals []CodingSignal) CodingEffort {
- effort := CodingEffort{}
- for _, s := range signals {
- units := s.Units
- if units <= 0 {
- units = 1
- }
- effort.JCoding += units
- effort.Signals++
- }
- return effort
-}
diff --git a/boatstack/internal/deliverycontrol/coding_test.go b/boatstack/internal/deliverycontrol/coding_test.go
deleted file mode 100644
index ac795e4..0000000
--- a/boatstack/internal/deliverycontrol/coding_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package deliverycontrol
-
-import "testing"
-
-func TestTallyCodingSumsUnits(t *testing.T) {
- effort := TallyCoding([]CodingSignal{{Units: 3}, {Units: 2}})
- if effort.JCoding != 5 || effort.Signals != 2 {
- t.Errorf("tally = %+v, want J_coding 5 over 2 signals", effort)
- }
-}
-
-func TestTallyCodingBareMarkerCountsOne(t *testing.T) {
- effort := TallyCoding([]CodingSignal{{Units: 0}, {Units: -4}})
- if effort.JCoding != 2 {
- t.Errorf("bare/negative markers should each count one unit; J_coding = %d, want 2", effort.JCoding)
- }
-}
-
-func TestCodingLogRoundTrip(t *testing.T) {
- dir := t.TempDir()
- if signals, err := ReadCodingSignals(dir); err != nil || len(signals) != 0 {
- t.Fatalf("empty coding log must read clean: %v %v", signals, err)
- }
- if err := AppendCodingSignal(dir, CodingSignal{Units: 2, Note: "repair"}); err != nil {
- t.Fatal(err)
- }
- if err := AppendCodingSignal(dir, CodingSignal{Units: 1, Note: "amend"}); err != nil {
- t.Fatal(err)
- }
- signals, err := ReadCodingSignals(dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(signals) != 2 || signals[0].Note != "repair" || signals[1].Units != 1 {
- t.Errorf("round-trip mismatch: %+v", signals)
- }
-}
-
-// Coding effort must be reported ALONGSIDE flow regret, never folded into it.
-func TestComputeReportWithCodingKeepsRegretFlowOnly(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
- // A single friction attempt: J_flow = 3, oracle from BUILD = 3, regret = 0.
- walk := Trajectory{{From: StateBuild, Transition: "delivery.publish", Outcome: OutcomeDenied, CostClass: CostFriction}}
- signals := []CodingSignal{{Units: 7}}
-
- report := ComputeReportWithCoding(walk, g, DefaultFlowCostWeights(), StatePublished, signals)
- if report.JCoding != 7 {
- t.Errorf("J_coding = %d, want 7", report.JCoding)
- }
- // Regret is derived purely from flow; coding effort must not perturb it.
- bare := ComputeReport(walk, g, DefaultFlowCostWeights(), StatePublished)
- if report.Regret != bare.Regret || report.JFlow != bare.JFlow {
- t.Errorf("coding telemetry leaked into flow: with-coding=%+v flow-only=%+v", report, bare)
- }
-}
diff --git a/boatstack/internal/deliverycontrol/codinglog.go b/boatstack/internal/deliverycontrol/codinglog.go
deleted file mode 100644
index a9a2a30..0000000
--- a/boatstack/internal/deliverycontrol/codinglog.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package deliverycontrol
-
-import (
- "bufio"
- "encoding/json"
- "errors"
- "io/fs"
- "os"
- "path/filepath"
-)
-
-// codingLogFile is the append-only record of coding-effort signals within a
-// flow-log directory, held separate from the trajectory log so J_coding and
-// J_flow can never be conflated at the storage layer. One JSON object per line.
-const codingLogFile = "coding.jsonl"
-
-// AppendCodingSignal appends one coding-effort signal under dir, creating the
-// directory and file as needed. Like the trajectory writer it returns an error so
-// tests can assert round-trips, while the live recorder swallows every error as
-// best-effort — telemetry must never change command behavior.
-func AppendCodingSignal(dir string, signal CodingSignal) error {
- if dir == "" {
- return errors.New("coding log directory is empty")
- }
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
- line, err := json.Marshal(signal)
- if err != nil {
- return err
- }
- file, err := os.OpenFile(filepath.Join(dir, codingLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
- if err != nil {
- return err
- }
- defer file.Close()
- if _, err := file.Write(append(line, '\n')); err != nil {
- return err
- }
- return nil
-}
-
-// ReadCodingSignals reads the append-only coding log under dir in write order. A
-// missing log is an empty slice, not an error, so a first read before any write
-// is well-defined.
-func ReadCodingSignals(dir string) ([]CodingSignal, error) {
- file, err := os.Open(filepath.Join(dir, codingLogFile))
- if err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return []CodingSignal{}, nil
- }
- return nil, err
- }
- defer file.Close()
-
- var signals []CodingSignal
- scanner := bufio.NewScanner(file)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
- for scanner.Scan() {
- line := scanner.Bytes()
- if len(line) == 0 {
- continue
- }
- var signal CodingSignal
- if err := json.Unmarshal(line, &signal); err != nil {
- return nil, err
- }
- signals = append(signals, signal)
- }
- if err := scanner.Err(); err != nil {
- return nil, err
- }
- return signals, nil
-}
diff --git a/boatstack/internal/deliverycontrol/commandlog.go b/boatstack/internal/deliverycontrol/commandlog.go
deleted file mode 100644
index 44fbaa6..0000000
--- a/boatstack/internal/deliverycontrol/commandlog.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package deliverycontrol
-
-import (
- "bufio"
- "encoding/json"
- "errors"
- "io/fs"
- "os"
- "path/filepath"
- "time"
-)
-
-const (
- commandEventSchemaVersion = 1
- commandLogFile = "commands.jsonl"
-)
-
-// CommandEvent is one secret-free helper dispatch. It deliberately excludes
-// argv, stdin, stdout, stderr, and environment values; only bounded workflow
-// identity and timing fields may enter the shadow log.
-type CommandEvent struct {
- SchemaVersion int `json:"schema_version"`
- Verb string `json:"verb"`
- Category string `json:"category"`
- Feature string `json:"feature,omitempty"`
- Slice string `json:"slice,omitempty"`
- Transition TransitionID `json:"transition,omitempty"`
- StartedAt string `json:"started_at"`
- FinishedAt string `json:"finished_at"`
- DurationMS int64 `json:"duration_ms"`
- ExitCode int `json:"exit_code"`
- Outcome string `json:"outcome"`
- AuthorityFingerprint string `json:"authority_fingerprint,omitempty"`
- OperationFingerprint string `json:"operation_fingerprint,omitempty"`
-}
-
-func NewCommandEvent(event CommandEvent) CommandEvent {
- event.SchemaVersion = commandEventSchemaVersion
- return event
-}
-
-func validateCommandEvent(event CommandEvent) error {
- if event.SchemaVersion != commandEventSchemaVersion || event.Verb == "" || event.Category == "" {
- return errors.New("command event identity is invalid")
- }
- started, err := time.Parse(time.RFC3339Nano, event.StartedAt)
- if err != nil {
- return errors.New("command event started_at is invalid")
- }
- finished, err := time.Parse(time.RFC3339Nano, event.FinishedAt)
- if err != nil || finished.Before(started) || event.DurationMS < 0 {
- return errors.New("command event timing is invalid")
- }
- if event.Outcome != "succeeded" && event.Outcome != "failed" && event.Outcome != "usage_error" {
- return errors.New("command event outcome is invalid")
- }
- return nil
-}
-
-func AppendCommandEvent(dir string, event CommandEvent) error {
- if dir == "" {
- return errors.New("command log directory is empty")
- }
- event = NewCommandEvent(event)
- if err := validateCommandEvent(event); err != nil {
- return err
- }
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
- line, err := json.Marshal(event)
- if err != nil {
- return err
- }
- file, err := os.OpenFile(filepath.Join(dir, commandLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
- if err != nil {
- return err
- }
- defer file.Close()
- _, err = file.Write(append(line, '\n'))
- return err
-}
-
-func ReadCommandEvents(dir string) ([]CommandEvent, error) {
- file, err := os.Open(filepath.Join(dir, commandLogFile))
- if err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return []CommandEvent{}, nil
- }
- return nil, err
- }
- defer file.Close()
-
- events := []CommandEvent{}
- scanner := bufio.NewScanner(file)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
- for scanner.Scan() {
- if len(scanner.Bytes()) == 0 {
- continue
- }
- var event CommandEvent
- if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
- return nil, err
- }
- if err := validateCommandEvent(event); err != nil {
- return nil, err
- }
- events = append(events, event)
- }
- if err := scanner.Err(); err != nil {
- return nil, err
- }
- return events, nil
-}
diff --git a/boatstack/internal/deliverycontrol/commandlog_test.go b/boatstack/internal/deliverycontrol/commandlog_test.go
deleted file mode 100644
index fbb91c2..0000000
--- a/boatstack/internal/deliverycontrol/commandlog_test.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package deliverycontrol
-
-import (
- "path/filepath"
- "testing"
-)
-
-func TestCommandLogRoundTrip(t *testing.T) {
- dir := filepath.Join(t.TempDir(), "flow")
- event := NewCommandEvent(CommandEvent{
- Verb: "publish-pr", Category: "publication", Feature: "demo", Slice: "delivery",
- Transition: "delivery.publish", StartedAt: "2026-08-09T10:00:00Z",
- FinishedAt: "2026-08-09T10:00:01Z", DurationMS: 1000, ExitCode: 0,
- Outcome: "succeeded", AuthorityFingerprint: "authority", OperationFingerprint: "operation",
- })
- if err := AppendCommandEvent(dir, event); err != nil {
- t.Fatal(err)
- }
- events, err := ReadCommandEvents(dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(events) != 1 || events[0] != event {
- t.Fatalf("events = %+v, want %+v", events, event)
- }
-}
-
-func TestCommandLogRejectsMalformedOrSecretBearingShape(t *testing.T) {
- dir := filepath.Join(t.TempDir(), "flow")
- bad := CommandEvent{Verb: "x", Category: "test", StartedAt: "bad", FinishedAt: "bad", Outcome: "succeeded"}
- if err := AppendCommandEvent(dir, bad); err == nil {
- t.Fatal("malformed timing was accepted")
- }
- if events, err := ReadCommandEvents(dir); err != nil || len(events) != 0 {
- t.Fatalf("failed append changed log: events=%v err=%v", events, err)
- }
-}
diff --git a/boatstack/internal/deliverycontrol/cost.go b/boatstack/internal/deliverycontrol/cost.go
deleted file mode 100644
index 4fb400a..0000000
--- a/boatstack/internal/deliverycontrol/cost.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package deliverycontrol
-
-// FlowCostWeights maps a TransitionCostClass to its J_flow cost. Per the cmg
-// model (../../notes/delivery-flow-navigation-model.md): a normal
-// move/observe/inspect costs 1; a denied/blocked committed mutation is friction
-// and costs 3 (it burns a turn and returns nothing).
-type FlowCostWeights map[TransitionCostClass]int
-
-// DefaultFlowCostWeights is the cost model the cmg prototype pins
-// (composable-model-graph:python/examples/12-agent-trajectory/main.py).
-func DefaultFlowCostWeights() FlowCostWeights {
- return FlowCostWeights{
- CostObserve: 1,
- CostInspect: 1,
- CostQuery: 1,
- CostMutation: 1,
- CostRecovery: 1,
- CostFriction: 3,
- }
-}
-
-// Cost returns the weight for a cost class and whether it is defined.
-func (w FlowCostWeights) Cost(class TransitionCostClass) (int, bool) {
- v, ok := w[class]
- return v, ok
-}
diff --git a/boatstack/internal/deliverycontrol/graph.go b/boatstack/internal/deliverycontrol/graph.go
deleted file mode 100644
index 0c169d1..0000000
--- a/boatstack/internal/deliverycontrol/graph.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package deliverycontrol
-
-import "sort"
-
-// Edge is a directed, costed control between two delivery-flow states: a single
-// move an agent can make, priced by its cost class.
-type Edge struct {
- Transition TransitionID
- From StateID
- To StateID
- CostClass TransitionCostClass
- Cost int
-}
-
-// Graph is a weighted directed graph of delivery-flow controls. It is the
-// optimization projection of the single registry declaration: the same
-// transitions the conformance projection audits, arranged as a graph a
-// deterministic oracle can score. Only state-changing transitions (To != "")
-// become edges; read-only observes do not advance state and so are not moves on
-// this graph.
-type Graph struct {
- weights FlowCostWeights
- out map[StateID][]Edge
- nodes map[StateID]bool
-}
-
-// NewGraph returns an empty graph priced by the given weights.
-func NewGraph(weights FlowCostWeights) *Graph {
- return &Graph{
- weights: weights,
- out: map[StateID][]Edge{},
- nodes: map[StateID]bool{},
- }
-}
-
-// AddEdge adds a directed edge priced by the transition's cost class. An edge
-// whose cost class has no defined weight is skipped (the well-formedness
-// conformance test forbids that in the registry, so this only guards ad-hoc
-// graphs). Endpoints are registered as nodes even when the class is unknown, so
-// a state that only appears on a skipped edge is still a known node.
-func (g *Graph) AddEdge(from, to StateID, transition TransitionID, class TransitionCostClass) {
- g.nodes[from] = true
- g.nodes[to] = true
- cost, ok := g.weights.Cost(class)
- if !ok {
- return
- }
- g.out[from] = append(g.out[from], Edge{
- Transition: transition,
- From: from,
- To: to,
- CostClass: class,
- Cost: cost,
- })
-}
-
-// Out returns the out-edges of a state in insertion order (deterministic).
-func (g *Graph) Out(state StateID) []Edge {
- return g.out[state]
-}
-
-// Has reports whether a state is a known node (appears as an edge endpoint).
-// The oracle uses this to return Unresolved for a state it has never seen rather
-// than fabricate a path from nowhere.
-func (g *Graph) Has(state StateID) bool {
- return g.nodes[state]
-}
-
-// Nodes returns every known state, sorted for deterministic iteration.
-func (g *Graph) Nodes() []StateID {
- out := make([]StateID, 0, len(g.nodes))
- for n := range g.nodes {
- out = append(out, n)
- }
- sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
- return out
-}
-
-// RegistryGraph projects the registry into a costed graph: one edge per
-// (From, To) pair of every transition that changes delivery state. A transition
-// with an empty To (a pure observation) advances nothing and contributes no
-// edge; a transition with an empty From (delivery.next, resolved from any state)
-// likewise contributes no edge because it changes no state. Iteration order
-// follows the registry declaration, so the projection is deterministic.
-func RegistryGraph(weights FlowCostWeights) *Graph {
- g := NewGraph(weights)
- for _, tr := range Transitions() {
- if tr.To == "" {
- continue
- }
- for _, from := range tr.From {
- g.AddEdge(from, tr.To, tr.ID, tr.CostClass)
- }
- }
- return g
-}
diff --git a/boatstack/internal/deliverycontrol/liveness.go b/boatstack/internal/deliverycontrol/liveness.go
deleted file mode 100644
index 2275bfb..0000000
--- a/boatstack/internal/deliverycontrol/liveness.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package deliverycontrol
-
-import (
- "fmt"
- "sort"
-)
-
-// TerminalStates are delivery-flow states from which no further move is expected:
-// PUBLISHED is the accepted goal, DISCARDED is the archived end. A reachable
-// state that is neither terminal nor able to move is a deadlock.
-func TerminalStates() []StateID {
- return []StateID{StatePublished, StateDiscarded}
-}
-
-// EntryStates are where a delivery flow can begin: a fresh delivery
-// (UNINITIALIZED) and the recovery entry (INVALID, re-entered via repair). The
-// liveness check reaches the rest of the graph from these.
-func EntryStates() []StateID {
- return []StateID{StateUninitialized, StateInvalid}
-}
-
-// LivenessResult reports deadlock-freedom over the delivery graph: every state
-// reachable from the entries either is terminal, is the goal, or can still move
-// and still reach the goal.
-type LivenessResult struct {
- Goal StateID `json:"goal"`
- Reachable []StateID `json:"reachable"`
- Deadlocks []StateID `json:"deadlocks"` // reachable, non-terminal, non-goal states with no out-edge
- GoalUnreachable []StateID `json:"goal_unreachable"` // reachable, non-terminal, non-goal states from which the goal is Unresolved
- Live bool `json:"live"`
-}
-
-// CheckLiveness verifies the delivery graph is free of deadlocks and that the
-// goal stays reachable. From every state reachable from the entries, a
-// non-terminal non-goal state must have at least one out-edge (it can move) and
-// the oracle must resolve a path from it to the goal (it is not stranded). The
-// result is deterministic: all reported slices are sorted.
-func CheckLiveness(g *Graph, entries []StateID, goal StateID, terminals []StateID) LivenessResult {
- terminal := map[StateID]bool{}
- for _, s := range terminals {
- terminal[s] = true
- }
-
- // Breadth-first reachability from the entries, following out-edges.
- seen := map[StateID]bool{}
- queue := append([]StateID{}, entries...)
- for _, e := range entries {
- seen[e] = true
- }
- for len(queue) > 0 {
- current := queue[0]
- queue = queue[1:]
- for _, edge := range g.Out(current) {
- if !seen[edge.To] {
- seen[edge.To] = true
- queue = append(queue, edge.To)
- }
- }
- }
-
- result := LivenessResult{Goal: goal, Live: true}
- for s := range seen {
- result.Reachable = append(result.Reachable, s)
- if s == goal || terminal[s] {
- continue
- }
- if len(g.Out(s)) == 0 {
- result.Deadlocks = append(result.Deadlocks, s)
- result.Live = false
- continue
- }
- if g.ShortestFlow(s, goal).Resolution != Resolved {
- result.GoalUnreachable = append(result.GoalUnreachable, s)
- result.Live = false
- }
- }
- sort.Slice(result.Reachable, func(i, j int) bool { return result.Reachable[i] < result.Reachable[j] })
- sort.Slice(result.Deadlocks, func(i, j int) bool { return result.Deadlocks[i] < result.Deadlocks[j] })
- sort.Slice(result.GoalUnreachable, func(i, j int) bool { return result.GoalUnreachable[i] < result.GoalUnreachable[j] })
- return result
-}
-
-// CheckResult is the outcome of the runtime flow-check gate: registry
-// well-formedness plus deadlock-freedom over the delivery graph.
-type CheckResult struct {
- RegistryIssues []string `json:"registry_issues"`
- Liveness LivenessResult `json:"liveness"`
- OK bool `json:"ok"`
-}
-
-// Check runs the full static gate over the single declaration and its graph. It
-// takes no repository state — it validates the owned model itself, so the CLI
-// gate is deterministic and side-effect free.
-func Check() CheckResult {
- result := CheckResult{RegistryIssues: CheckRegistry()}
- graph := RegistryGraph(DefaultFlowCostWeights())
- result.Liveness = CheckLiveness(graph, EntryStates(), StatePublished, TerminalStates())
- result.OK = len(result.RegistryIssues) == 0 && result.Liveness.Live
- return result
-}
-
-// CheckRegistry validates the single declaration at runtime: unique ids, valid
-// kinds and cost classes with defined weights, declared endpoint states, and a
-// non-empty registry. It returns a sorted list of human-readable issues, empty
-// when the registry is well-formed. This is the runtime half of the conformance
-// gate; the compile-time parity test in package boatstack guarantees the handler
-// references name real functions.
-func CheckRegistry() []string {
- weights := DefaultFlowCostWeights()
- states := map[StateID]bool{}
- for _, s := range States() {
- states[s] = true
- }
- kinds := map[TransitionKind]bool{}
- for _, k := range AllKinds() {
- kinds[k] = true
- }
- classes := map[TransitionCostClass]bool{}
- for _, c := range AllCostClasses() {
- classes[c] = true
- }
-
- var issues []string
- seen := map[TransitionID]bool{}
- for _, tr := range Transitions() {
- if tr.ID == "" {
- issues = append(issues, "transition with empty ID")
- continue
- }
- if seen[tr.ID] {
- issues = append(issues, fmt.Sprintf("%s: duplicate transition ID", tr.ID))
- }
- seen[tr.ID] = true
- if !kinds[tr.Kind] {
- issues = append(issues, fmt.Sprintf("%s: undeclared kind %q", tr.ID, tr.Kind))
- }
- if !classes[tr.CostClass] {
- issues = append(issues, fmt.Sprintf("%s: undeclared cost class %q", tr.ID, tr.CostClass))
- } else if _, ok := weights.Cost(tr.CostClass); !ok {
- issues = append(issues, fmt.Sprintf("%s: cost class %q has no weight", tr.ID, tr.CostClass))
- }
- for _, from := range tr.From {
- if !states[from] {
- issues = append(issues, fmt.Sprintf("%s: undeclared From state %q", tr.ID, from))
- }
- }
- if tr.To != "" && !states[tr.To] {
- issues = append(issues, fmt.Sprintf("%s: undeclared To state %q", tr.ID, tr.To))
- }
- if tr.HandlerRef == "" {
- issues = append(issues, fmt.Sprintf("%s: empty HandlerRef", tr.ID))
- }
- }
- if len(seen) == 0 {
- issues = append(issues, "registry is empty")
- }
- sort.Strings(issues)
- return issues
-}
diff --git a/boatstack/internal/deliverycontrol/liveness_test.go b/boatstack/internal/deliverycontrol/liveness_test.go
deleted file mode 100644
index 81aea08..0000000
--- a/boatstack/internal/deliverycontrol/liveness_test.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package deliverycontrol
-
-import "testing"
-
-// control-law: delivery-graph-is-live
-// Deadlock-freedom: from every state reachable in the real registry graph, the
-// flow can still move and still reach the goal (PUBLISHED). A regression that
-// stranded a state — an out-edge removed, a goal made unreachable — fails here.
-func TestRegistryGraphIsLive(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
- result := CheckLiveness(g, EntryStates(), StatePublished, TerminalStates())
-
- if !result.Live {
- t.Fatalf("delivery graph is not live: deadlocks=%v goal-unreachable=%v", result.Deadlocks, result.GoalUnreachable)
- }
- // The core lifecycle states must all be reachable from the entries.
- want := []StateID{
- StateUninitialized, StateBuild, StateTestPassed, StateReviewPassed, StatePublished,
- StateAmendmentRequired, StateAmendmentDrafted, StateAmendmentApproved,
- }
- reachable := map[StateID]bool{}
- for _, s := range result.Reachable {
- reachable[s] = true
- }
- for _, s := range want {
- if !reachable[s] {
- t.Errorf("expected %q reachable from entries", s)
- }
- }
-}
-
-// A hand-built graph with a genuine dead end is caught.
-func TestCheckLivenessDetectsDeadlock(t *testing.T) {
- g := NewGraph(DefaultFlowCostWeights())
- g.AddEdge(StateUninitialized, StateBuild, "activate", CostMutation)
- // BUILD has no way forward and is not terminal → a deadlock.
- result := CheckLiveness(g, []StateID{StateUninitialized}, StatePublished, TerminalStates())
- if result.Live {
- t.Fatal("expected a deadlock to be reported")
- }
- found := false
- for _, s := range result.Deadlocks {
- if s == StateBuild {
- found = true
- }
- }
- if !found {
- t.Errorf("BUILD should be a deadlock; got %v", result.Deadlocks)
- }
-}
-
-// A reachable non-terminal state that cannot reach the goal is reported as
-// goal-unreachable (not a deadlock — it can move, just not to the goal).
-func TestCheckLivenessDetectsGoalUnreachable(t *testing.T) {
- g := NewGraph(DefaultFlowCostWeights())
- g.AddEdge(StateUninitialized, StateBuild, "activate", CostMutation)
- g.AddEdge(StateBuild, StateDiscarded, "discard", CostMutation) // moves, but only to a terminal
- result := CheckLiveness(g, []StateID{StateUninitialized}, StatePublished, TerminalStates())
- if result.Live {
- t.Fatal("expected goal-unreachable to make the graph non-live")
- }
- if len(result.GoalUnreachable) == 0 {
- t.Errorf("BUILD cannot reach PUBLISHED; expected it reported, got %v", result.GoalUnreachable)
- }
-}
-
-func TestCheckRegistryClean(t *testing.T) {
- if issues := CheckRegistry(); len(issues) != 0 {
- t.Errorf("registry should be well-formed; issues: %v", issues)
- }
-}
diff --git a/boatstack/internal/deliverycontrol/oracle.go b/boatstack/internal/deliverycontrol/oracle.go
deleted file mode 100644
index a968b6a..0000000
--- a/boatstack/internal/deliverycontrol/oracle.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package deliverycontrol
-
-// Resolution reports whether the oracle found a path. Unknown or unreachable
-// states are Unresolved — the oracle never fabricates a route it cannot prove.
-type Resolution string
-
-const (
- Resolved Resolution = "resolved"
- Unresolved Resolution = "unresolved"
-)
-
-// FlowPath is the oracle's answer: the lowest-cost sequence of controls from a
-// start state to a goal state, or Unresolved when none exists.
-type FlowPath struct {
- From StateID
- Goal StateID
- Edges []Edge
- Cost int
- Resolution Resolution
-}
-
-// ShortestFlow is the free oracle: Dijkstra over the owned graph for the
-// lowest-cost walk from a state to a goal. It is deterministic — ties are broken
-// by the frontier's insertion order, which follows the registry declaration — so
-// the same graph and endpoints always yield the same path.
-//
-// It returns Unresolved, never a guess, when:
-// - the start state is not a known node (unknown/invalid state), or
-// - the goal is not a known node, or
-// - no path from start to goal exists.
-//
-// A start that already equals the goal resolves to a zero-cost, empty path.
-func (g *Graph) ShortestFlow(from, goal StateID) FlowPath {
- result := FlowPath{From: from, Goal: goal, Resolution: Unresolved}
-
- if !g.Has(from) || !g.Has(goal) {
- return result
- }
- if from == goal {
- result.Cost = 0
- result.Resolution = Resolved
- return result
- }
-
- const unreached = -1
- dist := map[StateID]int{from: 0}
- prev := map[StateID]Edge{}
- visited := map[StateID]bool{}
-
- for {
- // Select the unvisited node with the smallest known distance. Nodes() is
- // sorted, so among equal distances the lexicographically-first node wins:
- // a stable, reproducible choice.
- current := StateID("")
- best := unreached
- for _, node := range g.Nodes() {
- if visited[node] {
- continue
- }
- d, seen := dist[node]
- if !seen {
- continue
- }
- if best == unreached || d < best {
- best = d
- current = node
- }
- }
- if best == unreached {
- // Frontier exhausted without reaching the goal.
- return result
- }
- if current == goal {
- break
- }
- visited[current] = true
-
- for _, edge := range g.Out(current) {
- candidate := dist[current] + edge.Cost
- if existing, seen := dist[edge.To]; !seen || candidate < existing {
- dist[edge.To] = candidate
- prev[edge.To] = edge
- }
- }
- }
-
- // Reconstruct the path from goal back to start.
- var reversed []Edge
- for at := goal; at != from; {
- edge, ok := prev[at]
- if !ok {
- return FlowPath{From: from, Goal: goal, Resolution: Unresolved}
- }
- reversed = append(reversed, edge)
- at = edge.From
- }
- edges := make([]Edge, len(reversed))
- for i, edge := range reversed {
- edges[len(reversed)-1-i] = edge
- }
-
- result.Edges = edges
- result.Cost = dist[goal]
- result.Resolution = Resolved
- return result
-}
diff --git a/boatstack/internal/deliverycontrol/oracle_test.go b/boatstack/internal/deliverycontrol/oracle_test.go
deleted file mode 100644
index c0ebf63..0000000
--- a/boatstack/internal/deliverycontrol/oracle_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package deliverycontrol
-
-import "testing"
-
-// control-law: oracle-resolves-real-paths-or-unresolved
-// The oracle must return the true shortest cost over the registry graph for
-// reachable goals and Unresolved — never a fabricated path — for unknown or
-// unreachable states.
-func TestShortestFlowOverRegistry(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
-
- cases := []struct {
- name string
- from StateID
- goal StateID
- cost int
- }{
- {"activate through publish", StateUninitialized, StatePublished, 4},
- {"build to published", StateBuild, StatePublished, 3},
- {"review to published", StateReviewPassed, StatePublished, 1},
- {"repair out of invalid", StateInvalid, StateUninitialized, 1},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- path := g.ShortestFlow(tc.from, tc.goal)
- if path.Resolution != Resolved {
- t.Fatalf("%s→%s: expected Resolved, got %s", tc.from, tc.goal, path.Resolution)
- }
- if path.Cost != tc.cost {
- t.Errorf("%s→%s: cost = %d, want %d (edges: %v)", tc.from, tc.goal, path.Cost, tc.cost, edgeIDs(path.Edges))
- }
- // The reconstructed path's costs must sum to the reported cost, and
- // each edge must chain from the previous To.
- sum, at := 0, tc.from
- for _, e := range path.Edges {
- if e.From != at {
- t.Errorf("%s→%s: broken chain at edge %s (from %s, expected %s)", tc.from, tc.goal, e.Transition, e.From, at)
- }
- sum += e.Cost
- at = e.To
- }
- if sum != path.Cost {
- t.Errorf("%s→%s: edge cost sum %d != reported %d", tc.from, tc.goal, sum, path.Cost)
- }
- if at != tc.goal {
- t.Errorf("%s→%s: path ends at %s", tc.from, tc.goal, at)
- }
- })
- }
-}
-
-func TestShortestFlowSameStateIsFree(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
- path := g.ShortestFlow(StateBuild, StateBuild)
- if path.Resolution != Resolved || path.Cost != 0 || len(path.Edges) != 0 {
- t.Fatalf("same-state path: got %+v, want resolved zero-cost empty", path)
- }
-}
-
-func TestShortestFlowUnresolved(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
-
- // Unknown start state — the oracle has never seen it, so it must not guess.
- if path := g.ShortestFlow(StateID("MADE_UP"), StatePublished); path.Resolution != Unresolved {
- t.Errorf("unknown start: got %s, want Unresolved", path.Resolution)
- }
- // Unknown goal state.
- if path := g.ShortestFlow(StateBuild, StateID("NOWHERE")); path.Resolution != Unresolved {
- t.Errorf("unknown goal: got %s, want Unresolved", path.Resolution)
- }
- // Known states with no connecting path: PENDING can only be discarded, so it
- // cannot reach PUBLISHED.
- if path := g.ShortestFlow(StatePending, StatePublished); path.Resolution != Unresolved {
- t.Errorf("unreachable goal: got %s, want Unresolved", path.Resolution)
- }
-}
-
-func edgeIDs(edges []Edge) []TransitionID {
- ids := make([]TransitionID, len(edges))
- for i, e := range edges {
- ids[i] = e.Transition
- }
- return ids
-}
diff --git a/boatstack/internal/deliverycontrol/registry.go b/boatstack/internal/deliverycontrol/registry.go
deleted file mode 100644
index 1d74289..0000000
--- a/boatstack/internal/deliverycontrol/registry.go
+++ /dev/null
@@ -1,157 +0,0 @@
-package deliverycontrol
-
-// registry is the single declaration of Boatstack's delivery transitions,
-// mirroring ../../notes/delivery-control-inventory.md. Each HandlerRef names a
-// real exported function in package boatstack, and each CLIVerb names a real
-// dispatch verb in cmd/boatstack-helper; the conformance tests keep both
-// faithful in each direction. The registry is the authoritative projection
-// source: the flow commands consume it at runtime (NextControl resolves the
-// prescribed command through Transition(id).CLIVerb), and the coverage
-// conformance test asserts it covers exactly the real delivery machine — no real
-// mutation verb without a row, no row without a real verb.
-var registry = []TransitionDescriptor{
- {
- ID: "delivery.activate", From: []StateID{StateUninitialized}, To: StateBuild,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "ActivatePlan", CLIVerb: "activate-plan",
- Note: "Requires CheckPlan + repository-safety PASS and a human/policy approval receipt; writes the plan lock via the mutation boundary. Reversible via delivery.undo while no gate receipt exists.",
- },
- {
- ID: "delivery.record_gate_test", From: []StateID{StateBuild}, To: StateTestPassed,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "RecordDeliveryGate", CLIVerb: "record-delivery-gate",
- Note: "Records the test gate (PASS/PASS_WITH_GAPS) against a validated plan lock and a matching evidence ledger. Reversible via record-change re-gate.",
- },
- {
- ID: "delivery.record_gate_review", From: []StateID{StateTestPassed}, To: StateReviewPassed,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "RecordDeliveryGate", CLIVerb: "record-delivery-gate",
- Note: "Records the review gate; requires prior TEST_PASSED with a matching diff and reviewer identity/method on high-risk paths. Clears rework mode.",
- },
- {
- ID: "delivery.record_change", From: []StateID{StateTestPassed, StateReviewPassed, StatePublished}, To: StateBuild,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "RecordChangeObservation", CLIVerb: "record-change",
- Note: "Rework resets the addressable slice to BUILD (bounded by the typed failure-class counter and a changed mechanism); amendment/plan-invalid set Mode without consuming repair authority; a fully-published delivery emits a corrective child with no state mutation.",
- },
- {
- ID: "delivery.requirement_amendment", From: []StateID{StateBuild, StateTestPassed, StateReviewPassed}, To: StateAmendmentRequired,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "RecordChangeObservation", CLIVerb: "record-change",
- Note: "A requirement_amendment observation pauses ordinary gates and enters the owned amendment planning path while preserving the active plan lock.",
- },
- {
- ID: "delivery.amend_write", From: []StateID{StateAmendmentRequired, StateAmendmentDrafted, StateAmendmentApproved}, To: StateAmendmentDrafted,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "WritePlanningArtifact", CLIVerb: "planning-write",
- Note: "Writes one lifecycle-fingerprinted amendment artifact through a fresh flow-bootstrap prescription; stale observation, lock, worktree, branch, source-plan, or prior plan bytes refuse before mutation.",
- },
- {
- ID: "delivery.amend_approve", From: []StateID{StateAmendmentDrafted}, To: StateAmendmentApproved,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "RecordApproval", CLIVerb: "record-approval",
- Note: "Records approval for the exact amended plan and current product baseline. Policy-authorized repositories derive the same state without a receipt.",
- },
- {
- ID: "delivery.amend_activate", From: []StateID{StateAmendmentApproved}, To: StateBuild,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "ActivatePlan", CLIVerb: "activate-plan",
- Note: "Revalidates the amended plan and authority, preserves the published prefix, compiles the tail, commits the new plan lock, and clears amendment mode only after successful activation.",
- },
- {
- ID: "delivery.invalid_plan_rewrite", From: []StateID{StatePlanInvalid}, To: StateAmendmentDrafted,
- Kind: KindRecovery, CostClass: CostRecovery, Reversible: true,
- HandlerRef: "WritePlanningArtifact", CLIVerb: "planning-write",
- Note: "Re-authors an invalid active plan only through lifecycle-bound planning transport; raw managed-path writes remain denied.",
- },
- {
- ID: "delivery.record_journey_results", From: []StateID{StateBuild, StateTestPassed}, To: StateBuild,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "RecordJourneyResults", CLIVerb: "record-journey-results",
- Note: "Records typed PASS/FAIL journey-oracle evidence bound to the compiled manifest, current head commit, and current delivery diff.",
- },
- {
- ID: "delivery.publish", From: []StateID{StateReviewPassed, StatePublished}, To: StatePublished,
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: false,
- HandlerRef: "PublishPR", CLIVerb: "publish-pr",
- Note: "Publishes the reviewed slice behind a human-confirmed preview fingerprint and advances ActiveIndex to the next slice. Re-publishing a PUBLISHED-open slice is idempotent and does not advance the pointer.",
- },
- {
- ID: "delivery.undo", From: []StateID{StateBuild}, To: StateUninitialized,
- Kind: KindReversibleMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "UndoManagedMutation", CLIVerb: "undo",
- Note: "Reverses a plan-activation/compiled-plan mutation through the boundary (closed under inversion, so redo is undo of the returned receipt). Refused once any gate receipt exists (would strand delivery state).",
- },
- {
- ID: "delivery.discard_delivery", From: []StateID{StatePending, StateBuild, StateTestPassed, StateReviewPassed, StatePublished}, To: StateDiscarded,
- Kind: KindReversibleMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "DiscardDelivery", CLIVerb: "discard-delivery",
- Note: "Archives (never deletes) the delivery state directory. Refuses a slice with a set PRState unless --force; preserves published authority and git/lock/merged history.",
- },
- {
- ID: "delivery.repair_state", From: []StateID{StateInvalid}, To: StateUninitialized,
- Kind: KindRecovery, CostClass: CostRecovery, Reversible: true,
- HandlerRef: "RepairState", CLIVerb: "repair-state",
- Note: "Quarantines a malformed unregistered feature draft by moving it aside; refuses when a plan lock, pr.md, managed state, tracked files, or an active/published delivery is present. Typed and bounded — never a generic bypass.",
- },
- {
- ID: "delivery.ignore_delivery", From: []StateID{StatePending, StateBuild, StateTestPassed, StateReviewPassed, StatePublished}, To: "",
- Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true,
- HandlerRef: "IgnoreDelivery", CLIVerb: "ignore-delivery",
- Note: "Appends the feature to project.json workflow.ignored_deliveries, filtering it from ResolveNext and publication authority. Changes no slice status; reversible by removing the entry.",
- },
- {
- ID: "delivery.status", From: []StateID{StateBuild, StateTestPassed, StateReviewPassed, StatePublished}, To: "",
- Kind: KindObserve, CostClass: CostObserve, Reversible: false,
- HandlerRef: "CurrentDeliveryState", CLIVerb: "delivery-status",
- Note: "Reads delivery state and validates the plan lock. No state change.",
- },
- {
- ID: "delivery.check_ship", From: []StateID{StateReviewPassed, StatePublished}, To: "",
- Kind: KindQuery, CostClass: CostQuery, Reversible: false,
- HandlerRef: "CheckDeliveryReadyForShip", CLIVerb: "pr-context",
- Note: "Re-checks receipt freshness and gate policy for the addressable slice and returns its PR sources via pr-context. No state change.",
- },
- {
- ID: "delivery.next", From: nil, To: "",
- Kind: KindObserve, CostClass: CostObserve, Reversible: false,
- HandlerRef: "ResolveNext", CLIVerb: "next-status",
- Note: "Derives the recommended next move. Read-only, except that the published branch caches an observed terminal PRState — and, under the merged terminal, a fired goal-escape demotion — as a best-effort side effect (a known bypass, modeled not fixed).",
- },
- {
- ID: "delivery.recovery_status", From: []StateID{StateBuild, StateTestPassed, StateReviewPassed, StatePublished}, To: "",
- Kind: KindObserve, CostClass: CostObserve, Reversible: false,
- HandlerRef: "ResolveRecovery", CLIVerb: "recovery-status",
- Note: "Derives a correction decision; carries no edit/approve/publish authority. Read-only, except the same best-effort terminal-PRState cache.",
- },
-}
-
-// Transitions returns a copy of the declared transition set.
-func Transitions() []TransitionDescriptor {
- out := make([]TransitionDescriptor, len(registry))
- copy(out, registry)
- return out
-}
-
-// Transition returns the descriptor with the given ID.
-func Transition(id TransitionID) (TransitionDescriptor, bool) {
- for _, t := range registry {
- if t.ID == id {
- return t, true
- }
- }
- return TransitionDescriptor{}, false
-}
-
-// HandlerRefs returns the distinct real function names the registry declares.
-func HandlerRefs() []string {
- seen := map[string]bool{}
- var out []string
- for _, t := range registry {
- if !seen[t.HandlerRef] {
- seen[t.HandlerRef] = true
- out = append(out, t.HandlerRef)
- }
- }
- return out
-}
diff --git a/boatstack/internal/deliverycontrol/registry_test.go b/boatstack/internal/deliverycontrol/registry_test.go
deleted file mode 100644
index 7519cf7..0000000
--- a/boatstack/internal/deliverycontrol/registry_test.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package deliverycontrol
-
-import "testing"
-
-// control-law: deliverycontrol-registry-well-formed
-// The single declaration must be internally consistent: unique ids, declared
-// states on every edge, valid kinds/cost classes, and a defined cost weight for
-// every class a transition uses.
-func TestRegistryWellFormed(t *testing.T) {
- states := map[StateID]bool{}
- for _, s := range States() {
- states[s] = true
- }
- kinds := map[TransitionKind]bool{}
- for _, k := range AllKinds() {
- kinds[k] = true
- }
- classes := map[TransitionCostClass]bool{}
- for _, c := range AllCostClasses() {
- classes[c] = true
- }
- weights := DefaultFlowCostWeights()
-
- seen := map[TransitionID]bool{}
- for _, tr := range Transitions() {
- if tr.ID == "" {
- t.Errorf("transition with empty ID: %+v", tr)
- }
- if seen[tr.ID] {
- t.Errorf("duplicate transition ID %q", tr.ID)
- }
- seen[tr.ID] = true
- if !kinds[tr.Kind] {
- t.Errorf("%s: undeclared kind %q", tr.ID, tr.Kind)
- }
- if !classes[tr.CostClass] {
- t.Errorf("%s: undeclared cost class %q", tr.ID, tr.CostClass)
- }
- if _, ok := weights.Cost(tr.CostClass); !ok {
- t.Errorf("%s: cost class %q has no weight", tr.ID, tr.CostClass)
- }
- for _, from := range tr.From {
- if !states[from] {
- t.Errorf("%s: undeclared From state %q", tr.ID, from)
- }
- }
- if tr.To != "" && !states[tr.To] {
- t.Errorf("%s: undeclared To state %q", tr.ID, tr.To)
- }
- if tr.HandlerRef == "" {
- t.Errorf("%s: empty HandlerRef", tr.ID)
- }
- }
- if len(seen) == 0 {
- t.Fatal("registry is empty")
- }
-}
-
-// The cmg model only makes friction expensive; if friction ever costs no more
-// than a move, regret vanishes and the whole model is meaningless.
-func TestFrictionCostsMoreThanAMove(t *testing.T) {
- w := DefaultFlowCostWeights()
- move, ok := w.Cost(CostObserve)
- if !ok {
- t.Fatal("observe cost undefined")
- }
- friction, ok := w.Cost(CostFriction)
- if !ok {
- t.Fatal("friction cost undefined")
- }
- if friction <= move {
- t.Errorf("friction (%d) must cost more than a move (%d)", friction, move)
- }
-}
-
-func TestSliceStatusStatesAreDeclared(t *testing.T) {
- declared := map[StateID]bool{}
- for _, s := range States() {
- declared[s] = true
- }
- for _, s := range SliceStatusStates() {
- if !declared[s] {
- t.Errorf("slice-status state %q is not in States()", s)
- }
- }
-}
diff --git a/boatstack/internal/deliverycontrol/state.go b/boatstack/internal/deliverycontrol/state.go
deleted file mode 100644
index ee3d85a..0000000
--- a/boatstack/internal/deliverycontrol/state.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package deliverycontrol
-
-const (
- // Slice-status states — these string values mirror DeliverySlice.Status
- // literals in the real state machine (delivery.go, next.go). Kept in sync by
- // the parity conformance test (control-law: registry-mirrors-real-transitions).
- StatePending StateID = "PENDING"
- StateBuild StateID = "BUILD"
- StateTestPassed StateID = "TEST_PASSED"
- StateReviewPassed StateID = "REVIEW_PASSED"
- StatePublished StateID = "PUBLISHED"
-
- // Composite exceptional states. These are real durable delivery positions,
- // not presentation aliases: DeliveryState.Mode removes the ordinary gate
- // actuators even while the active slice still says BUILD. Keeping them out of
- // this vocabulary made the old liveness proof project a deadlocked delivery
- // back to BUILD and therefore prove the wrong machine.
- StateAmendmentRequired StateID = "AMENDMENT_REQUIRED"
- StateAmendmentDrafted StateID = "AMENDMENT_DRAFTED"
- StateAmendmentApproved StateID = "AMENDMENT_APPROVED"
- StatePlanInvalid StateID = "PLAN_INVALID"
-
- // Boundary states — needed to describe transitions faithfully; not stored as
- // a slice Status.
- StateUninitialized StateID = "UNINITIALIZED" // no managed delivery yet
- StateFeatureComplete StateID = "FEATURE_COMPLETE" // published + merged (ActiveIndex >= len(Slices))
- StateInvalid StateID = "INVALID" // malformed / blocked delivery
- StateDiscarded StateID = "DISCARDED" // archived via discard-delivery
-
- // StateUnresolved is the sentinel an oracle returns for unknown/invalid
- // state. A controller must never fabricate a path; it returns UNRESOLVED.
- StateUnresolved StateID = "UNRESOLVED"
-)
-
-// SliceStatusStates returns the states that mirror real DeliverySlice.Status
-// literals, in lifecycle order. The parity test pins this set to the strings the
-// real state machine actually uses.
-func SliceStatusStates() []StateID {
- return []StateID{StatePending, StateBuild, StateTestPassed, StateReviewPassed, StatePublished}
-}
-
-// States returns every declared state.
-func States() []StateID {
- return []StateID{
- StatePending, StateBuild, StateTestPassed, StateReviewPassed, StatePublished,
- StateAmendmentRequired, StateAmendmentDrafted, StateAmendmentApproved, StatePlanInvalid,
- StateUninitialized, StateFeatureComplete, StateInvalid, StateDiscarded, StateUnresolved,
- }
-}
diff --git a/boatstack/internal/deliverycontrol/trajectory.go b/boatstack/internal/deliverycontrol/trajectory.go
deleted file mode 100644
index efae1d2..0000000
--- a/boatstack/internal/deliverycontrol/trajectory.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package deliverycontrol
-
-// Outcome is what happened when an agent attempted a control.
-type Outcome string
-
-const (
- // OutcomeAllowed: the control was accepted and advanced (or observed) state.
- OutcomeAllowed Outcome = "allowed"
- // OutcomeDenied: the control was refused — a blocked committed mutation is the
- // friction the model prices at 3 (a burned turn that returns nothing).
- OutcomeDenied Outcome = "denied"
-)
-
-// TransitionAttempt is one recorded move on the delivery-flow graph: the control
-// an agent tried, from which state, and how it landed. Trajectories are built
-// from these, append-only.
-type TransitionAttempt struct {
- Sequence int `json:"sequence"`
- From StateID `json:"from"`
- Transition TransitionID `json:"transition"`
- Goal StateID `json:"goal,omitempty"`
- Outcome Outcome `json:"outcome"`
- CostClass TransitionCostClass `json:"cost_class"`
- Note string `json:"note,omitempty"`
- Category string `json:"category,omitempty"`
-}
-
-// Trajectory is an ordered walk of attempts — one real (or replayed) session.
-type Trajectory []TransitionAttempt
-
-// ChargedCostClass returns the cost class an attempt is billed at. A denied
-// mutation (committed or reversible) is friction; every other outcome keeps the
-// transition's declared class. Read-only controls are never denied in the model,
-// so they always bill their own class. Centralizing the rule here keeps the
-// recorder and the report in agreement.
-func ChargedCostClass(kind TransitionKind, declared TransitionCostClass, outcome Outcome) TransitionCostClass {
- if outcome == OutcomeDenied {
- switch kind {
- case KindCommittedMutation, KindReversibleMutation:
- return CostFriction
- }
- }
- return declared
-}
-
-// FlowTrajectoryReport is the meter: the observed navigation cost of a walk,
-// the oracle's cost for the same start→goal, and the regret between them. When
-// the oracle cannot resolve the endpoints, Resolution is Unresolved and Regret
-// is left at zero (there is no baseline to regret against — never a fabricated
-// one).
-type FlowTrajectoryReport struct {
- Start StateID `json:"start"`
- Goal StateID `json:"goal"`
- JFlow int `json:"j_flow"`
- JFlowStar int `json:"j_flow_star"`
- Regret int `json:"regret"`
- // JCoding is coding effort measured as telemetry and reported ALONGSIDE J_flow.
- // It is never summed into J_flow and never enters Regret — the decomposition
- // J = J_flow + J_coding keeps the two costs separate by construction.
- JCoding int `json:"j_coding"`
- Steps int `json:"steps"`
- Resolution Resolution `json:"resolution"`
- PositiveGapByCategory map[string]int `json:"positive_gap_by_category,omitempty"`
- Feature string `json:"feature,omitempty"`
- CommandCoverageStatus string `json:"command_coverage_status"`
- CommandEvents int `json:"command_events"`
- CommandFailures int `json:"command_failures"`
- ObservedCommandMS int64 `json:"observed_command_ms"`
- CommandWallSpanMS int64 `json:"command_wall_span_ms"`
- FirstCommandAt string `json:"first_command_at,omitempty"`
- LastCommandAt string `json:"last_command_at,omitempty"`
- CommandFailureByCategory map[string]int `json:"command_failure_by_category,omitempty"`
-}
-
-// WalkCost sums a trajectory's observed J_flow: each attempt billed at its
-// charged cost class. Attempts whose class has no weight are skipped rather than
-// silently counted as zero-defined.
-func (t Trajectory) WalkCost(weights FlowCostWeights) int {
- total := 0
- for _, attempt := range t {
- if cost, ok := weights.Cost(attempt.CostClass); ok {
- total += cost
- }
- }
- return total
-}
-
-// ComputeReport measures a trajectory against the oracle. Start is the walk's
-// first From; the caller supplies the goal (the accepted end state B). J_flow is
-// the observed walk cost; J_flow* is the oracle's shortest cost start→goal over
-// the graph; Regret = J_flow − J_flow* when the oracle resolves.
-func ComputeReport(t Trajectory, g *Graph, weights FlowCostWeights, goal StateID) FlowTrajectoryReport {
- report := FlowTrajectoryReport{
- Goal: goal,
- JFlow: t.WalkCost(weights),
- Steps: len(t),
- Resolution: Unresolved,
- }
- if len(t) > 0 {
- report.Start = t[0].From
- }
- oracle := g.ShortestFlow(report.Start, goal)
- report.Resolution = oracle.Resolution
- if oracle.Resolution == Resolved {
- report.JFlowStar = oracle.Cost
- report.Regret = report.JFlow - oracle.Cost
- if report.Regret > 0 {
- report.PositiveGapByCategory = map[string]int{}
- for _, attempt := range t {
- if attempt.Category == "" {
- continue
- }
- if cost, ok := weights.Cost(attempt.CostClass); ok {
- report.PositiveGapByCategory[attempt.Category] += cost
- }
- }
- }
- }
- return report
-}
-
-// ComputeReportWithCoding measures flow regret exactly as ComputeReport and then
-// attaches coding effort as a SEPARATE figure. J_coding is summed from telemetry
-// signals — never from the graph — and is never folded into J_flow or Regret, so
-// optimizing flow can never be confused with reducing coding effort.
-func ComputeReportWithCoding(t Trajectory, g *Graph, weights FlowCostWeights, goal StateID, signals []CodingSignal) FlowTrajectoryReport {
- report := ComputeReport(t, g, weights, goal)
- report.JCoding = TallyCoding(signals).JCoding
- return report
-}
diff --git a/boatstack/internal/deliverycontrol/trajectory_test.go b/boatstack/internal/deliverycontrol/trajectory_test.go
deleted file mode 100644
index da2b35c..0000000
--- a/boatstack/internal/deliverycontrol/trajectory_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package deliverycontrol
-
-import "testing"
-
-// Incident-graph node names, from the cmg design note
-// (../../notes/delivery-flow-navigation-model.md). The published-slice incident
-// is modeled there as a small costed graph; this test rebuilds that graph and
-// walk and runs the real Go oracle over it, pinning the note's SELF-CHECK
-// numbers (J_flow=15, J_flow*=5, regret=10) to this implementation.
-const (
- sNeedsFix StateID = "NEEDS_FIX"
- sObserved StateID = "OBSERVED"
- sPushDenied StateID = "PUSH_DENIED"
- sUndoBlocked StateID = "UNDO_BLOCKED"
- sRepair StateID = "REPAIR"
- sAsk StateID = "ASK"
- sDiagnosed StateID = "DIAGNOSED"
- sUpgraded StateID = "UPGRADED"
- sRegated StateID = "REGATED"
- sLanded StateID = "LANDED"
-)
-
-func incidentGraph() *Graph {
- g := NewGraph(DefaultFlowCostWeights())
- // The unique low-cost exit of the start state: observe before acting.
- g.AddEdge(sNeedsFix, sObserved, "observe-state", CostObserve)
- // The friction region — reachable only by acting before observing.
- g.AddEdge(sNeedsFix, sPushDenied, "git-push", CostFriction)
- g.AddEdge(sPushDenied, sUndoBlocked, "undo", CostFriction)
- g.AddEdge(sUndoBlocked, sRepair, "undo-mutation", CostFriction)
- g.AddEdge(sRepair, sAsk, "repair-state", CostRecovery)
- g.AddEdge(sAsk, sObserved, "ask-then-observe", CostObserve)
- // The shared tail every path takes once oriented.
- g.AddEdge(sObserved, sDiagnosed, "diagnose", CostInspect)
- g.AddEdge(sDiagnosed, sUpgraded, "upgrade-helper", CostInspect)
- g.AddEdge(sUpgraded, sRegated, "re-gate", CostMutation)
- g.AddEdge(sRegated, sLanded, "publish-update", CostMutation)
- return g
-}
-
-// observedIncidentWalk is the real session from the note: the agent acts before
-// it observes, hits friction on three committed/reversible mutations, then
-// orients. Friction is derived by ChargedCostClass from the denied outcome — not
-// hardcoded — so the model's "denied mutation = friction" rule is what produces
-// the cost.
-func observedIncidentWalk() Trajectory {
- deny := func(from StateID, id TransitionID, kind TransitionKind) TransitionAttempt {
- return TransitionAttempt{
- From: from, Transition: id, Goal: sLanded, Outcome: OutcomeDenied,
- CostClass: ChargedCostClass(kind, CostMutation, OutcomeDenied),
- }
- }
- allow := func(from StateID, id TransitionID, class TransitionCostClass) TransitionAttempt {
- return TransitionAttempt{
- From: from, Transition: id, Goal: sLanded, Outcome: OutcomeAllowed,
- CostClass: ChargedCostClass(KindObserve, class, OutcomeAllowed),
- }
- }
- return Trajectory{
- deny(sNeedsFix, "git-push", KindCommittedMutation),
- deny(sPushDenied, "undo", KindReversibleMutation),
- deny(sUndoBlocked, "undo-mutation", KindCommittedMutation),
- allow(sRepair, "repair-state", CostRecovery),
- allow(sAsk, "ask-then-observe", CostObserve),
- allow(sObserved, "diagnose", CostInspect),
- allow(sDiagnosed, "upgrade-helper", CostInspect),
- allow(sUpgraded, "re-gate", CostMutation),
- allow(sRegated, "publish-update", CostMutation),
- }
-}
-
-// control-law: flow-regret-matches-cmg-note
-func TestPublishedSliceIncidentRegret(t *testing.T) {
- g := incidentGraph()
- weights := DefaultFlowCostWeights()
-
- // The oracle path is Dijkstra over the owned graph, not learned: observe first.
- oracle := g.ShortestFlow(sNeedsFix, sLanded)
- if oracle.Resolution != Resolved {
- t.Fatalf("oracle could not resolve the incident: %s", oracle.Resolution)
- }
- if oracle.Cost != 5 {
- t.Errorf("J_flow* = %d, want 5 (oracle path: %v)", oracle.Cost, edgeIDs(oracle.Edges))
- }
- if got := edgeIDs(oracle.Edges); len(got) != 5 || got[0] != "observe-state" {
- t.Errorf("oracle should exit via observe-state; got %v", got)
- }
-
- report := ComputeReport(observedIncidentWalk(), g, weights, sLanded)
- if report.Resolution != Resolved {
- t.Fatalf("report unresolved")
- }
- if report.JFlow != 15 {
- t.Errorf("J_flow = %d, want 15", report.JFlow)
- }
- if report.JFlowStar != 5 {
- t.Errorf("J_flow* = %d, want 5", report.JFlowStar)
- }
- if report.Regret != 10 {
- t.Errorf("regret = %d, want 10 (all of it in J_flow)", report.Regret)
- }
-}
-
-func TestChargedCostClass(t *testing.T) {
- cases := []struct {
- kind TransitionKind
- declared TransitionCostClass
- outcome Outcome
- want TransitionCostClass
- }{
- {KindCommittedMutation, CostMutation, OutcomeDenied, CostFriction},
- {KindReversibleMutation, CostMutation, OutcomeDenied, CostFriction},
- {KindCommittedMutation, CostMutation, OutcomeAllowed, CostMutation},
- {KindObserve, CostObserve, OutcomeDenied, CostObserve}, // reads are never friction
- {KindRecovery, CostRecovery, OutcomeDenied, CostRecovery}, // recovery denial is not modeled as friction
- }
- for _, tc := range cases {
- if got := ChargedCostClass(tc.kind, tc.declared, tc.outcome); got != tc.want {
- t.Errorf("ChargedCostClass(%s,%s,%s) = %s, want %s", tc.kind, tc.declared, tc.outcome, got, tc.want)
- }
- }
-}
-
-// A trajectory whose goal the oracle cannot resolve reports Unresolved and no
-// regret — never a fabricated baseline.
-func TestComputeReportUnresolvedGoal(t *testing.T) {
- g := RegistryGraph(DefaultFlowCostWeights())
- traj := Trajectory{{From: StatePending, Transition: "delivery.discard_delivery", Outcome: OutcomeAllowed, CostClass: CostMutation}}
- report := ComputeReport(traj, g, DefaultFlowCostWeights(), StatePublished)
- if report.Resolution != Unresolved {
- t.Errorf("resolution = %s, want Unresolved", report.Resolution)
- }
- if report.Regret != 0 || report.JFlowStar != 0 {
- t.Errorf("unresolved report must not fabricate a baseline: %+v", report)
- }
- if report.JFlow != 1 {
- t.Errorf("observed J_flow should still be measured: got %d, want 1", report.JFlow)
- }
-}
diff --git a/boatstack/internal/deliverycontrol/trajectorylog.go b/boatstack/internal/deliverycontrol/trajectorylog.go
deleted file mode 100644
index 918030d..0000000
--- a/boatstack/internal/deliverycontrol/trajectorylog.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package deliverycontrol
-
-import (
- "bufio"
- "encoding/json"
- "errors"
- "io/fs"
- "os"
- "path/filepath"
-)
-
-// trajectoryLogFile is the append-only record of attempts within a flow-log
-// directory. One JSON object per line (JSONL), oldest first.
-const trajectoryLogFile = "trajectory.jsonl"
-
-// AppendAttempt appends one attempt to the trajectory log under dir, creating
-// the directory and file as needed. It is the write half of the shadow trace;
-// it returns an error so tests can assert round-trips, but the live recorder
-// treats every error as best-effort and swallows it — a trace must never change
-// command behavior.
-func AppendAttempt(dir string, attempt TransitionAttempt) error {
- if dir == "" {
- return errors.New("trajectory log directory is empty")
- }
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
- line, err := json.Marshal(attempt)
- if err != nil {
- return err
- }
- file, err := os.OpenFile(filepath.Join(dir, trajectoryLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
- if err != nil {
- return err
- }
- defer file.Close()
- if _, err := file.Write(append(line, '\n')); err != nil {
- return err
- }
- return nil
-}
-
-// ReadTrajectory reads the append-only log under dir in write order. A missing
-// log is an empty trajectory, not an error, so a first read before any write is
-// well-defined.
-func ReadTrajectory(dir string) (Trajectory, error) {
- file, err := os.Open(filepath.Join(dir, trajectoryLogFile))
- if err != nil {
- if errors.Is(err, fs.ErrNotExist) {
- return Trajectory{}, nil
- }
- return nil, err
- }
- defer file.Close()
-
- var trajectory Trajectory
- scanner := bufio.NewScanner(file)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
- for scanner.Scan() {
- line := scanner.Bytes()
- if len(line) == 0 {
- continue
- }
- var attempt TransitionAttempt
- if err := json.Unmarshal(line, &attempt); err != nil {
- return nil, err
- }
- trajectory = append(trajectory, attempt)
- }
- if err := scanner.Err(); err != nil {
- return nil, err
- }
- return trajectory, nil
-}
diff --git a/boatstack/internal/deliverycontrol/trajectorylog_test.go b/boatstack/internal/deliverycontrol/trajectorylog_test.go
deleted file mode 100644
index 512ab10..0000000
--- a/boatstack/internal/deliverycontrol/trajectorylog_test.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package deliverycontrol
-
-import (
- "path/filepath"
- "testing"
-)
-
-func TestTrajectoryLogRoundTrip(t *testing.T) {
- dir := filepath.Join(t.TempDir(), "flow")
-
- // A first read before any write is an empty trajectory, not an error.
- if traj, err := ReadTrajectory(dir); err != nil || len(traj) != 0 {
- t.Fatalf("empty read: got %v (%d), err %v", traj, len(traj), err)
- }
-
- want := Trajectory{
- {Sequence: 0, From: StateBuild, Transition: "delivery.record_gate_test", Outcome: OutcomeAllowed, CostClass: CostMutation},
- {Sequence: 1, From: StateBuild, Transition: "delivery.undo", Outcome: OutcomeDenied, CostClass: CostFriction, Note: "gate receipt exists"},
- }
- for _, attempt := range want {
- if err := AppendAttempt(dir, attempt); err != nil {
- t.Fatalf("append %s: %v", attempt.Transition, err)
- }
- }
-
- got, err := ReadTrajectory(dir)
- if err != nil {
- t.Fatalf("read: %v", err)
- }
- if len(got) != len(want) {
- t.Fatalf("read %d attempts, want %d", len(got), len(want))
- }
- for i := range want {
- if got[i] != want[i] {
- t.Errorf("attempt %d: got %+v, want %+v", i, got[i], want[i])
- }
- }
-
- // Order is preserved and the walk cost reflects the friction on the denial.
- if cost := got.WalkCost(DefaultFlowCostWeights()); cost != 4 {
- t.Errorf("walk cost = %d, want 4 (1 move + 3 friction)", cost)
- }
-}
-
-func TestAppendAttemptRejectsEmptyDir(t *testing.T) {
- if err := AppendAttempt("", TransitionAttempt{Transition: "x"}); err == nil {
- t.Error("expected an error for an empty directory")
- }
-}
diff --git a/boatstack/internal/deliverycontrol/transition.go b/boatstack/internal/deliverycontrol/transition.go
deleted file mode 100644
index 27265eb..0000000
--- a/boatstack/internal/deliverycontrol/transition.go
+++ /dev/null
@@ -1,84 +0,0 @@
-// Package deliverycontrol is the shadow declaration of Boatstack's delivery
-// state machine: the states an owned delivery moves through and the transitions
-// (the moves an agent makes) between them.
-//
-// It exists to make J_flow — the cost of navigating the deterministic delivery
-// workflow the tool already owns — computable, following the split
-// J = J_flow + J_coding established in
-// ../../notes/delivery-flow-navigation-model.md. This is the "one declaration":
-// a single, source-cited registry that later projects two ways that can never
-// disagree — a conformance view (every transition names a real handler, every
-// state mirrors a real literal) and an optimization view (a weighted graph a
-// deterministic oracle scores).
-//
-// This package is shadow-only. Nothing imports it at runtime; it changes no
-// command, gate, authority, verification, evidence, or recovery behavior, and
-// the mutation boundary (mutation.go) is preserved unchanged. Later, opt-in
-// phases add tracing, an oracle, and an advisory controller on top of this
-// declaration.
-package deliverycontrol
-
-// StateID is a delivery-flow state. Slice-status states (see SliceStatusStates)
-// mirror the string literals stored in DeliverySlice.Status by the real state
-// machine; the remaining states name boundary conditions the registry needs to
-// describe transitions faithfully (uninitialized, feature-complete, invalid,
-// discarded), plus the UNRESOLVED sentinel an oracle must return for unknown
-// state rather than fabricate a path.
-type StateID string
-
-// TransitionID is the stable, semantic identifier of a delivery transition,
-// independent of the CLI verb or Go handler that currently implements it.
-type TransitionID string
-
-// TransitionKind classifies what a transition does to delivery state.
-type TransitionKind string
-
-const (
- KindObserve TransitionKind = "observe" // read-only; no state change
- KindInspect TransitionKind = "inspect" // read-only projection/derivation
- KindQuery TransitionKind = "query" // read-only decision (may hit the network)
- KindReversibleMutation TransitionKind = "reversible_mutation" // committed but closed under inversion / archived, not destroyed
- KindCommittedMutation TransitionKind = "committed_mutation" // advances delivery state
- KindRecovery TransitionKind = "recovery" // typed, bounded correction path
-)
-
-// TransitionCostClass names the cost bucket a transition draws from. Weights
-// live in FlowCostWeights; the cmg model
-// (../../notes/delivery-flow-navigation-model.md) prices a normal
-// move/observe/inspect at 1 and a denied/blocked committed mutation (friction)
-// at 3.
-type TransitionCostClass string
-
-const (
- CostObserve TransitionCostClass = "observe"
- CostInspect TransitionCostClass = "inspect"
- CostQuery TransitionCostClass = "query"
- CostMutation TransitionCostClass = "mutation"
- CostRecovery TransitionCostClass = "recovery"
- CostFriction TransitionCostClass = "friction"
-)
-
-// TransitionDescriptor is one row of the delivery-flow inventory
-// (../../notes/delivery-control-inventory.md), declared once here.
-type TransitionDescriptor struct {
- ID TransitionID
- From []StateID // source states; empty means "from an uninitialized/any delivery" (see Note)
- To StateID // resulting delivery-flow state; "" when the transition changes no slice status
- Kind TransitionKind
- CostClass TransitionCostClass
- Reversible bool
- HandlerRef string // name of the real exported boatstack function that performs it
- CLIVerb string // the boatstack-helper subcommand that invokes it ("" if none)
- Note string // guard / authority / recovery summary, from the inventory
-}
-
-// AllKinds enumerates the valid transition kinds so conformance checks can
-// reject a descriptor that names an undeclared kind.
-func AllKinds() []TransitionKind {
- return []TransitionKind{KindObserve, KindInspect, KindQuery, KindReversibleMutation, KindCommittedMutation, KindRecovery}
-}
-
-// AllCostClasses enumerates the valid cost classes for the same reason.
-func AllCostClasses() []TransitionCostClass {
- return []TransitionCostClass{CostObserve, CostInspect, CostQuery, CostMutation, CostRecovery, CostFriction}
-}
diff --git a/boatstack/internal/effects/artifacts.go b/boatstack/internal/effects/artifacts.go
new file mode 100644
index 0000000..21bb40b
--- /dev/null
+++ b/boatstack/internal/effects/artifacts.go
@@ -0,0 +1,364 @@
+package effects
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+func prepareAttachBinding(layout ports.ControllerLayout, admission protocol.Admission, now time.Time) (ports.ResourceMutation, error) {
+ topologyValue, _ := admission.Parameters.Get("topology")
+ configAuthority, _ := admission.Parameters.Get("config_authority")
+ topology := model.Topology(topologyValue)
+ if topology != model.TopologyDetached && topology != model.TopologyHybrid {
+ return ports.ResourceMutation{}, fmt.Errorf("repository.attach topology must be detached or hybrid")
+ }
+ controllerID := sha256Bytes([]byte("v2:" + admission.Invocation.RepositoryID + ":" + admission.Invocation.GitCommonID + ":" + topologyValue + ":" + configAuthority))[:20]
+ binding := durable.Binding{
+ SchemaVersion: durable.BindingSchemaVersion, RepositoryID: admission.Invocation.RepositoryID, GitCommonID: admission.Invocation.GitCommonID,
+ Topology: topology, ControllerID: controllerID, ConfigAuthority: configAuthority, CreatedAt: admission.IssuedAt.UTC(),
+ }
+ raw, err := durable.EncodeBinding(binding)
+ if err != nil {
+ return ports.ResourceMutation{}, err
+ }
+ return mutationFor(layout.BindingPath, raw, 0o600, true, false)
+}
+
+type approvalArtifact struct {
+ SchemaVersion int `json:"schema_version"`
+ DeliveryID string `json:"delivery_id"`
+ PlanFingerprint string `json:"plan_fingerprint"`
+ Actor string `json:"actor"`
+ AdmissionID string `json:"admission_id"`
+ ApprovedAt time.Time `json:"approved_at"`
+}
+
+type gateArtifact struct {
+ SchemaVersion int `json:"schema_version"`
+ DeliveryID string `json:"delivery_id"`
+ TransitionID catalog.TransitionID `json:"transition_id"`
+ Revision string `json:"revision"`
+ Fingerprint string `json:"fingerprint"`
+ AdmissionID string `json:"admission_id"`
+ RecordedAt time.Time `json:"recorded_at"`
+}
+
+type gateEvidenceInput struct {
+ SchemaVersion int `json:"schema_version"`
+ Gate string `json:"gate"`
+ SourceRevision string `json:"source_revision"`
+ Outcome string `json:"outcome"`
+ Producer string `json:"producer"`
+ CompletedAt time.Time `json:"completed_at"`
+}
+
+func decodeStrictArtifact(raw []byte, value any) error {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(value); err != nil {
+ return err
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return fmt.Errorf("artifact contains trailing JSON")
+ }
+ return nil
+}
+
+type publicationPreview struct {
+ SchemaVersion int `json:"schema_version"`
+ DeliveryID string `json:"delivery_id"`
+ BaseRef string `json:"base_ref"`
+ HeadRef string `json:"head_ref"`
+ BodyPath string `json:"body_path"`
+ BodySHA256 string `json:"body_sha256"`
+ Fingerprint string `json:"fingerprint"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition, state *durable.State) ([]ports.ResourceMutation, error) {
+ var mutations []ports.ResourceMutation
+ deliveryID, err := safeSegment(admission.Goal.DeliveryID, "delivery identity")
+ if err != nil {
+ return nil, err
+ }
+ artifactRoot := filepath.Join(layout.RepositoryRoot, ".boatstack")
+ switch transition.ID {
+ case "configuration.initialize", "configuration.mutate", "installation.initialize":
+ source, _ := admission.Parameters.Get("config_path")
+ expected, _ := admission.Parameters.Get("config_sha256")
+ raw, readErr := os.ReadFile(source)
+ if readErr != nil {
+ return nil, fmt.Errorf("read configuration source: %w", readErr)
+ }
+ config, actual, decodeErr := protocol.ProjectConfigFingerprint(raw)
+ if decodeErr != nil {
+ return nil, decodeErr
+ }
+ if actual != expected {
+ return nil, fmt.Errorf("configuration fingerprint mismatch: got %s", actual)
+ }
+ mutation, mutationErr := mutationFor(layout.ConfigPath, raw, 0o644, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ state.ConfigFingerprint = expected
+ policy := config.ControlPolicy()
+ state.PlanApprovalPolicy = policy.PlanApproval
+ state.VisualEvidencePolicy = policy.VisualEvidence
+ state.ExternalEffectPolicy = policy.ExternalEffectAuthority
+ state.IndependentReview = policy.IndependentReviewForHighRisk
+ state.EnabledHosts = append([]string(nil), policy.Hosts...)
+ case "plan.create", "plan.amend":
+ source, _ := admission.Parameters.Get("source_path")
+ raw, readErr := os.ReadFile(source)
+ if readErr != nil {
+ return nil, fmt.Errorf("read source plan: %w", readErr)
+ }
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("source plan is empty")
+ }
+ fingerprint := sha256Bytes(raw)
+ path := filepath.Join(artifactRoot, "plans", deliveryID+".source")
+ mutation, mutationErr := mutationFor(path, raw, 0o644, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ approvalMutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), nil, 0o644, false, true)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, approvalMutation)
+ state.PlanFingerprint = fingerprint
+ case "plan.validate":
+ path := filepath.Join(artifactRoot, "plans", deliveryID+".source")
+ raw, readErr := os.ReadFile(path)
+ if readErr != nil || len(raw) == 0 {
+ return nil, fmt.Errorf("validate source plan %s: %w", path, readErr)
+ }
+ state.PlanFingerprint = sha256Bytes(raw)
+ case "plan.approve", "plan.approve-amendment":
+ fingerprint, _ := admission.Parameters.Get("plan_fingerprint")
+ actor, _ := admission.Parameters.Get("actor")
+ if state.PlanFingerprint == "" || fingerprint != state.PlanFingerprint {
+ return nil, fmt.Errorf("approval plan fingerprint is stale")
+ }
+ artifact := approvalArtifact{SchemaVersion: 1, DeliveryID: deliveryID, PlanFingerprint: fingerprint, Actor: actor, AdmissionID: admission.ID, ApprovedAt: admission.IssuedAt.UTC()}
+ raw, encodeErr := encodeJSON(artifact)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ mutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), raw, 0o644, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ case "evidence.approval.revoke":
+ mutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "approvals", deliveryID+".json"), nil, 0o644, false, true)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record":
+ revision, _ := admission.Parameters.Get("source_revision")
+ evidencePath, _ := admission.Parameters.Get("evidence_path")
+ fingerprint, _ := admission.Parameters.Get("evidence_fingerprint")
+ evidenceRaw, readErr := os.ReadFile(evidencePath)
+ if readErr != nil {
+ return nil, fmt.Errorf("read gate evidence: %w", readErr)
+ }
+ if actual := sha256Bytes(evidenceRaw); actual != fingerprint {
+ return nil, fmt.Errorf("gate evidence fingerprint mismatch: got %s", actual)
+ }
+ gate, _ := catalog.GateName(transition.ID)
+ var input gateEvidenceInput
+ if decodeErr := decodeStrictArtifact(evidenceRaw, &input); decodeErr != nil || input.SchemaVersion != 1 ||
+ input.Gate != gate || input.SourceRevision != revision || input.Outcome != "passed" || input.Producer == "" || input.CompletedAt.IsZero() {
+ return nil, fmt.Errorf("gate evidence must be a strict passed schema-1 %s receipt for revision %s", gate, revision)
+ }
+ artifact := gateArtifact{SchemaVersion: 1, DeliveryID: deliveryID, TransitionID: transition.ID, Revision: revision, Fingerprint: fingerprint, AdmissionID: admission.ID, RecordedAt: admission.IssuedAt.UTC()}
+ raw, encodeErr := encodeJSON(artifact)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ payloadMutation, mutationErr := mutationFor(filepath.Join(layout.EvidenceRoot, deliveryID, gate+".evidence.json"), evidenceRaw, 0o644, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, payloadMutation)
+ mutation, mutationErr := mutationFor(filepath.Join(layout.EvidenceRoot, deliveryID, gate+".json"), raw, 0o644, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ case "evidence.visual.attach":
+ manifest, _ := admission.Parameters.Get("manifest_path")
+ privacyReceipt, _ := admission.Parameters.Get("privacy_receipt")
+ raw, readErr := os.ReadFile(manifest)
+ if readErr != nil {
+ return nil, readErr
+ }
+ if sha256Bytes(raw) != privacyReceipt {
+ return nil, fmt.Errorf("visual evidence privacy receipt does not bind manifest bytes")
+ }
+ mutation, mutationErr := mutationFor(filepath.Join(layout.EvidenceRoot, deliveryID, "visual-manifest.json"), raw, 0o644, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ case "publication.preview":
+ baseRef, _ := admission.Parameters.Get("base_ref")
+ headRef, _ := admission.Parameters.Get("head_ref")
+ bodyPath, _ := admission.Parameters.Get("body_path")
+ if err := protocol.ValidateGitReference(baseRef); err != nil {
+ return nil, err
+ }
+ if err := protocol.ValidateGitBranch(headRef); err != nil {
+ return nil, err
+ }
+ if admission.Invocation.Ref != "refs/heads/"+headRef {
+ return nil, fmt.Errorf("publication head does not match the exact invoking branch")
+ }
+ configRaw, readConfigErr := os.ReadFile(layout.ConfigPath)
+ if readConfigErr != nil {
+ return nil, readConfigErr
+ }
+ config, decodeConfigErr := protocol.DecodeProjectConfig(configRaw)
+ if decodeConfigErr != nil {
+ return nil, decodeConfigErr
+ }
+ if baseRef != config.Project.DefaultBranch {
+ return nil, fmt.Errorf("publication base %q does not match configured default branch %q", baseRef, config.Project.DefaultBranch)
+ }
+ body, readErr := os.ReadFile(bodyPath)
+ if readErr != nil {
+ return nil, readErr
+ }
+ preview := publicationPreview{SchemaVersion: 1, DeliveryID: deliveryID, BaseRef: baseRef, HeadRef: headRef, BodyPath: bodyPath, BodySHA256: sha256Bytes(body), CreatedAt: admission.IssuedAt.UTC()}
+ identity := preview
+ identity.Fingerprint, identity.CreatedAt = "", time.Time{}
+ identityRaw, encodeErr := json.Marshal(identity)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ preview.Fingerprint = sha256Bytes(identityRaw)
+ raw, encodeErr := encodeJSON(preview)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ mutation, mutationErr := mutationFor(filepath.Join(artifactRoot, "publication", deliveryID+".preview.json"), raw, 0o644, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ state.PreviewFingerprint = preview.Fingerprint
+ case "publication.execute":
+ preview, readErr := loadPublicationPreview(filepath.Join(artifactRoot, "publication", deliveryID+".preview.json"))
+ if readErr != nil {
+ return nil, readErr
+ }
+ if err := validatePublicationPreviewForAdmission(layout, admission, preview); err != nil {
+ return nil, err
+ }
+ if preview.Fingerprint != state.PreviewFingerprint {
+ return nil, fmt.Errorf("publication preview fingerprint is stale")
+ }
+ case "publication.correct":
+ if err := validateCorrectionBody(admission); err != nil {
+ return nil, err
+ }
+ }
+ return mutations, nil
+}
+
+func loadPublicationPreview(path string) (publicationPreview, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return publicationPreview{}, err
+ }
+ var preview publicationPreview
+ if err := decodeStrictArtifact(raw, &preview); err != nil {
+ return publicationPreview{}, err
+ }
+ if preview.SchemaVersion != 1 || preview.DeliveryID == "" || preview.BaseRef == "" || preview.HeadRef == "" || preview.BodyPath == "" || preview.BodySHA256 == "" || preview.Fingerprint == "" || preview.CreatedAt.IsZero() {
+ return publicationPreview{}, fmt.Errorf("invalid publication preview")
+ }
+ if err := protocol.ValidateGitReference(preview.BaseRef); err != nil {
+ return publicationPreview{}, err
+ }
+ if err := protocol.ValidateGitBranch(preview.HeadRef); err != nil {
+ return publicationPreview{}, err
+ }
+ if !filepath.IsAbs(preview.BodyPath) {
+ return publicationPreview{}, fmt.Errorf("publication body path must be absolute")
+ }
+ body, err := os.ReadFile(preview.BodyPath)
+ if err != nil {
+ return publicationPreview{}, err
+ }
+ if sha256Bytes(body) != preview.BodySHA256 {
+ return publicationPreview{}, fmt.Errorf("publication body changed after preview")
+ }
+ identity := preview
+ identity.Fingerprint, identity.CreatedAt = "", time.Time{}
+ identityRaw, err := json.Marshal(identity)
+ if err != nil {
+ return publicationPreview{}, err
+ }
+ if sha256Bytes(identityRaw) != preview.Fingerprint {
+ return publicationPreview{}, fmt.Errorf("publication preview failed content identity verification")
+ }
+ return preview, nil
+}
+
+func validatePublicationPreviewForAdmission(layout ports.ControllerLayout, admission protocol.Admission, preview publicationPreview) error {
+ deliveryID, err := safeSegment(admission.Goal.DeliveryID, "delivery identity")
+ if err != nil {
+ return err
+ }
+ expected, _ := admission.Parameters.Get("preview_fingerprint")
+ if expected == "" || preview.Fingerprint != expected || preview.DeliveryID != deliveryID {
+ return fmt.Errorf("publication preview does not match the exact admitted delivery")
+ }
+ if admission.Invocation.Ref != "refs/heads/"+preview.HeadRef {
+ return fmt.Errorf("publication preview head does not match the exact invoking branch")
+ }
+ configRaw, err := os.ReadFile(layout.ConfigPath)
+ if err != nil {
+ return err
+ }
+ config, err := protocol.DecodeProjectConfig(configRaw)
+ if err != nil {
+ return err
+ }
+ if preview.BaseRef != config.Project.DefaultBranch {
+ return fmt.Errorf("publication preview base does not match current configuration authority")
+ }
+ return nil
+}
+
+func validateCorrectionBody(admission protocol.Admission) error {
+ bodyPath, _ := admission.Parameters.Get("body_path")
+ expected, _ := admission.Parameters.Get("body_sha256")
+ raw, err := os.ReadFile(bodyPath)
+ if err != nil {
+ return err
+ }
+ if expected == "" || sha256Bytes(raw) != expected {
+ return fmt.Errorf("publication correction body does not match its admitted fingerprint")
+ }
+ return nil
+}
diff --git a/boatstack/internal/effects/clock.go b/boatstack/internal/effects/clock.go
new file mode 100644
index 0000000..08dbfea
--- /dev/null
+++ b/boatstack/internal/effects/clock.go
@@ -0,0 +1,7 @@
+package effects
+
+import "time"
+
+type Clock struct{}
+
+func (Clock) Now() time.Time { return time.Now().UTC() }
diff --git a/boatstack/internal/effects/command_boundary.go b/boatstack/internal/effects/command_boundary.go
new file mode 100644
index 0000000..37bf86b
--- /dev/null
+++ b/boatstack/internal/effects/command_boundary.go
@@ -0,0 +1,253 @@
+package effects
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "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/kernel/supervisor"
+)
+
+type NativeCommandRunner interface {
+ CombinedOutput(context.Context, string, string, ...string) ([]byte, error)
+}
+
+type nativeExecRunner struct{}
+
+func (nativeExecRunner) CombinedOutput(ctx context.Context, directory, name string, arguments ...string) ([]byte, error) {
+ command := exec.CommandContext(ctx, name, arguments...)
+ command.Dir = directory
+ return command.CombinedOutput()
+}
+
+type NativeBoundary struct{ runner NativeCommandRunner }
+
+func NewNativeBoundary() NativeBoundary { return NativeBoundary{runner: nativeExecRunner{}} }
+
+func NewNativeBoundaryWithRunner(runner NativeCommandRunner) (NativeBoundary, error) {
+ if runner == nil {
+ return NativeBoundary{}, fmt.Errorf("native command boundary requires a runner")
+ }
+ return NativeBoundary{runner: runner}, nil
+}
+
+type pullRequestObservation struct {
+ State string `json:"state"`
+ URL string `json:"url"`
+ Number int `json:"number"`
+ MergedAt string `json:"mergedAt"`
+ BaseRefName string `json:"baseRefName"`
+ HeadRefName string `json:"headRefName"`
+ HeadRefOID string `json:"headRefOid"`
+ IsCrossRepository bool `json:"isCrossRepository"`
+}
+
+func (b NativeBoundary) PrepareObservation(ctx context.Context, admission protocol.Admission, transition catalog.Transition, layout ports.ControllerLayout, state *durable.State) error {
+ switch transition.ID {
+ case "publication.observe", "publication.reconcile":
+ publicationID, _ := admission.Parameters.Get("publication_id")
+ if state.PublicationID != "" && state.PublicationID != publicationID {
+ state.Publication = model.PublicationConflicting
+ return nil
+ }
+ output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "view", "--json", "state,url,number,mergedAt,baseRefName,headRefName,headRefOid,isCrossRepository", "--", publicationID)
+ if err != nil {
+ state.Publication, state.PublicationID, state.PublicationURL = model.PublicationUnavailable, publicationID, ""
+ return nil
+ }
+ var observation pullRequestObservation
+ if err := json.Unmarshal(output, &observation); err != nil {
+ state.Publication, state.PublicationID, state.PublicationURL = model.PublicationConflicting, publicationID, ""
+ return nil
+ }
+ state.PublicationID, state.PublicationURL = fmt.Sprintf("%d", observation.Number), observation.URL
+ configRaw, readErr := os.ReadFile(layout.ConfigPath)
+ if readErr != nil {
+ return readErr
+ }
+ config, decodeErr := protocol.DecodeProjectConfig(configRaw)
+ if decodeErr != nil {
+ return decodeErr
+ }
+ head := strings.TrimPrefix(admission.Invocation.Ref, "refs/heads/")
+ identityMatches := strings.HasPrefix(admission.Invocation.Ref, "refs/heads/") && observation.Number > 0 && observation.URL != "" &&
+ !observation.IsCrossRepository && observation.BaseRefName == config.Project.DefaultBranch && observation.HeadRefName == head &&
+ admission.SourceRevision != "" && strings.EqualFold(observation.HeadRefOID, admission.SourceRevision)
+ if !identityMatches {
+ state.Publication = model.PublicationConflicting
+ return nil
+ }
+ switch strings.ToUpper(observation.State) {
+ case "OPEN":
+ state.Publication = model.PublicationOpen
+ case "MERGED":
+ state.Publication = model.PublicationMerged
+ case "CLOSED":
+ if observation.MergedAt != "" {
+ state.Publication = model.PublicationMerged
+ } else {
+ state.Publication = model.PublicationClosedUnmerged
+ }
+ default:
+ state.Publication = model.PublicationConflicting
+ }
+ case "configuration.reconcile":
+ raw, err := os.ReadFile(layout.ConfigPath)
+ if err != nil {
+ return err
+ }
+ config, fingerprint, err := protocol.ProjectConfigFingerprint(raw)
+ if err != nil {
+ return err
+ }
+ policy := config.ControlPolicy()
+ state.ConfigFingerprint = fingerprint
+ state.PlanApprovalPolicy = policy.PlanApproval
+ state.VisualEvidencePolicy = policy.VisualEvidence
+ state.ExternalEffectPolicy = policy.ExternalEffectAuthority
+ state.IndependentReview = policy.IndependentReviewForHighRisk
+ state.EnabledHosts = append([]string(nil), policy.Hosts...)
+ }
+ return nil
+}
+
+func (b NativeBoundary) Execute(ctx context.Context, admission protocol.Admission, transition catalog.Transition, layout ports.ControllerLayout, state durable.State) (ports.EffectResult, error) {
+ settled := ports.EffectResult{Settlement: ports.EffectSettled}
+ switch transition.ID {
+ case "gate.build.record", "gate.test.record":
+ raw, err := os.ReadFile(layout.ConfigPath)
+ if err != nil {
+ return settled, err
+ }
+ config, err := protocol.DecodeProjectConfig(raw)
+ if err != nil {
+ return settled, err
+ }
+ gate, _ := catalog.GateName(transition.ID)
+ command := strings.TrimSpace(config.Project.Commands[gate])
+ if command == "" {
+ return settled, fmt.Errorf("repository configuration has no %s command", gate)
+ }
+ intent := supervisor.ClassifyCommandIntent(command)
+ if intent.Class != supervisor.IntentOrdinary {
+ return settled, fmt.Errorf("configured %s command crosses protected effect boundary %s", gate, intent.Operation)
+ }
+ executable, arguments := "/bin/sh", []string{"-c", command}
+ if runtime.GOOS == "windows" {
+ executable, arguments = "powershell.exe", []string{"-NoProfile", "-NonInteractive", "-Command", command}
+ }
+ if _, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, executable, arguments...); err != nil {
+ return settled, fmt.Errorf("configured %s command did not pass: %w", gate, err)
+ }
+ case "workspace.cut":
+ branch, _ := admission.Parameters.Get("branch")
+ baseRef, _ := admission.Parameters.Get("base_ref")
+ if err := protocol.ValidateGitBranch(branch); err != nil {
+ return settled, err
+ }
+ if err := protocol.ValidateGitReference(baseRef); err != nil {
+ return settled, err
+ }
+ absolute, err := canonicalWorkspaceDestination(admission)
+ if err != nil || absolute == layout.RepositoryRoot {
+ return settled, fmt.Errorf("workspace destination must be an explicit non-primary path")
+ }
+ if _, err := os.Stat(absolute); err == nil {
+ return settled, fmt.Errorf("workspace destination already exists: %s", absolute)
+ } else if !os.IsNotExist(err) {
+ return settled, err
+ }
+ baseConfig, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "show", baseRef+":.boatstack/project.json")
+ if err != nil {
+ return settled, fmt.Errorf("workspace base does not contain the verified V2 configuration: %w", err)
+ }
+ _, baseFingerprint, fingerprintErr := protocol.ProjectConfigFingerprint(baseConfig)
+ if fingerprintErr != nil {
+ return settled, fmt.Errorf("workspace base configuration is invalid: %w", fingerprintErr)
+ }
+ if state.ConfigFingerprint == "" || baseFingerprint != state.ConfigFingerprint {
+ return settled, fmt.Errorf("workspace base configuration does not match current repository authority")
+ }
+ if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "check-ref-format", "--branch", branch); err != nil {
+ return settled, fmt.Errorf("invalid workspace branch: %s: %w", strings.TrimSpace(string(output)), err)
+ }
+ arguments := []string{"worktree", "add", "-b", branch, absolute, baseRef}
+ if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", arguments...); err != nil {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ case "workspace.sync":
+ if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "fetch", "origin"); err != nil {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ case "workspace.cleanup", "workspace.reap":
+ if state.Workspace != model.WorkspaceLanded && state.Workspace != model.WorkspaceAbandoned {
+ return settled, fmt.Errorf("workspace is neither landed nor explicitly abandoned")
+ }
+ if state.WorkspacePath == "" || state.WorkspaceBranch == "" {
+ return settled, fmt.Errorf("workspace cleanup identity is incomplete")
+ }
+ 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 {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ branchMode := "-d"
+ if state.Workspace == model.WorkspaceAbandoned {
+ branchMode = "-D"
+ }
+ if output, err := b.runner.CombinedOutput(ctx, neutralDirectory, "git", append(gitPrefix, "branch", branchMode, state.WorkspaceBranch)...); err != nil {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ case "publication.execute":
+ deliveryID, err := safeSegment(admission.Goal.DeliveryID, "delivery identity")
+ if err != nil {
+ return settled, err
+ }
+ preview, err := loadPublicationPreview(filepath.Join(layout.RepositoryRoot, ".boatstack", "publication", deliveryID+".preview.json"))
+ if err != nil {
+ return settled, err
+ }
+ if err := validatePublicationPreviewForAdmission(layout, admission, preview); err != nil {
+ return settled, err
+ }
+ if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "--set-upstream", "origin", preview.HeadRef); err != nil {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "create", "--base", preview.BaseRef, "--head", preview.HeadRef, "--body-file", preview.BodyPath); err != nil {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ case "publication.correct":
+ publicationID, _ := admission.Parameters.Get("publication_id")
+ bodyPath, _ := admission.Parameters.Get("body_path")
+ if err := validateCorrectionBody(admission); err != nil {
+ return settled, err
+ }
+ if state.PublicationID == "" || publicationID != state.PublicationID {
+ return settled, fmt.Errorf("publication correction identity does not match durable provider evidence")
+ }
+ if !strings.HasPrefix(admission.Invocation.Ref, "refs/heads/") {
+ return settled, fmt.Errorf("publication correction requires an exact branch invocation")
+ }
+ branch := strings.TrimPrefix(admission.Invocation.Ref, "refs/heads/")
+ if err := protocol.ValidateGitBranch(branch); err != nil {
+ return settled, err
+ }
+ if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "git", "push", "--set-upstream", "origin", branch); err != nil {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ if output, err := b.runner.CombinedOutput(ctx, layout.RepositoryRoot, "gh", "pr", "edit", "--body-file", bodyPath, "--", publicationID); err != nil {
+ return ports.EffectResult{Settlement: ports.EffectUnknown, Detail: strings.TrimSpace(string(output))}, nil
+ }
+ }
+ return settled, nil
+}
diff --git a/boatstack/internal/effects/command_boundary_test.go b/boatstack/internal/effects/command_boundary_test.go
new file mode 100644
index 0000000..db4864a
--- /dev/null
+++ b/boatstack/internal/effects/command_boundary_test.go
@@ -0,0 +1,179 @@
+package effects
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+type boundaryRunner struct {
+ calls int
+ err error
+ output []byte
+ directory string
+ name string
+ arguments []string
+}
+
+func (r *boundaryRunner) CombinedOutput(_ context.Context, directory, name string, arguments ...string) ([]byte, error) {
+ r.calls++
+ r.directory = directory
+ r.name = name
+ r.arguments = append([]string(nil), arguments...)
+ if r.output != nil {
+ return append([]byte(nil), r.output...), r.err
+ }
+ return []byte("private output must not escape"), r.err
+}
+
+func writeBoundaryConfig(t *testing.T, command string) ports.ControllerLayout {
+ t.Helper()
+ repository := t.TempDir()
+ path := filepath.Join(repository, ".boatstack", "project.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ raw := []byte(`{"schema_version":2,"project":{"name":"boundary","default_branch":"main","commands":{"build":"` + command + `"}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`)
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return ports.ControllerLayout{RepositoryRoot: repository, ConfigPath: path}
+}
+
+func TestConfiguredBuildCommandMustPassBeforeGateInstallation(t *testing.T) {
+ runner := &boundaryRunner{err: errors.New("exit status 1")}
+ boundary, err := NewNativeBoundaryWithRunner(runner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transition, _ := catalog.Default().Lookup("gate.build.record")
+ _, err = boundary.Execute(context.Background(), protocol.Admission{}, transition, writeBoundaryConfig(t, "go test ./..."), durable.State{})
+ if err == nil || runner.calls != 1 {
+ t.Fatalf("failed configured command result: err=%v calls=%d", err, runner.calls)
+ }
+ if message := err.Error(); message == "" || strings.Contains(message, "private output") {
+ t.Fatalf("gate error leaked command output: %q", message)
+ }
+}
+
+func TestConfiguredBuildCommandCannotCrossConstitutionalGuard(t *testing.T) {
+ runner := &boundaryRunner{}
+ boundary, err := NewNativeBoundaryWithRunner(runner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transition, _ := catalog.Default().Lookup("gate.build.record")
+ _, err = boundary.Execute(context.Background(), protocol.Admission{}, transition, writeBoundaryConfig(t, "git reset --hard HEAD~1"), durable.State{})
+ if err == nil || runner.calls != 0 {
+ t.Fatalf("protected configured command result: err=%v calls=%d", err, runner.calls)
+ }
+}
+
+func TestPublicationObservationTerminatesOptionsBeforeIdentifier(t *testing.T) {
+ runner := &boundaryRunner{output: []byte(`{"state":"OPEN","url":"https://example.invalid/pull/7","number":7,"mergedAt":null,"baseRefName":"main","headRefName":"feature","headRefOid":"revision","isCrossRepository":false}`)}
+ boundary, err := NewNativeBoundaryWithRunner(runner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transition, _ := catalog.Default().Lookup("publication.observe")
+ admission := protocol.Admission{
+ Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision",
+ Parameters: protocol.Parameters{{Name: "publication_id", Value: "-dangerous"}},
+ }
+ state := durable.State{}
+ layout := writeBoundaryConfig(t, "go test ./...")
+ if err := boundary.PrepareObservation(context.Background(), admission, transition, layout, &state); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"pr", "view", "--json", "state,url,number,mergedAt,baseRefName,headRefName,headRefOid,isCrossRepository", "--", "-dangerous"}
+ if runner.name != "gh" || strings.Join(runner.arguments, "\x00") != strings.Join(want, "\x00") {
+ t.Fatalf("observation command = %s %q, want gh %q", runner.name, runner.arguments, want)
+ }
+ if state.PublicationID != "7" {
+ t.Fatalf("publication ID = %q, want 7", state.PublicationID)
+ }
+}
+
+func TestPublicationObservationRejectsUnrelatedProviderIdentity(t *testing.T) {
+ runner := &boundaryRunner{output: []byte(`{"state":"OPEN","url":"https://example.invalid/pull/8","number":8,"baseRefName":"main","headRefName":"other","headRefOid":"revision","isCrossRepository":false}`)}
+ boundary, _ := NewNativeBoundaryWithRunner(runner)
+ transition, _ := catalog.Default().Lookup("publication.observe")
+ admission := protocol.Admission{
+ Invocation: model.InvocationContext{Ref: "refs/heads/feature"}, SourceRevision: "revision",
+ Parameters: protocol.Parameters{{Name: "publication_id", Value: "8"}},
+ }
+ state := durable.State{}
+ if err := boundary.PrepareObservation(context.Background(), admission, transition, writeBoundaryConfig(t, "go test ./..."), &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.Publication != model.PublicationConflicting {
+ t.Fatalf("unrelated PR observed as %s", state.Publication)
+ }
+}
+
+func TestPublicationPreviewRejectsFieldTamperingUnderAnOldFingerprint(t *testing.T) {
+ repository := t.TempDir()
+ bodyPath := filepath.Join(repository, "body.md")
+ if err := os.WriteFile(bodyPath, []byte("reviewed body"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ preview := publicationPreview{
+ SchemaVersion: 1, DeliveryID: "delivery", BaseRef: "main", HeadRef: "feature",
+ BodyPath: bodyPath, BodySHA256: sha256Bytes([]byte("reviewed body")), CreatedAt: time.Unix(10, 0).UTC(),
+ }
+ identity := preview
+ identity.CreatedAt = time.Time{}
+ raw, err := json.Marshal(identity)
+ if err != nil {
+ t.Fatal(err)
+ }
+ preview.Fingerprint = sha256Bytes(raw)
+ preview.HeadRef = "attacker-controlled"
+ encoded, err := encodeJSON(preview)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(repository, "preview.json")
+ if err := os.WriteFile(path, encoded, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadPublicationPreview(path); err == nil {
+ t.Fatal("tampered publication preview retained authority")
+ }
+}
+
+func TestPublicationCorrectionRejectsBodyDriftBeforeProviderCall(t *testing.T) {
+ runner := &boundaryRunner{}
+ boundary, _ := NewNativeBoundaryWithRunner(runner)
+ transition, _ := catalog.Default().Lookup("publication.correct")
+ layout := writeBoundaryConfig(t, "go test ./...")
+ bodyPath := filepath.Join(layout.RepositoryRoot, "body.md")
+ if err := os.WriteFile(bodyPath, []byte("changed"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ admission := protocol.Admission{
+ Invocation: model.InvocationContext{Ref: "refs/heads/feature"},
+ Parameters: protocol.Parameters{
+ {Name: "publication_id", Value: "7"}, {Name: "body_path", Value: bodyPath},
+ {Name: "body_sha256", Value: sha256Bytes([]byte("reviewed"))},
+ },
+ }
+ state := durable.State{PublicationID: "7"}
+ if _, err := boundary.Execute(context.Background(), admission, transition, layout, state); err == nil {
+ t.Fatal("publication correction accepted drifted body bytes")
+ }
+ if runner.calls != 0 {
+ t.Fatalf("provider received %d calls after body drift", runner.calls)
+ }
+}
diff --git a/boatstack/internal/effects/dirsync_unix.go b/boatstack/internal/effects/dirsync_unix.go
new file mode 100644
index 0000000..d619e89
--- /dev/null
+++ b/boatstack/internal/effects/dirsync_unix.go
@@ -0,0 +1,26 @@
+//go:build !windows
+
+package effects
+
+import (
+ "errors"
+ "os"
+ "syscall"
+)
+
+func syncDirectory(path string) error {
+ directory, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer directory.Close()
+ if err := directory.Sync(); err != nil {
+ // File fsync and atomic rename already completed. A filesystem that does
+ // not implement directory fsync cannot use its absence as state evidence.
+ if errors.Is(err, os.ErrInvalid) || errors.Is(err, syscall.ENOTSUP) {
+ return nil
+ }
+ return err
+ }
+ return nil
+}
diff --git a/boatstack/internal/effects/dirsync_windows.go b/boatstack/internal/effects/dirsync_windows.go
new file mode 100644
index 0000000..4cf189f
--- /dev/null
+++ b/boatstack/internal/effects/dirsync_windows.go
@@ -0,0 +1,8 @@
+//go:build windows
+
+package effects
+
+// replaceFile uses MoveFileExW with MOVEFILE_WRITE_THROUGH on Windows. Go's
+// ordinary directory handle cannot be flushed portably, so no weaker second
+// flush is attempted here.
+func syncDirectory(string) error { return nil }
diff --git a/boatstack/internal/effects/driver.go b/boatstack/internal/effects/driver.go
new file mode 100644
index 0000000..a77f813
--- /dev/null
+++ b/boatstack/internal/effects/driver.go
@@ -0,0 +1,384 @@
+package effects
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "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/kernel/reducer"
+)
+
+type CommandBoundary interface {
+ PrepareObservation(context.Context, protocol.Admission, catalog.Transition, ports.ControllerLayout, *durable.State) error
+ Execute(context.Context, protocol.Admission, catalog.Transition, ports.ControllerLayout, durable.State) (ports.EffectResult, error)
+}
+
+type Driver struct {
+ resolver ports.InvocationResolver
+ clock ports.Clock
+ boundary CommandBoundary
+}
+
+func NewDriver(resolver ports.InvocationResolver, clock ports.Clock, boundary CommandBoundary) (Driver, error) {
+ if resolver == nil || clock == nil || boundary == nil {
+ return Driver{}, fmt.Errorf("effect driver requires resolver, clock, and command boundary")
+ }
+ return Driver{resolver: resolver, clock: clock, boundary: boundary}, nil
+}
+
+func (d Driver) Prepare(ctx context.Context, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) {
+ layout, currentInvocation, err := d.resolver.ResolveLayout(ctx, admission.Invocation)
+ if err != nil {
+ return nil, err
+ }
+ if currentInvocation.RepositoryID != admission.Invocation.RepositoryID || currentInvocation.GitCommonID != admission.Invocation.GitCommonID || currentInvocation.WorktreeID != admission.Invocation.WorktreeID {
+ return nil, fmt.Errorf("effect invocation identity changed before preparation")
+ }
+ if transition.ID == "recovery.resume" || transition.ID == "recovery.rollback" || transition.ID == "workspace.reconcile" {
+ return d.prepareRecoveryReplay(ctx, layout, admission, transition)
+ }
+ state, err := loadDurableState(layout.StatePath, admission.Invocation, d.clock.Now())
+ if err != nil {
+ return nil, err
+ }
+ if state.RepositoryID != admission.Invocation.RepositoryID || state.GitCommonID != admission.Invocation.GitCommonID || state.WorktreeID != admission.Invocation.WorktreeID {
+ return nil, fmt.Errorf("durable state belongs to a different invocation")
+ }
+ if err := verifyWorkspaceBranchParameter(state, admission, transition.ID); err != nil {
+ return nil, err
+ }
+ if err := verifyRuntimeParameters(admission, transition); err != nil {
+ return nil, err
+ }
+ next := state
+ if err := d.boundary.PrepareObservation(ctx, admission, transition, layout, &next); err != nil {
+ return nil, err
+ }
+ if err := reducer.Apply(&next, admission, transition); err != nil {
+ return nil, err
+ }
+ next.Revision++
+ next.UpdatedAt = d.clock.Now().UTC()
+ var verificationInvocation *model.InvocationContext
+ if transition.ID == "workspace.cut" {
+ destination, destinationErr := canonicalWorkspaceDestination(admission)
+ if destinationErr != nil {
+ return nil, destinationErr
+ }
+ destinationID, destinationErr := model.DeriveWorktreeID(admission.Invocation.GitCommonID, destination)
+ if destinationErr != nil {
+ return nil, destinationErr
+ }
+ branch, _ := admission.Parameters.Get("branch")
+ targetInvocation := admission.Invocation
+ targetInvocation.WorktreeID = destinationID
+ targetInvocation.InvokingPath = destination
+ targetInvocation.Ref = "refs/heads/" + branch
+ verificationInvocation = &targetInvocation
+ next.WorktreeID = destinationID
+ next.WorkspacePath = destination
+ next.SourceRevision = ""
+ next.WorktreeFingerprint = ""
+ next.Verification = model.VerificationUnverified
+ } else if transition.ID == "workspace.cleanup" || transition.ID == "workspace.reap" {
+ targetInvocation, targetErr := d.resolver.ResolveInvocation(ctx, state.WorkspaceSourcePath, admission.Invocation.Host, admission.Invocation.Correlation)
+ if targetErr != nil {
+ return nil, fmt.Errorf("resolve preserved source checkout before workspace removal: %w", targetErr)
+ }
+ if targetInvocation.RepositoryID != admission.Invocation.RepositoryID || targetInvocation.GitCommonID != admission.Invocation.GitCommonID ||
+ targetInvocation.WorktreeID != state.WorkspaceSourceID || targetInvocation.Ref != state.WorkspaceSourceRef ||
+ targetInvocation.ControllerID != admission.Invocation.ControllerID || targetInvocation.Topology != admission.Invocation.Topology {
+ return nil, fmt.Errorf("preserved source checkout identity changed; refusing workspace removal")
+ }
+ verificationInvocation = &targetInvocation
+ next.WorktreeID = targetInvocation.WorktreeID
+ next.WorkspaceBranch = ""
+ next.WorkspacePath = ""
+ next.WorkspaceBaseRef = ""
+ next.WorkspaceSourcePath = ""
+ next.WorkspaceSourceID = ""
+ next.WorkspaceSourceRef = ""
+ }
+
+ mutations, err := prepareArtifacts(layout, admission, transition, &next)
+ if err != nil {
+ return nil, err
+ }
+ statePath := layout.StatePath
+ stateInstallLast := true
+ if transition.ID == "repository.attach" {
+ statePath = filepath.Join(layout.ExternalStateRoot, "state.json")
+ stateInstallLast = false
+ configMutations, configErr := prepareConfigurationAuthorityTransfer(layout, admission, state, true)
+ if configErr != nil {
+ return nil, configErr
+ }
+ mutations = append(mutations, configMutations...)
+ bindingMutation, mutationErr := prepareAttachBinding(layout, admission, d.clock.Now())
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, bindingMutation)
+ } else if transition.ID == "repository.detach" {
+ statePath = filepath.Join(layout.EmbeddedStateRoot, "state.json")
+ stateInstallLast = false
+ configMutations, configErr := prepareConfigurationAuthorityTransfer(layout, admission, state, false)
+ if configErr != nil {
+ return nil, configErr
+ }
+ mutations = append(mutations, configMutations...)
+ bindingMutation, mutationErr := mutationFor(layout.BindingPath, nil, 0o600, true, true)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, bindingMutation)
+ }
+ if transition.ID == "workspace.cut" {
+ parked := parkedSourceState(state, transition.ID, d.clock.Now())
+ parkedRaw, encodeErr := durable.EncodeState(parked)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ parkedMutation, mutationErr := mutationFor(statePath, parkedRaw, 0o600, false, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, parkedMutation)
+ destinationRaw, encodeErr := durable.EncodeState(next)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ destinationStatePath := filepath.Join(layout.SharedRoot, "worktrees", next.WorktreeID, "state.json")
+ destinationMutation, mutationErr := mutationFor(destinationStatePath, destinationRaw, 0o600, true, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, destinationMutation)
+ } else if transition.ID == "workspace.cleanup" || transition.ID == "workspace.reap" {
+ currentStateRemoval, mutationErr := mutationFor(statePath, nil, 0o600, false, true)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, currentStateRemoval)
+ targetRaw, encodeErr := durable.EncodeState(next)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ targetStatePath := filepath.Join(layout.SharedRoot, "worktrees", next.WorktreeID, "state.json")
+ targetMutation, mutationErr := mutationFor(targetStatePath, targetRaw, 0o600, true, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, targetMutation)
+ } else {
+ stateRaw, encodeErr := durable.EncodeState(next)
+ if encodeErr != nil {
+ return nil, encodeErr
+ }
+ stateMutation, mutationErr := mutationFor(statePath, stateRaw, 0o600, stateInstallLast, false)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, stateMutation)
+ }
+ if closesInterruptedJournal(transition.ID) {
+ transactionID, _ := admission.Parameters.Get("transaction_id")
+ recoveryMutations, recoveryErr := prepareJournalClosure(layout, transactionID, string(transition.ID), d.clock.Now(), admission.ID)
+ if recoveryErr != nil {
+ return nil, recoveryErr
+ }
+ mutations = append(mutations, recoveryMutations...)
+ }
+ prepared := &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation}
+ if requiresCommandBoundary(transition.ID) {
+ prepared.boundary = func(boundaryContext context.Context) (ports.EffectResult, error) {
+ return d.boundary.Execute(boundaryContext, admission, transition, layout, state)
+ }
+ }
+ return prepared, nil
+}
+
+func prepareConfigurationAuthorityTransfer(layout ports.ControllerLayout, admission protocol.Admission, state durable.State, attaching bool) ([]ports.ResourceMutation, error) {
+ target := ""
+ if attaching {
+ authority, _ := admission.Parameters.Get("config_authority")
+ if authority != "external" {
+ return nil, nil
+ }
+ target = filepath.Join(layout.FlowRoot, "project.json")
+ } else {
+ if layout.ConfigAuthority != "external" {
+ return nil, nil
+ }
+ target = filepath.Join(layout.RepositoryRoot, ".boatstack", "project.json")
+ }
+ raw, err := os.ReadFile(layout.ConfigPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ _, fingerprint, err := protocol.ProjectConfigFingerprint(raw)
+ if err != nil {
+ return nil, err
+ }
+ if state.Configuration != model.ConfigurationVerified || state.ConfigFingerprint == "" || fingerprint != state.ConfigFingerprint {
+ return nil, nil
+ }
+ mutation, err := mutationFor(target, raw, 0o600, false, false)
+ if err != nil {
+ return nil, err
+ }
+ return []ports.ResourceMutation{mutation}, nil
+}
+
+func verifyWorkspaceBranchParameter(state durable.State, admission protocol.Admission, id catalog.TransitionID) error {
+ switch id {
+ case "workspace.sync", "workspace.activate", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon":
+ default:
+ return nil
+ }
+ branch, _ := admission.Parameters.Get("branch")
+ if state.WorkspaceBranch == "" || branch != state.WorkspaceBranch || admission.Invocation.Ref != "refs/heads/"+branch {
+ return fmt.Errorf("workspace transition %q branch does not match durable workspace and invocation identity", id)
+ }
+ return nil
+}
+
+func canonicalWorkspaceDestination(admission protocol.Admission) (string, error) {
+ value, _ := admission.Parameters.Get("destination")
+ absolute, err := filepath.Abs(value)
+ if err != nil {
+ return "", err
+ }
+ parent, err := filepath.EvalSymlinks(filepath.Dir(absolute))
+ if err != nil {
+ return "", fmt.Errorf("resolve workspace destination parent: %w", err)
+ }
+ canonical := filepath.Join(parent, filepath.Base(absolute))
+ if canonical == admission.Invocation.InvokingPath {
+ return "", fmt.Errorf("workspace destination must differ from the invoking worktree")
+ }
+ return canonical, nil
+}
+
+func parkedSourceState(state durable.State, transition catalog.TransitionID, now time.Time) durable.State {
+ state.Revision++
+ state.Phase = model.PhaseDormant
+ state.Engagement = model.EngagementDormant
+ state.Delivery = model.DeliveryUninitialized
+ state.Workspace = model.WorkspaceAbsent
+ state.Plan = model.PlanAbsent
+ state.Publication = model.PublicationNone
+ state.Verification = model.VerificationUnverified
+ state.Recovery = model.RecoveryNone
+ state.Transaction = model.TransactionNone
+ state.Terminal = model.TerminalNonterminal
+ state.Goal = model.Goal{}
+ state.SourceRevision = ""
+ state.WorktreeFingerprint = ""
+ state.PlanFingerprint = ""
+ state.WorkspaceBranch = ""
+ state.WorkspacePath = ""
+ state.WorkspaceBaseRef = ""
+ state.WorkspaceSourcePath = ""
+ state.WorkspaceSourceID = ""
+ state.WorkspaceSourceRef = ""
+ state.PublicationID = ""
+ state.PublicationURL = ""
+ state.PreviewFingerprint = ""
+ state.Gates = nil
+ reducer.ClearRecoveryContext(&state)
+ state.LastTransition = transition
+ state.UpdatedAt = now.UTC()
+ return state
+}
+
+func closesInterruptedJournal(id catalog.TransitionID) bool {
+ switch id {
+ case "runtime.reconcile", "configuration.reconcile", "publication.reconcile", "recovery.escalate":
+ return true
+ default:
+ return false
+ }
+}
+
+func requiresCommandBoundary(id catalog.TransitionID) bool {
+ switch id {
+ case "gate.build.record", "gate.test.record", "workspace.cut", "workspace.sync", "workspace.cleanup", "workspace.reap", "publication.execute", "publication.correct":
+ return true
+ default:
+ return false
+ }
+}
+
+func loadDurableState(path string, invocation model.InvocationContext, now time.Time) (durable.State, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return durable.Default(invocation, now), nil
+ }
+ return durable.State{}, err
+ }
+ return durable.DecodeState(raw)
+}
+
+func verifyRuntimeParameters(admission protocol.Admission, transition catalog.Transition) error {
+ if transition.ID != "runtime.hydrate" && transition.ID != "runtime.replace" && transition.ID != "runtime.reconcile" && transition.ID != "installation.initialize" && transition.ID != "installation.update" {
+ return nil
+ }
+ runtimePath, _ := admission.Parameters.Get("runtime_path")
+ if !filepath.IsAbs(runtimePath) {
+ return fmt.Errorf("declared runtime path must be absolute")
+ }
+ resolved, err := filepath.EvalSymlinks(runtimePath)
+ 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)
+ }
+ expected, _ := admission.Parameters.Get("runtime_sha256")
+ raw, err := os.ReadFile(runtimePath)
+ if err != nil {
+ return fmt.Errorf("read declared runtime: %w", err)
+ }
+ if actual := sha256Bytes(raw); actual != expected {
+ return fmt.Errorf("declared runtime fingerprint mismatch: got %s", actual)
+ }
+ return nil
+}
+
+func mutationFor(path string, target []byte, mode os.FileMode, installLast, deleteResource bool) (ports.ResourceMutation, error) {
+ if !filepath.IsAbs(path) {
+ return ports.ResourceMutation{}, fmt.Errorf("managed resource path is not absolute: %s", path)
+ }
+ prior, exists, priorMode, err := readAllIfExists(path)
+ if err != nil {
+ return ports.ResourceMutation{}, err
+ }
+ if exists {
+ mode = priorMode
+ }
+ return ports.ResourceMutation{Path: path, Prior: prior, Target: target, PriorExists: exists, Mode: uint32(mode.Perm()), InstallLast: installLast, Delete: deleteResource}, nil
+}
+
+var safeArtifactSegment = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
+
+func safeSegment(value, name string) (string, error) {
+ value = strings.TrimSpace(value)
+ if !safeArtifactSegment.MatchString(value) || value == "." || value == ".." {
+ return "", fmt.Errorf("invalid %s %q", name, value)
+ }
+ return value, nil
+}
diff --git a/boatstack/internal/effects/filelock_unix.go b/boatstack/internal/effects/filelock_unix.go
new file mode 100644
index 0000000..56fc732
--- /dev/null
+++ b/boatstack/internal/effects/filelock_unix.go
@@ -0,0 +1,20 @@
+//go:build darwin || linux
+
+package effects
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+)
+
+func lockFile(file *os.File) error {
+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+ return fmt.Errorf("kernel lock is held: %w", err)
+ }
+ return nil
+}
+
+func unlockFile(file *os.File) error {
+ return syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
+}
diff --git a/boatstack/internal/effects/filelock_windows.go b/boatstack/internal/effects/filelock_windows.go
new file mode 100644
index 0000000..7e1a986
--- /dev/null
+++ b/boatstack/internal/effects/filelock_windows.go
@@ -0,0 +1,41 @@
+//go:build windows
+
+package effects
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+ "unsafe"
+)
+
+const (
+ lockfileFailImmediately = 0x00000001
+ lockfileExclusiveLock = 0x00000002
+)
+
+var (
+ lockFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("LockFileEx")
+ unlockFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("UnlockFileEx")
+)
+
+func lockFile(file *os.File) error {
+ var overlapped syscall.Overlapped
+ result, _, callErr := lockFileEx.Call(
+ file.Fd(), uintptr(lockfileFailImmediately|lockfileExclusiveLock), 0,
+ 1, 0, uintptr(unsafe.Pointer(&overlapped)),
+ )
+ if result == 0 {
+ return fmt.Errorf("kernel lock is held: %w", callErr)
+ }
+ return nil
+}
+
+func unlockFile(file *os.File) error {
+ var overlapped syscall.Overlapped
+ result, _, callErr := unlockFileEx.Call(file.Fd(), 0, 1, 0, uintptr(unsafe.Pointer(&overlapped)))
+ if result == 0 {
+ return callErr
+ }
+ return nil
+}
diff --git a/boatstack/internal/effects/integration_test.go b/boatstack/internal/effects/integration_test.go
new file mode 100644
index 0000000..35c5e4a
--- /dev/null
+++ b/boatstack/internal/effects/integration_test.go
@@ -0,0 +1,518 @@
+package effects_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ boatstack "github.com/operatorstack/boatstack/boatstack"
+ "github.com/operatorstack/boatstack/boatstack/internal/effects"
+ "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"
+ "github.com/operatorstack/boatstack/boatstack/internal/surfaces"
+)
+
+type fixedClock struct{ value time.Time }
+
+func (c fixedClock) Now() time.Time { return c.value }
+
+func run(t *testing.T, directory, name string, arguments ...string) {
+ t.Helper()
+ command := exec.Command(name, arguments...)
+ command.Dir = directory
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("%s %v: %v\n%s", name, arguments, err, output)
+ }
+}
+
+func testRepository(t *testing.T) string {
+ t.Helper()
+ repository := t.TempDir()
+ run(t, repository, "git", "init", "-q")
+ run(t, repository, "git", "config", "user.email", "boatstack@example.invalid")
+ run(t, repository, "git", "config", "user.name", "Boatstack Test")
+ if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, repository, "git", "add", "README.md")
+ run(t, repository, "git", "commit", "-q", "-m", "fixture")
+ return repository
+}
+
+func TestConcreteBoundaryAppliesAndReceiptsOneTransition(t *testing.T) {
+ // control-law: request-to-boundary-to-effect-to-verified-receipt
+ ctx := context.Background()
+ repository := testRepository(t)
+ clock := fixedClock{value: time.Unix(1000, 0).UTC()}
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "correlation-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ observer, err := plant.NewObserver(resolver, clock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ locker, err := effects.NewLocker(resolver)
+ if err != nil {
+ t.Fatal(err)
+ }
+ journal, err := effects.NewJournal(resolver, clock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ receipts, err := effects.NewReceiptStore(resolver, clock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ driver, err := effects.NewDriver(resolver, clock, effects.NewNativeBoundary())
+ if err != nil {
+ t.Fatal(err)
+ }
+ kernel, err := engine.New(catalog.Default(), observer, clock, locker, journal, driver, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ goal := model.Goal{ID: "goal-1", Kind: model.GoalVerified, DeliveryID: "delivery-1"}
+ authority := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{
+ ID: "authority-1", Class: catalog.AuthorityHuman, Subject: invocation.RepositoryID, Fingerprint: "human-fingerprint",
+ IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour),
+ }}}
+ result, err := kernel.Apply(ctx, engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Goal: goal, Authority: authority, Requested: "repository.attach"},
+ FlowID: "flow-1", Parameters: protocol.Parameters{{Name: "topology", Value: string(model.TopologyDetached)}, {Name: "config_authority", Value: "repository"}}, AdmissionLifetime: time.Minute,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Receipt.ID == "" || result.Target.Phase.Value != model.PhaseObserved {
+ t.Fatalf("result = %#v", result)
+ }
+ layout, _, err := resolver.ResolveLayout(ctx, invocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{layout.StatePath, layout.ReceiptPath, layout.EventPath} {
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("expected V2 artifact %s: %v", path, err)
+ }
+ }
+}
+
+func TestExternalConfigurationAuthorityTransfersAcrossAttachAndDetach(t *testing.T) {
+ // control-law: detached-config-authority-selects-the-real-reader-and-writer
+ ctx := context.Background()
+ repository := testRepository(t)
+ externalRoot := t.TempDir()
+ kernel, err := boatstack.NewV2Kernel(externalRoot)
+ if err != nil {
+ t.Fatal(err)
+ }
+ goal := model.Goal{ID: "external-config-goal", Kind: model.GoalApprovedPlan, DeliveryID: "external-config"}
+ now := time.Now().UTC()
+ human := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{
+ ID: "external-config-human", Class: catalog.AuthorityHuman, Subject: "integration", Fingerprint: "explicit-human",
+ IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour),
+ }}}
+ apply := func(id catalog.TransitionID, authority protocol.AuthorityBundle, repositoryAuthority bool, parameters protocol.Parameters) surfaces.Response {
+ t.Helper()
+ response, handleErr := kernel.Handle(ctx, surfaces.Request{
+ SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationApply, Repository: repository, Host: "cli",
+ CorrelationID: "external-config-" + string(id), FlowID: "flow-external-config", Goal: goal, TransitionID: id,
+ Authority: authority, RepositoryAuthority: repositoryAuthority, Parameters: parameters,
+ })
+ if handleErr != nil {
+ t.Fatalf("apply %s: %v", id, handleErr)
+ }
+ return response
+ }
+ executable, _ := os.Executable()
+ executable, _ = filepath.Abs(executable)
+ executable, _ = filepath.EvalSymlinks(executable)
+ runtimeRaw, _ := os.ReadFile(executable)
+ 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: "config_path", Value: initialPath}, {Name: "config_sha256", Value: configFingerprint(t, initialConfig)},
+ })
+ apply("repository.attach", human, false, protocol.Parameters{{Name: "topology", Value: "detached"}, {Name: "config_authority", Value: "external"}})
+ resolver, err := plant.NewResolver(externalRoot)
+ if err != nil {
+ t.Fatal(err)
+ }
+ detachedInvocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "inspect-detached")
+ if err != nil {
+ t.Fatal(err)
+ }
+ detachedLayout, _, err := resolver.ResolveLayout(ctx, detachedInvocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if detachedLayout.ConfigAuthority != "external" || detachedLayout.ConfigPath == filepath.Join(repository, ".boatstack", "project.json") {
+ t.Fatalf("detached layout did not select external config: %#v", detachedLayout)
+ }
+ apply("engagement.begin", protocol.AuthorityBundle{}, true, nil)
+ updatedConfig := []byte("{\"schema_version\":2,\"project\":{\"name\":\"external-updated\",\"default_branch\":\"main\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n")
+ updatedPath := filepath.Join(t.TempDir(), "updated.json")
+ if err := os.WriteFile(updatedPath, updatedConfig, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ apply("configuration.mutate", human, false, protocol.Parameters{{Name: "config_path", Value: updatedPath}, {Name: "config_sha256", Value: configFingerprint(t, updatedConfig)}})
+ repositoryConfigPath := filepath.Join(repository, ".boatstack", "project.json")
+ if raw, err := os.ReadFile(repositoryConfigPath); err != nil || string(raw) != string(initialConfig) {
+ t.Fatalf("external mutation leaked into repository authority before detach: err=%v value=%q", err, raw)
+ }
+ apply("repository.detach", human, false, nil)
+ if raw, err := os.ReadFile(repositoryConfigPath); err != nil || string(raw) != string(updatedConfig) {
+ t.Fatalf("detach did not transfer external config authority: err=%v value=%q", err, raw)
+ }
+ embeddedInvocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "inspect-embedded")
+ if err != nil {
+ t.Fatal(err)
+ }
+ embeddedLayout, _, err := resolver.ResolveLayout(ctx, embeddedInvocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ canonicalRepositoryConfig, _ := filepath.EvalSymlinks(repositoryConfigPath)
+ if embeddedLayout.ConfigAuthority != "repository" || embeddedLayout.ConfigPath != canonicalRepositoryConfig {
+ t.Fatalf("detach did not restore repository config authority: %#v", embeddedLayout)
+ }
+}
+
+func TestConcreteWorkflowPreservesConfigurationProofAndGoalTerminals(t *testing.T) {
+ // control-law: successful-writes-remain-independently-verifiable-and-goal-specific
+ ctx := context.Background()
+ repository := testRepository(t)
+ clock := fixedClock{value: time.Unix(2000, 0).UTC()}
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "workflow-correlation")
+ if err != nil {
+ t.Fatal(err)
+ }
+ observer, _ := plant.NewObserver(resolver, clock)
+ locker, _ := effects.NewLocker(resolver)
+ journal, _ := effects.NewJournal(resolver, clock)
+ receipts, _ := effects.NewReceiptStore(resolver, clock)
+ driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary())
+ kernel, err := engine.New(catalog.Default(), observer, clock, locker, journal, driver, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ authority := func(class catalog.AuthorityClass) protocol.AuthorityBundle {
+ fingerprint := "fingerprint-" + string(class)
+ subject := "integration"
+ if class == catalog.AuthorityRepository {
+ subject = filepath.Join(repository, ".boatstack", "project.json")
+ raw, readErr := os.ReadFile(subject)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ fingerprint = configFingerprint(t, raw)
+ }
+ return protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{
+ ID: "authority-" + string(class), Class: class, Subject: subject, Fingerprint: fingerprint,
+ IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour),
+ }}}
+ }
+ apply := func(goal model.Goal, id catalog.TransitionID, auth protocol.AuthorityBundle, parameters protocol.Parameters) engine.ApplyResult {
+ t.Helper()
+ result, applyErr := kernel.Apply(ctx, engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Goal: goal, Authority: auth, Requested: id},
+ FlowID: "flow-workflow", Parameters: parameters, AdmissionLifetime: time.Minute,
+ })
+ if applyErr != nil {
+ t.Fatalf("apply %s: %v", id, applyErr)
+ }
+ return result
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ executable, _ = filepath.Abs(executable)
+ executable, _ = filepath.EvalSymlinks(executable)
+ runtimeRaw, _ := os.ReadFile(executable)
+ 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 {
+ t.Fatal(err)
+ }
+ 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: "config_path", Value: configPath}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)},
+ })
+ apply(approvedGoal, "engagement.begin", authority(catalog.AuthorityRepository), nil)
+
+ updatedConfigPath := filepath.Join(t.TempDir(), "project-v2-updated.json")
+ updatedConfig := []byte("{\"schema_version\":2,\"project\":{\"name\":\"integration-updated\",\"default_branch\":\"main\",\"commands\":{\"build\":\"go version\",\"test\":\"go version\"}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\"]}\n")
+ if err := os.WriteFile(updatedConfigPath, updatedConfig, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ configResult := apply(approvedGoal, "configuration.mutate", authority(catalog.AuthorityHuman), protocol.Parameters{
+ {Name: "config_path", Value: updatedConfigPath}, {Name: "config_sha256", Value: configFingerprint(t, updatedConfig)},
+ })
+ if configResult.Target.Configuration.Value != model.ConfigurationVerified {
+ t.Fatalf("configuration mutation invalidated itself: %s", configResult.Target.Configuration.Value)
+ }
+
+ planPath := filepath.Join(t.TempDir(), "plan.md")
+ planRaw := []byte("# Verified plan\n")
+ if err := os.WriteFile(planPath, planRaw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ apply(approvedGoal, "plan.create", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "source_path", Value: planPath}, {Name: "delivery_id", Value: approvedGoal.DeliveryID}})
+ apply(approvedGoal, "plan.validate", authority(catalog.AuthorityRepository), nil)
+ approved := apply(approvedGoal, "plan.approve", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "plan_fingerprint", Value: digestBytes(planRaw)}, {Name: "actor", Value: "integration-human"}})
+ if approved.Target.Terminal.Value != model.TerminalEstablished || approved.Target.Plan.Value != model.PlanApproved {
+ t.Fatalf("approved-plan terminal not established: %#v", approved.Target)
+ }
+
+ verifiedGoal := model.Goal{ID: "goal-verified", Kind: model.GoalVerified, DeliveryID: approvedGoal.DeliveryID}
+ apply(verifiedGoal, "goal.configure", authority(catalog.AuthorityHuman), protocol.Parameters{{Name: "goal_kind", Value: string(verifiedGoal.Kind)}, {Name: "delivery_id", Value: verifiedGoal.DeliveryID}})
+ apply(verifiedGoal, "plan.activate", authority(catalog.AuthorityHuman), nil)
+ head := strings.TrimSpace(commandOutput(t, repository, "git", "rev-parse", "HEAD"))
+ gateParameters := func(name string) protocol.Parameters {
+ evidenceRaw, marshalErr := json.Marshal(map[string]any{
+ "schema_version": 1, "gate": name, "source_revision": head,
+ "outcome": "passed", "producer": "integration", "completed_at": clock.Now(),
+ })
+ if marshalErr != nil {
+ t.Fatal(marshalErr)
+ }
+ evidencePath := filepath.Join(t.TempDir(), name+".json")
+ if writeErr := os.WriteFile(evidencePath, evidenceRaw, 0o600); writeErr != nil {
+ t.Fatal(writeErr)
+ }
+ return protocol.Parameters{
+ {Name: "source_revision", Value: head}, {Name: "evidence_path", Value: evidencePath},
+ {Name: "evidence_fingerprint", Value: digestBytes(evidenceRaw)},
+ }
+ }
+ apply(verifiedGoal, "gate.build.record", authority(catalog.AuthorityRepository), gateParameters("build"))
+ apply(verifiedGoal, "gate.test.record", authority(catalog.AuthorityRepository), gateParameters("test"))
+ verified := apply(verifiedGoal, "gate.review.record", authority(catalog.AuthorityRepository), gateParameters("review"))
+ if verified.Target.Terminal.Value != model.TerminalEstablished || verified.Target.Verification.Value != model.VerificationCurrent || verified.Target.Delivery.Value != model.DeliveryTerminal {
+ t.Fatalf("verified terminal not established: %#v", verified.Target)
+ }
+ if err := os.WriteFile(filepath.Join(repository, ".boatstack", "evidence", verifiedGoal.DeliveryID, "review.json"), []byte("tampered\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ tamperedObservation, err := observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tampered, err := model.Canonicalize(tamperedObservation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tampered.Verification.Value != model.VerificationStale || tampered.Terminal.Value != model.TerminalStale {
+ t.Fatalf("tampered gate remained authoritative: verification=%s terminal=%s", tampered.Verification.Value, tampered.Terminal.Value)
+ }
+ if tampered.Phase.Value != model.PhaseActive || tampered.Delivery.Value != model.DeliveryActive {
+ t.Fatalf("stale terminal had no re-verification path: phase=%s delivery=%s", tampered.Phase.Value, tampered.Delivery.Value)
+ }
+ reverified := apply(verifiedGoal, "gate.review.record", authority(catalog.AuthorityRepository), gateParameters("review"))
+ if reverified.Target.Terminal.Value != model.TerminalEstablished || reverified.Target.Verification.Value != model.VerificationCurrent {
+ t.Fatalf("repaired evidence did not re-establish the exact terminal: %#v", reverified.Target)
+ }
+}
+
+func TestWorkspaceCutTransfersAuthorityToExactDestinationWorktree(t *testing.T) {
+ // control-law: workspace-creation-transfers-controller-authority-in-one-transaction
+ ctx := context.Background()
+ repository := testRepository(t)
+ clock := fixedClock{value: time.Unix(3000, 0).UTC()}
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceInvocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "workspace-transfer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ observer, _ := plant.NewObserver(resolver, clock)
+ locker, _ := effects.NewLocker(resolver)
+ journal, _ := effects.NewJournal(resolver, clock)
+ receipts, _ := effects.NewReceiptStore(resolver, clock)
+ driver, _ := effects.NewDriver(resolver, clock, effects.NewNativeBoundary())
+ kernel, err := engine.New(catalog.Default(), observer, clock, locker, journal, driver, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ goal := model.Goal{ID: "goal-workspace", Kind: model.GoalMerged, DeliveryID: "delivery-workspace"}
+ human := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{
+ ID: "human-workspace", Class: catalog.AuthorityHuman, Subject: "operator", Fingerprint: "human-workspace-proof",
+ IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour),
+ }}}
+ apply := func(invocation model.InvocationContext, id catalog.TransitionID, authority protocol.AuthorityBundle, parameters protocol.Parameters) engine.ApplyResult {
+ t.Helper()
+ result, applyErr := kernel.Apply(ctx, engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Goal: goal, Authority: authority, Requested: id},
+ FlowID: "flow-workspace", Parameters: parameters, AdmissionLifetime: time.Minute,
+ })
+ if applyErr != nil {
+ t.Fatalf("apply %s: %v", id, applyErr)
+ }
+ return result
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ executable, _ = filepath.Abs(executable)
+ executable, _ = filepath.EvalSymlinks(executable)
+ runtimeRaw, _ := os.ReadFile(executable)
+ 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: "config_path", Value: configSource}, {Name: "config_sha256", Value: configFingerprint(t, configRaw)},
+ })
+ run(t, repository, "git", "add", ".boatstack/project.json")
+ run(t, repository, "git", "commit", "-q", "-m", "install V2 configuration")
+ repositoryAuthority := func(path string) protocol.AuthorityBundle {
+ raw, readErr := os.ReadFile(filepath.Join(path, ".boatstack", "project.json"))
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ return protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{
+ ID: "repository-workspace", Class: catalog.AuthorityRepository, Subject: filepath.Join(path, ".boatstack", "project.json"), Fingerprint: configFingerprint(t, raw),
+ IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour),
+ }}}
+ }
+ apply(sourceInvocation, "engagement.begin", repositoryAuthority(repository), nil)
+ destination := filepath.Join(t.TempDir(), "feature-worktree")
+ destinationParent, err := filepath.EvalSymlinks(filepath.Dir(destination))
+ if err != nil {
+ t.Fatal(err)
+ }
+ canonicalDestination := filepath.Join(destinationParent, filepath.Base(destination))
+ cut := apply(sourceInvocation, "workspace.cut", human, protocol.Parameters{
+ {Name: "branch", Value: "feature/v2-workspace-transfer"}, {Name: "base_ref", Value: "HEAD"}, {Name: "destination", Value: destination},
+ })
+ if cut.Target.Invocation.InvokingPath != canonicalDestination || cut.Target.Invocation.WorktreeID == sourceInvocation.WorktreeID || cut.Target.Workspace.Value != model.WorkspaceCut {
+ t.Fatalf("workspace authority did not transfer to destination: %#v", cut.Target)
+ }
+
+ sourceAfter, err := resolver.ResolveInvocation(ctx, repository, "cli", "source-after-transfer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceObservation, err := observer.Observe(ctx, ports.ObservationRequest{Invocation: sourceAfter})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sourceObservation.Phase.Value != model.PhaseDormant || sourceObservation.Engagement.Value != model.EngagementDormant || sourceObservation.Goal.Status != model.FactAbsent {
+ t.Fatalf("source checkout retained ambient workflow authority: %#v", sourceObservation)
+ }
+
+ destinationInvocation, err := resolver.ResolveInvocation(ctx, canonicalDestination, "cli", "destination-activation")
+ if err != nil {
+ t.Fatal(err)
+ }
+ destinationConfigPath := filepath.Join(canonicalDestination, ".boatstack", "project.json")
+ destinationConfig, err := os.ReadFile(destinationConfigPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lineEndingVariant := bytes.ReplaceAll(destinationConfig, []byte("\r\n"), []byte("\n"))
+ lineEndingVariant = bytes.ReplaceAll(lineEndingVariant, []byte("\n"), []byte("\r\n"))
+ if err := os.WriteFile(destinationConfigPath, lineEndingVariant, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ destinationObservation, err := observer.Observe(ctx, ports.ObservationRequest{Invocation: destinationInvocation})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if destinationObservation.Workspace.Value != model.WorkspaceCut || destinationObservation.Goal.Value != goal {
+ t.Fatalf("destination did not receive exact controller state: %#v", destinationObservation)
+ }
+ activated := apply(destinationInvocation, "workspace.activate", repositoryAuthority(canonicalDestination), protocol.Parameters{{Name: "branch", Value: "feature/v2-workspace-transfer"}})
+ if activated.Target.Workspace.Value != model.WorkspaceActive || activated.Target.Invocation.WorktreeID != destinationInvocation.WorktreeID {
+ t.Fatalf("destination activation failed: %#v", activated.Target)
+ }
+ if err := os.WriteFile(destinationConfigPath, destinationConfig, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ goal = model.Goal{ID: "goal-workspace-abandon", Kind: model.GoalAbandoned, DeliveryID: "delivery-workspace"}
+ apply(destinationInvocation, "goal.configure", human, protocol.Parameters{{Name: "goal_kind", Value: string(goal.Kind)}, {Name: "delivery_id", Value: goal.DeliveryID}})
+ abandoned := apply(destinationInvocation, "workspace.abandon", human, protocol.Parameters{{Name: "branch", Value: "feature/v2-workspace-transfer"}})
+ if abandoned.Target.Terminal.Value != model.TerminalEstablished || abandoned.Target.Workspace.Value != model.WorkspaceAbandoned {
+ t.Fatalf("workspace abandonment did not establish its configured terminal: %#v", abandoned.Target)
+ }
+ cleaned := apply(destinationInvocation, "workspace.cleanup", human, protocol.Parameters{{Name: "branch", Value: "feature/v2-workspace-transfer"}})
+ if cleaned.Target.Invocation.WorktreeID != sourceInvocation.WorktreeID || cleaned.Target.Workspace.Value != model.WorkspaceAbsent || cleaned.Target.Phase.Value != model.PhaseAbandoned {
+ t.Fatalf("cleanup did not return verified authority to source checkout: %#v", cleaned.Target)
+ }
+ if _, err := os.Stat(canonicalDestination); !os.IsNotExist(err) {
+ t.Fatalf("workspace destination still exists after verified cleanup: %v", err)
+ }
+ layout, _, err := resolver.ResolveLayout(ctx, destinationInvocation)
+ if err == nil {
+ t.Fatal("deleted destination invocation still resolved")
+ }
+ layout, _, err = resolver.ResolveLayout(ctx, cleaned.Target.Invocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ eventBytes, err := os.ReadFile(layout.EventPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Count(strings.TrimSpace(string(eventBytes)), "\n")+1 != 7 {
+ t.Fatalf("shared flow telemetry lost a cross-worktree transition: %s", eventBytes)
+ }
+}
+
+func digestBytes(value []byte) string {
+ digest := sha256.Sum256(value)
+ return hex.EncodeToString(digest[:])
+}
+
+func configFingerprint(t *testing.T, value []byte) string {
+ t.Helper()
+ _, fingerprint, err := protocol.ProjectConfigFingerprint(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fingerprint
+}
+
+func commandOutput(t *testing.T, directory, name string, arguments ...string) string {
+ t.Helper()
+ command := exec.Command(name, arguments...)
+ command.Dir = directory
+ value, err := command.Output()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(value)
+}
diff --git a/boatstack/internal/effects/io.go b/boatstack/internal/effects/io.go
new file mode 100644
index 0000000..aff065f
--- /dev/null
+++ b/boatstack/internal/effects/io.go
@@ -0,0 +1,80 @@
+package effects
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+func sha256Bytes(value []byte) string {
+ digest := sha256.Sum256(value)
+ return hex.EncodeToString(digest[:])
+}
+
+func encodeJSON(value any) ([]byte, error) {
+ raw, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append(raw, '\n'), nil
+}
+
+func atomicWrite(path string, value []byte, mode os.FileMode) error {
+ if !filepath.IsAbs(path) {
+ return fmt.Errorf("effect path must be absolute: %s", path)
+ }
+ directory := filepath.Dir(path)
+ if err := os.MkdirAll(directory, 0o700); err != nil {
+ return err
+ }
+ temporary, err := os.CreateTemp(directory, ".boatstack-v2-stage-*")
+ if err != nil {
+ return err
+ }
+ temporaryPath := temporary.Name()
+ cleanup := func() { _ = os.Remove(temporaryPath) }
+ defer cleanup()
+ if err := temporary.Chmod(mode); err != nil {
+ _ = temporary.Close()
+ return err
+ }
+ if _, err := temporary.Write(value); 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 := replaceFile(temporaryPath, path); err != nil {
+ return err
+ }
+ return syncDirectory(directory)
+}
+
+func readAllIfExists(path string) ([]byte, bool, os.FileMode, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, false, 0o600, nil
+ }
+ return nil, false, 0, err
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ return nil, false, 0, err
+ }
+ if !info.Mode().IsRegular() {
+ return nil, false, 0, fmt.Errorf("managed resource is not a regular file: %s", path)
+ }
+ value, err := io.ReadAll(file)
+ return value, true, info.Mode().Perm(), err
+}
diff --git a/boatstack/internal/effects/journal.go b/boatstack/internal/effects/journal.go
new file mode 100644
index 0000000..c5e63f5
--- /dev/null
+++ b/boatstack/internal/effects/journal.go
@@ -0,0 +1,224 @@
+package effects
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+type Journal struct {
+ resolver ports.InvocationResolver
+ clock ports.Clock
+ mu lockedMutex
+ active map[string]string
+}
+
+func NewJournal(resolver ports.InvocationResolver, clock ports.Clock) (*Journal, error) {
+ if resolver == nil || clock == nil {
+ return nil, fmt.Errorf("transaction journal requires resolver and clock")
+ }
+ return &Journal{resolver: resolver, clock: clock, active: map[string]string{}}, nil
+}
+
+type journalRecord struct {
+ SchemaVersion int `json:"schema_version"`
+ Admission protocol.Admission `json:"admission"`
+ TransitionID catalog.TransitionID `json:"transition_id"`
+ TransitionClass catalog.EventClass `json:"transition_class"`
+ Status string `json:"status"`
+ Mutations []ports.ResourceMutation `json:"mutations,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ ReceiptID string `json:"receipt_id,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func journalName(id, suffix string) (string, error) {
+ if !strings.HasPrefix(id, "adm-") || strings.ContainsAny(id, `/\\`) {
+ return "", fmt.Errorf("invalid admission identity %q", id)
+ }
+ return id + suffix, nil
+}
+
+func (j *Journal) pendingPath(ctx context.Context, admission protocol.Admission) (string, error) {
+ layout, _, err := j.resolver.ResolveLayout(ctx, admission.Invocation)
+ if err != nil {
+ return "", err
+ }
+ name, err := journalName(admission.ID, ".pending")
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(layout.JournalRoot, name), nil
+}
+
+func (j *Journal) Begin(ctx context.Context, admission protocol.Admission, transition catalog.Transition) error {
+ path, err := j.pendingPath(ctx, admission)
+ if err != nil {
+ return err
+ }
+ if _, statErr := os.Stat(path); statErr == nil {
+ return fmt.Errorf("transaction journal already exists for admission %s", admission.ID)
+ } else if !os.IsNotExist(statErr) {
+ return statErr
+ }
+ now := j.clock.Now().UTC()
+ record := journalRecord{SchemaVersion: 2, Admission: admission, TransitionID: transition.ID, TransitionClass: transition.Class, Status: "begun", CreatedAt: now, UpdatedAt: now}
+ raw, err := encodeJSON(record)
+ if err != nil {
+ return err
+ }
+ if err := atomicWrite(path, raw, 0o600); err != nil {
+ return err
+ }
+ name, err := journalName(admission.ID, ".pending")
+ if err != nil {
+ return err
+ }
+ j.bind(name, path)
+ return nil
+}
+
+func readJournal(path string) (journalRecord, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return journalRecord{}, err
+ }
+ var record journalRecord
+ decoder := json.NewDecoder(strings.NewReader(string(raw)))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&record); err != nil {
+ return journalRecord{}, err
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return journalRecord{}, fmt.Errorf("transaction journal %s contains trailing JSON", path)
+ }
+ if record.SchemaVersion != 2 || record.Admission.ID == "" || record.TransitionID == "" || !record.TransitionClass.Valid() || !record.TransitionClass.Controllable() || record.Status == "" {
+ return journalRecord{}, fmt.Errorf("invalid transaction journal %s", path)
+ }
+ if err := record.Admission.ValidateIdentity(); err != nil || record.Admission.TransitionID != record.TransitionID {
+ return journalRecord{}, fmt.Errorf("invalid transaction admission in %s: %v", path, err)
+ }
+ return record, nil
+}
+
+func (j *Journal) update(ctx context.Context, admissionID string, update func(*journalRecord)) error {
+ name, err := journalName(admissionID, ".pending")
+ if err != nil {
+ return err
+ }
+ // The admission is inside the journal, so locate it only under the explicit
+ // invocation layout supplied by the active engine through the in-memory index.
+ // A process restart uses the recovery scanner rather than this method.
+ return j.updateKnownPath(ctx, name, update)
+}
+
+func (j *Journal) updateKnownPath(ctx context.Context, name string, update func(*journalRecord)) error {
+ // Journal operations occur under the per-worktree kernel lock. Find the one
+ // pending record by its content identity across the current process's known
+ // layouts is intentionally not allowed; callers bind the path at Begin.
+ journalPath, ok := j.activePath(name)
+ if !ok {
+ return fmt.Errorf("transaction journal path is not bound for %s", name)
+ }
+ record, err := readJournal(journalPath)
+ if err != nil {
+ return err
+ }
+ update(&record)
+ record.UpdatedAt = j.clock.Now().UTC()
+ raw, err := encodeJSON(record)
+ if err != nil {
+ return err
+ }
+ return atomicWrite(journalPath, raw, 0o600)
+}
+
+func (j *Journal) activePath(name string) (string, bool) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ value, ok := j.active[name]
+ return value, ok
+}
+
+func (j *Journal) bind(name, path string) {
+ j.mu.Lock()
+ j.active[name] = path
+ j.mu.Unlock()
+}
+
+func (j *Journal) unbind(name string) {
+ j.mu.Lock()
+ delete(j.active, name)
+ j.mu.Unlock()
+}
+
+func (j *Journal) Stage(ctx context.Context, admissionID string, mutations []ports.ResourceMutation) error {
+ return j.update(ctx, admissionID, func(record *journalRecord) {
+ record.Status = "staged"
+ record.Mutations = append([]ports.ResourceMutation(nil), mutations...)
+ })
+}
+
+func (j *Journal) Mark(ctx context.Context, admissionID, status string) error {
+ if strings.TrimSpace(status) == "" {
+ return fmt.Errorf("journal status is required")
+ }
+ return j.update(ctx, admissionID, func(record *journalRecord) { record.Status = status })
+}
+
+func (j *Journal) finalize(ctx context.Context, admissionID, suffix, status, reason, receiptID string) error {
+ name, err := journalName(admissionID, ".pending")
+ if err != nil {
+ return err
+ }
+ path, ok := j.activePath(name)
+ if !ok {
+ return fmt.Errorf("transaction journal path is not bound for %s", admissionID)
+ }
+ record, err := readJournal(path)
+ if err != nil {
+ return err
+ }
+ record.Status, record.Reason, record.ReceiptID, record.UpdatedAt = status, reason, receiptID, j.clock.Now().UTC()
+ raw, err := encodeJSON(record)
+ if err != nil {
+ return err
+ }
+ finalPath := strings.TrimSuffix(path, ".pending") + suffix
+ if err := atomicWrite(finalPath, raw, 0o600); err != nil {
+ return err
+ }
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ j.unbind(name)
+ return nil
+}
+
+func (j *Journal) Commit(ctx context.Context, receipt protocol.TransitionReceipt) error {
+ return j.finalize(ctx, receipt.AdmissionID, ".committed", "committed", "", receipt.ID)
+}
+
+func (j *Journal) Abort(ctx context.Context, admissionID, reason string) error {
+ return j.finalize(ctx, admissionID, ".aborted", "aborted", reason, "")
+}
+
+func (j *Journal) RequireRecovery(ctx context.Context, admissionID, reason string) error {
+ return j.update(ctx, admissionID, func(record *journalRecord) {
+ record.Status, record.Reason = "recovery-required", reason
+ })
+}
+
+// scanJournal is used by recovery and tests. It never mutates a record.
+func scanJournal(path string) (journalRecord, error) { return readJournal(path) }
diff --git a/boatstack/internal/effects/locker.go b/boatstack/internal/effects/locker.go
new file mode 100644
index 0000000..4863686
--- /dev/null
+++ b/boatstack/internal/effects/locker.go
@@ -0,0 +1,105 @@
+package effects
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+)
+
+type Locker struct{ resolver ports.InvocationResolver }
+
+func NewLocker(resolver ports.InvocationResolver) (Locker, error) {
+ if resolver == nil {
+ return Locker{}, fmt.Errorf("effect locker requires an invocation resolver")
+ }
+ return Locker{resolver: resolver}, nil
+}
+
+type heldLock struct {
+ path string
+ file *os.File
+}
+
+type heldLocks struct{ values []heldLock }
+
+func (l *heldLocks) Release() error {
+ var first error
+ for index := len(l.values) - 1; index >= 0; index-- {
+ value := l.values[index]
+ if err := unlockFile(value.file); err != nil && first == nil {
+ first = fmt.Errorf("unlock %s: %w", value.path, err)
+ }
+ if err := value.file.Close(); err != nil && first == nil {
+ first = fmt.Errorf("close lock %s: %w", value.path, err)
+ }
+ }
+ l.values = nil
+ return first
+}
+
+func (l Locker) Acquire(ctx context.Context, invocation model.InvocationContext, resources []string) (ports.Lock, error) {
+ layout, _, err := l.resolver.ResolveLayout(ctx, invocation)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.MkdirAll(layout.LockRoot, 0o700); err != nil {
+ return nil, err
+ }
+ names := append([]string{"kernel"}, resources...)
+ sort.Strings(names)
+ unique := names[:0]
+ for _, name := range names {
+ name = strings.TrimSpace(name)
+ if name == "" || strings.ContainsAny(name, `/\\`) {
+ return nil, fmt.Errorf("invalid lock resource %q", name)
+ }
+ if len(unique) == 0 || unique[len(unique)-1] != name {
+ unique = append(unique, name)
+ }
+ }
+ held := &heldLocks{}
+ for _, name := range unique {
+ path := filepath.Join(layout.LockRoot, name+".lock")
+ file, openErr := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
+ if openErr != nil {
+ _ = held.Release()
+ return nil, fmt.Errorf("open lock %s: %w", name, openErr)
+ }
+ if lockErr := lockFile(file); lockErr != nil {
+ _ = file.Close()
+ _ = held.Release()
+ return nil, fmt.Errorf("acquire lock %s: %w", name, lockErr)
+ }
+ if truncateErr := file.Truncate(0); truncateErr != nil {
+ _ = unlockFile(file)
+ _ = file.Close()
+ _ = held.Release()
+ return nil, fmt.Errorf("truncate lock %s: %w", name, truncateErr)
+ }
+ if _, seekErr := file.Seek(0, 0); seekErr != nil {
+ _ = unlockFile(file)
+ _ = file.Close()
+ _ = held.Release()
+ return nil, fmt.Errorf("seek lock %s: %w", name, seekErr)
+ }
+ _, writeErr := fmt.Fprintf(file, "%s\n%s\n", invocation.Correlation, invocation.Ref)
+ syncErr := file.Sync()
+ if writeErr != nil || syncErr != nil {
+ _ = unlockFile(file)
+ _ = file.Close()
+ _ = held.Release()
+ if writeErr != nil {
+ return nil, writeErr
+ }
+ return nil, syncErr
+ }
+ held.values = append(held.values, heldLock{path: path, file: file})
+ }
+ return held, nil
+}
diff --git a/boatstack/internal/effects/locker_test.go b/boatstack/internal/effects/locker_test.go
new file mode 100644
index 0000000..3ef00f2
--- /dev/null
+++ b/boatstack/internal/effects/locker_test.go
@@ -0,0 +1,50 @@
+package effects
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/plant"
+)
+
+func TestKernelLockUsesProcessScopedHandleNotFilePresence(t *testing.T) {
+ repository := recoveryRepository(t)
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "lock-owner")
+ if err != nil {
+ t.Fatal(err)
+ }
+ locker, err := NewLocker(resolver)
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, err := locker.Acquire(context.Background(), invocation, []string{"state"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := locker.Acquire(context.Background(), invocation, []string{"state"}); err == nil {
+ t.Fatal("concurrent acquisition unexpectedly succeeded")
+ }
+ if err := first.Release(); err != nil {
+ t.Fatal(err)
+ }
+ layout, _, err := resolver.ResolveLayout(context.Background(), invocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(layout.LockRoot, "kernel.lock")); err != nil {
+ t.Fatalf("stable lock inode was removed: %v", err)
+ }
+ second, err := locker.Acquire(context.Background(), invocation, []string{"state"})
+ if err != nil {
+ t.Fatalf("released persistent lock could not be reacquired: %v", err)
+ }
+ if err := second.Release(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/boatstack/internal/effects/mutex.go b/boatstack/internal/effects/mutex.go
new file mode 100644
index 0000000..54ca2c2
--- /dev/null
+++ b/boatstack/internal/effects/mutex.go
@@ -0,0 +1,5 @@
+package effects
+
+import "sync"
+
+type lockedMutex struct{ sync.Mutex }
diff --git a/boatstack/internal/effects/prepared.go b/boatstack/internal/effects/prepared.go
new file mode 100644
index 0000000..fed30e8
--- /dev/null
+++ b/boatstack/internal/effects/prepared.go
@@ -0,0 +1,89 @@
+package effects
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+)
+
+type boundaryCall func(context.Context) (ports.EffectResult, error)
+
+type preparedEffect struct {
+ mutations []ports.ResourceMutation
+ boundary boundaryCall
+ verifyInvocation *model.InvocationContext
+ applied []ports.ResourceMutation
+ externalSettled bool
+}
+
+func (p *preparedEffect) Manifest() []ports.ResourceMutation {
+ result := make([]ports.ResourceMutation, len(p.mutations))
+ copy(result, p.mutations)
+ return result
+}
+
+func (p *preparedEffect) VerificationInvocation() (model.InvocationContext, bool) {
+ if p.verifyInvocation == nil {
+ return model.InvocationContext{}, false
+ }
+ return *p.verifyInvocation, true
+}
+
+func (p *preparedEffect) Execute(ctx context.Context) (ports.EffectResult, error) {
+ result := ports.EffectResult{Settlement: ports.EffectSettled}
+ if p.boundary != nil {
+ var err error
+ result, err = p.boundary(ctx)
+ if err != nil || result.Settlement == ports.EffectUnknown {
+ return result, err
+ }
+ p.externalSettled = true
+ }
+ ordered := append([]ports.ResourceMutation(nil), p.mutations...)
+ sort.SliceStable(ordered, func(i, j int) bool {
+ if ordered[i].InstallLast != ordered[j].InstallLast {
+ return !ordered[i].InstallLast
+ }
+ return ordered[i].Path < ordered[j].Path
+ })
+ for _, mutation := range ordered {
+ var err error
+ if mutation.Delete {
+ err = os.Remove(mutation.Path)
+ if os.IsNotExist(err) {
+ err = nil
+ }
+ } else {
+ err = atomicWrite(mutation.Path, mutation.Target, os.FileMode(mutation.Mode))
+ }
+ if err != nil {
+ return result, fmt.Errorf("install managed resource %s: %w", mutation.Path, err)
+ }
+ p.applied = append(p.applied, mutation)
+ }
+ return result, nil
+}
+
+func (p *preparedEffect) Rollback(context.Context) error {
+ var rollbackErrors []error
+ for index := len(p.applied) - 1; index >= 0; index-- {
+ mutation := p.applied[index]
+ if mutation.PriorExists {
+ if err := atomicWrite(mutation.Path, mutation.Prior, os.FileMode(mutation.Mode)); err != nil {
+ rollbackErrors = append(rollbackErrors, err)
+ }
+ } else if err := os.Remove(mutation.Path); err != nil && !os.IsNotExist(err) {
+ rollbackErrors = append(rollbackErrors, err)
+ }
+ }
+ p.applied = nil
+ if p.externalSettled {
+ rollbackErrors = append(rollbackErrors, fmt.Errorf("external effect settled and requires reconciliation or compensation"))
+ }
+ return errors.Join(rollbackErrors...)
+}
diff --git a/boatstack/internal/effects/prepared_test.go b/boatstack/internal/effects/prepared_test.go
new file mode 100644
index 0000000..bfd4a51
--- /dev/null
+++ b/boatstack/internal/effects/prepared_test.go
@@ -0,0 +1,43 @@
+package effects
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+)
+
+func TestPreparedEffectNeverAcceptsMixedEpochAtWriteBoundary(t *testing.T) {
+ // control-law: partial-local-install-restores-exact-prior-bytes
+ root := t.TempDir()
+ first := filepath.Join(root, "first.json")
+ authoritative := filepath.Join(root, "state.json")
+ blocker := filepath.Join(root, "not-a-directory")
+ if err := os.WriteFile(first, []byte("prior-first"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(authoritative, []byte("prior-state"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ prepared := &preparedEffect{mutations: []ports.ResourceMutation{
+ {Path: first, Prior: []byte("prior-first"), PriorExists: true, Target: []byte("target-first"), Mode: 0o600},
+ {Path: filepath.Join(blocker, "second.json"), Target: []byte("target-second"), Mode: 0o600},
+ {Path: authoritative, Prior: []byte("prior-state"), PriorExists: true, Target: []byte("target-state"), Mode: 0o600, InstallLast: true},
+ }}
+ if _, err := prepared.Execute(context.Background()); err == nil {
+ t.Fatal("injected write-boundary failure was accepted")
+ }
+ if err := prepared.Rollback(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ firstValue, _ := os.ReadFile(first)
+ stateValue, _ := os.ReadFile(authoritative)
+ if string(firstValue) != "prior-first" || string(stateValue) != "prior-state" {
+ t.Fatalf("mixed epoch survived rollback: first=%q state=%q", firstValue, stateValue)
+ }
+}
diff --git a/boatstack/internal/effects/receipts.go b/boatstack/internal/effects/receipts.go
new file mode 100644
index 0000000..d7a9e6e
--- /dev/null
+++ b/boatstack/internal/effects/receipts.go
@@ -0,0 +1,191 @@
+package effects
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+type ReceiptStore struct {
+ resolver ports.InvocationResolver
+ clock ports.Clock
+ // The engine serializes a worktree through the kernel lock. currentInvocation
+ // binds receipt operations to that exact Apply call without reverse lookup.
+ mu lockedMutex
+ currentInvocation map[string]receiptBinding
+}
+
+type receiptBinding struct {
+ admission protocol.Admission
+ layout ports.ControllerLayout
+}
+
+func NewReceiptStore(resolver ports.InvocationResolver, clock ports.Clock) (*ReceiptStore, error) {
+ if resolver == nil || clock == nil {
+ return nil, fmt.Errorf("receipt store requires resolver and clock")
+ }
+ return &ReceiptStore{resolver: resolver, clock: clock, currentInvocation: map[string]receiptBinding{}}, nil
+}
+
+func (s *ReceiptStore) Bind(ctx context.Context, flowID string, admission protocol.Admission) error {
+ layout, _, err := s.resolver.ResolveLayout(ctx, admission.Invocation)
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if current, exists := s.currentInvocation[flowID]; exists && current.admission.ID != admission.ID {
+ return fmt.Errorf("receipt flow %q is already bound to a different admission", flowID)
+ }
+ s.currentInvocation[flowID] = receiptBinding{admission: admission, layout: layout}
+ return nil
+}
+
+func (s *ReceiptStore) Unbind(flowID string) {
+ s.mu.Lock()
+ delete(s.currentInvocation, flowID)
+ s.mu.Unlock()
+}
+
+func (s *ReceiptStore) bindingForFlow(flowID string) (receiptBinding, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ binding, ok := s.currentInvocation[flowID]
+ return binding, ok
+}
+
+func (s *ReceiptStore) layoutForFlow(_ context.Context, flowID string) (ports.ControllerLayout, error) {
+ binding, ok := s.bindingForFlow(flowID)
+ if !ok {
+ return ports.ControllerLayout{}, fmt.Errorf("receipt flow %q is not bound to an admission", flowID)
+ }
+ return binding.layout, nil
+}
+
+func scanReceipts(path string, visit func(protocol.TransitionReceipt) error) error {
+ file, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ defer file.Close()
+ scanner := bufio.NewScanner(file)
+ buffer := make([]byte, 64*1024)
+ scanner.Buffer(buffer, 4*1024*1024)
+ for scanner.Scan() {
+ var receipt protocol.TransitionReceipt
+ if err := json.Unmarshal(scanner.Bytes(), &receipt); err != nil {
+ return fmt.Errorf("decode receipt stream: %w", err)
+ }
+ if err := receipt.Validate(); err != nil {
+ return fmt.Errorf("verify receipt stream: %w", err)
+ }
+ if err := visit(receipt); err != nil {
+ return err
+ }
+ }
+ return scanner.Err()
+}
+
+func (s *ReceiptStore) NextSequence(ctx context.Context, flowID string) (uint64, error) {
+ layout, err := s.layoutForFlow(ctx, flowID)
+ if err != nil {
+ return 0, err
+ }
+ var maximum uint64
+ err = scanReceipts(layout.ReceiptPath, func(receipt protocol.TransitionReceipt) error {
+ if receipt.FlowID == flowID && receipt.Sequence > maximum {
+ maximum = receipt.Sequence
+ }
+ return nil
+ })
+ return maximum + 1, err
+}
+
+func (s *ReceiptStore) FindByIdempotency(ctx context.Context, invocation model.InvocationContext, key string) (protocol.TransitionReceipt, bool, error) {
+ if key == "" {
+ return protocol.TransitionReceipt{}, false, nil
+ }
+ layout, _, err := s.resolver.ResolveLayout(ctx, invocation)
+ if err != nil {
+ return protocol.TransitionReceipt{}, false, err
+ }
+ var found protocol.TransitionReceipt
+ err = scanReceipts(layout.ReceiptPath, func(receipt protocol.TransitionReceipt) error {
+ if receipt.IdempotencyKey == key {
+ found = receipt
+ }
+ return nil
+ })
+ return found, found.ID != "", err
+}
+
+type processEvent struct {
+ SchemaVersion int `json:"schema_version"`
+ FlowID string `json:"flow_id"`
+ Sequence uint64 `json:"sequence"`
+ Timestamp time.Time `json:"timestamp"`
+ GoalID string `json:"goal_id"`
+ TransitionID string `json:"transition_id"`
+ SourceFingerprint string `json:"source_fingerprint"`
+ TargetFingerprint string `json:"target_fingerprint"`
+ Outcome string `json:"outcome"`
+ DurationNanoseconds int64 `json:"duration_nanoseconds"`
+ AuthorityClasses []string `json:"authority_classes,omitempty"`
+ Recovery string `json:"recovery,omitempty"`
+ Terminal string `json:"terminal"`
+ FailureClass string `json:"failure_class,omitempty"`
+}
+
+func appendLine(path string, value any, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, mode)
+ if err != nil {
+ return err
+ }
+ if _, err := file.Write(append(raw, '\n')); err != nil {
+ _ = file.Close()
+ return err
+ }
+ if err := file.Sync(); err != nil {
+ _ = file.Close()
+ return err
+ }
+ return file.Close()
+}
+
+func (s *ReceiptStore) Append(ctx context.Context, receipt protocol.TransitionReceipt) error {
+ layout, err := s.layoutForFlow(ctx, receipt.FlowID)
+ if err != nil {
+ return err
+ }
+ if err := appendLine(layout.ReceiptPath, receipt, 0o600); err != nil {
+ return err
+ }
+ event := processEvent{
+ SchemaVersion: 1, FlowID: receipt.FlowID, Sequence: receipt.Sequence, Timestamp: s.clock.Now().UTC(), GoalID: receipt.GoalID,
+ TransitionID: string(receipt.TransitionID), SourceFingerprint: receipt.SourceFingerprint, TargetFingerprint: receipt.TargetFingerprint,
+ Outcome: string(receipt.Outcome), DurationNanoseconds: receipt.DurationNanoseconds,
+ AuthorityClasses: append([]string(nil), receipt.AuthorityClasses...), Recovery: string(receipt.Recovery), Terminal: string(receipt.Terminal), FailureClass: receipt.FailureClass,
+ }
+ // Telemetry is passive and never changes transition success.
+ _ = appendLine(layout.EventPath, event, 0o600)
+ s.Unbind(receipt.FlowID)
+ return nil
+}
diff --git a/boatstack/internal/effects/recovery.go b/boatstack/internal/effects/recovery.go
new file mode 100644
index 0000000..c72cacc
--- /dev/null
+++ b/boatstack/internal/effects/recovery.go
@@ -0,0 +1,220 @@
+package effects
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+func (d Driver) prepareRecoveryReplay(ctx context.Context, layout ports.ControllerLayout, admission protocol.Admission, transition catalog.Transition) (ports.PreparedEffect, error) {
+ transactionID, _ := admission.Parameters.Get("transaction_id")
+ record, pendingPath, err := loadInterruptedJournal(layout, transactionID)
+ if err != nil {
+ return nil, err
+ }
+ if record.TransitionClass == catalog.EventOwnedExternal {
+ return nil, fmt.Errorf("transaction %s may have an external effect; reconcile or escalate instead of replaying it", transactionID)
+ }
+ if transition.ID == "workspace.reconcile" {
+ return d.prepareWorkspaceCutReconciliation(ctx, layout, admission, record, pendingPath)
+ }
+ resume := transition.ID == "recovery.resume"
+ if resume && len(record.Mutations) == 0 {
+ return nil, fmt.Errorf("transaction %s has no staged mutation manifest to resume", transactionID)
+ }
+ mutations := make([]ports.ResourceMutation, 0, len(record.Mutations)+2)
+ for _, original := range record.Mutations {
+ if err := validateRecoveryPath(layout, original.Path); err != nil {
+ return nil, err
+ }
+ var target []byte
+ deleteResource := false
+ if resume {
+ target = original.Target
+ deleteResource = original.Delete
+ } else if original.PriorExists {
+ target = original.Prior
+ } else {
+ deleteResource = true
+ }
+ mutation, mutationErr := mutationFor(original.Path, target, os.FileMode(original.Mode), original.InstallLast, deleteResource)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ }
+ closure, err := prepareJournalClosureFromRecord(pendingPath, record, string(transition.ID), d.clock.Now())
+ if err != nil {
+ return nil, err
+ }
+ mutations = append(mutations, closure...)
+ return &preparedEffect{mutations: mutations}, nil
+}
+
+func (d Driver) prepareWorkspaceCutReconciliation(ctx context.Context, layout ports.ControllerLayout, admission protocol.Admission, record journalRecord, pendingPath string) (ports.PreparedEffect, error) {
+ if record.TransitionID != "workspace.cut" {
+ return nil, fmt.Errorf("workspace reconciliation has no safe contract for interrupted transition %q", record.TransitionID)
+ }
+ destination, err := canonicalWorkspaceDestination(record.Admission)
+ if err != nil {
+ return nil, err
+ }
+ resume := false
+ var verificationInvocation *model.InvocationContext
+ if _, statErr := os.Stat(destination); statErr == nil {
+ current, resolveErr := d.resolver.ResolveInvocation(ctx, destination, admission.Invocation.Host, admission.Invocation.Correlation)
+ if resolveErr != nil {
+ return nil, fmt.Errorf("resolve partially created workspace: %w", resolveErr)
+ }
+ expectedID, deriveErr := model.DeriveWorktreeID(admission.Invocation.GitCommonID, destination)
+ if deriveErr != nil {
+ return nil, deriveErr
+ }
+ branch, _ := record.Admission.Parameters.Get("branch")
+ if current.RepositoryID != admission.Invocation.RepositoryID || current.GitCommonID != admission.Invocation.GitCommonID ||
+ current.WorktreeID != expectedID || current.Ref != "refs/heads/"+branch ||
+ current.ControllerID != admission.Invocation.ControllerID || current.Topology != admission.Invocation.Topology {
+ return nil, fmt.Errorf("partially created workspace identity conflicts with interrupted admission")
+ }
+ resume = true
+ verificationInvocation = ¤t
+ } else if !os.IsNotExist(statErr) {
+ return nil, statErr
+ }
+
+ mutations := make([]ports.ResourceMutation, 0, len(record.Mutations)+2)
+ for _, original := range record.Mutations {
+ if err := validateRecoveryPath(layout, original.Path); err != nil {
+ return nil, err
+ }
+ var target []byte
+ deleteResource := false
+ if resume {
+ target, deleteResource = original.Target, original.Delete
+ } else if original.PriorExists {
+ target = original.Prior
+ } else {
+ deleteResource = true
+ }
+ mutation, mutationErr := mutationFor(original.Path, target, os.FileMode(original.Mode), original.InstallLast, deleteResource)
+ if mutationErr != nil {
+ return nil, mutationErr
+ }
+ mutations = append(mutations, mutation)
+ }
+ closure, err := prepareJournalClosureFromRecord(pendingPath, record, string("workspace.reconcile"), d.clock.Now())
+ if err != nil {
+ return nil, err
+ }
+ mutations = append(mutations, closure...)
+ return &preparedEffect{mutations: mutations, verifyInvocation: verificationInvocation}, nil
+}
+
+func loadInterruptedJournal(layout ports.ControllerLayout, transactionID string) (journalRecord, string, error) {
+ name, err := journalName(transactionID, ".pending")
+ if err != nil {
+ return journalRecord{}, "", err
+ }
+ path := filepath.Join(layout.JournalRoot, name)
+ record, err := readJournal(path)
+ if err != nil {
+ return journalRecord{}, "", fmt.Errorf("read interrupted transaction %s: %w", transactionID, err)
+ }
+ if record.Admission.ID != transactionID {
+ return journalRecord{}, "", fmt.Errorf("interrupted transaction identity mismatch")
+ }
+ return record, path, nil
+}
+
+func prepareJournalClosure(layout ports.ControllerLayout, transactionID, outcome string, now time.Time, excludeAdmissionID string) ([]ports.ResourceMutation, error) {
+ var journals []struct {
+ path string
+ record journalRecord
+ }
+ if record, path, err := loadInterruptedJournal(layout, transactionID); err == nil {
+ journals = append(journals, struct {
+ path string
+ record journalRecord
+ }{path: path, record: record})
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ entries, err := os.ReadDir(layout.JournalRoot)
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".pending" || entry.Name() == excludeAdmissionID+".pending" || entry.Name() == transactionID+".pending" {
+ continue
+ }
+ path := filepath.Join(layout.JournalRoot, entry.Name())
+ record, readErr := readJournal(path)
+ if readErr != nil {
+ return nil, readErr
+ }
+ parent, ok := record.Admission.Parameters.Get("transaction_id")
+ if record.TransitionClass == catalog.EventRecovery && ok && parent == transactionID {
+ journals = append(journals, struct {
+ path string
+ record journalRecord
+ }{path: path, record: record})
+ }
+ }
+ if len(journals) == 0 {
+ return nil, fmt.Errorf("interrupted transaction group %s has no pending journal", transactionID)
+ }
+ var mutations []ports.ResourceMutation
+ for _, journal := range journals {
+ closure, closureErr := prepareJournalClosureFromRecord(journal.path, journal.record, outcome, now)
+ if closureErr != nil {
+ return nil, closureErr
+ }
+ mutations = append(mutations, closure...)
+ }
+ return mutations, nil
+}
+
+func prepareJournalClosureFromRecord(pendingPath string, record journalRecord, outcome string, now time.Time) ([]ports.ResourceMutation, error) {
+ record.Status = "recovered"
+ record.Reason = strings.TrimSpace(outcome)
+ record.UpdatedAt = now.UTC()
+ raw, err := encodeJSON(record)
+ if err != nil {
+ return nil, err
+ }
+ archivePath := strings.TrimSuffix(pendingPath, ".pending") + ".recovered"
+ archive, err := mutationFor(archivePath, raw, 0o600, false, false)
+ if err != nil {
+ return nil, err
+ }
+ removePending, err := mutationFor(pendingPath, nil, 0o600, true, true)
+ if err != nil {
+ return nil, err
+ }
+ return []ports.ResourceMutation{archive, removePending}, nil
+}
+
+func validateRecoveryPath(layout ports.ControllerLayout, path string) error {
+ if !filepath.IsAbs(path) {
+ return fmt.Errorf("interrupted transaction contains a non-absolute resource path")
+ }
+ for _, root := range []string{layout.RepositoryRoot, layout.StateRoot, layout.SharedRoot, layout.EmbeddedStateRoot, layout.ExternalStateRoot} {
+ if root == "" {
+ continue
+ }
+ relative, err := filepath.Rel(root, path)
+ if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
+ return nil
+ }
+ }
+ return fmt.Errorf("interrupted transaction resource escapes managed roots: %s", path)
+}
diff --git a/boatstack/internal/effects/recovery_test.go b/boatstack/internal/effects/recovery_test.go
new file mode 100644
index 0000000..4e3b62b
--- /dev/null
+++ b/boatstack/internal/effects/recovery_test.go
@@ -0,0 +1,166 @@
+package effects
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "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"
+)
+
+type recoveryClock struct{ value time.Time }
+
+func (c recoveryClock) Now() time.Time { return c.value }
+
+func recoveryRepository(t *testing.T) string {
+ t.Helper()
+ repository := t.TempDir()
+ for _, command := range [][]string{
+ {"git", "init", "-q"},
+ {"git", "config", "user.email", "boatstack@example.invalid"},
+ {"git", "config", "user.name", "Boatstack Test"},
+ } {
+ cmd := exec.Command(command[0], command[1:]...)
+ cmd.Dir = repository
+ if output, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("%v: %v: %s", command, err, output)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("recovery\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ for _, command := range [][]string{{"git", "add", "README.md"}, {"git", "commit", "-q", "-m", "fixture"}} {
+ cmd := exec.Command(command[0], command[1:]...)
+ cmd.Dir = repository
+ if output, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("%v: %v: %s", command, err, output)
+ }
+ }
+ return repository
+}
+
+func TestRestartRecoveryRollsBackExactPriorBytesAndArchivesJournal(t *testing.T) {
+ // control-law: interrupted-local-effect-has-restart-safe-exact-rollback
+ ctx := context.Background()
+ clock := recoveryClock{value: time.Unix(3000, 0).UTC()}
+ repository := recoveryRepository(t)
+ resolver, err := plant.NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalInvocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "before-restart")
+ if err != nil {
+ t.Fatal(err)
+ }
+ observer, _ := plant.NewObserver(resolver, clock)
+ initialObservation, err := observer.Observe(ctx, ports.ObservationRequest{Invocation: originalInvocation})
+ if err != nil {
+ t.Fatal(err)
+ }
+ initial, err := model.Canonicalize(initialObservation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ goal := model.Goal{ID: "goal-recovery", Kind: model.GoalVerified, DeliveryID: "delivery-recovery"}
+ authority := protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{
+ ID: "recovery-human", Class: catalog.AuthorityHuman, Subject: "fixture", Fingerprint: "recovery-human-fingerprint",
+ IssuedAt: clock.Now().Add(-time.Minute), ExpiresAt: clock.Now().Add(time.Hour),
+ }}}
+ transition, _ := catalog.Default().Lookup("installation.initialize")
+ executable, _ := os.Executable()
+ executable, _ = filepath.Abs(executable)
+ executable, _ = filepath.EvalSymlinks(executable)
+ runtimeRaw, _ := os.ReadFile(executable)
+ 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 {
+ t.Fatal(err)
+ }
+ _, configFingerprint, err := protocol.ProjectConfigFingerprint(configRaw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parameters := protocol.Parameters{
+ {Name: "source_revision", Value: "recovery-fixture"}, {Name: "runtime_path", Value: executable}, {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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ journalBeforeRestart, _ := NewJournal(resolver, clock)
+ driver, _ := NewDriver(resolver, clock, NewNativeBoundary())
+ if err := journalBeforeRestart.Begin(ctx, admission, transition); err != nil {
+ t.Fatal(err)
+ }
+ if err := journalBeforeRestart.Mark(ctx, admission.ID, "executing"); err != nil {
+ t.Fatal(err)
+ }
+ prepared, err := driver.Prepare(ctx, admission, transition)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := journalBeforeRestart.Stage(ctx, admission.ID, prepared.Manifest()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := prepared.Execute(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err := journalBeforeRestart.RequireRecovery(ctx, admission.ID, "simulated process loss after effect"); err != nil {
+ t.Fatal(err)
+ }
+
+ restartedInvocation, err := resolver.ResolveInvocation(ctx, repository, "cli", "after-restart")
+ if err != nil {
+ t.Fatal(err)
+ }
+ recoveryObservation, err := observer.Observe(ctx, ports.ObservationRequest{Invocation: restartedInvocation})
+ if err != nil {
+ t.Fatal(err)
+ }
+ recoverySnapshot, err := model.Canonicalize(recoveryObservation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if recoverySnapshot.Phase.Value != model.PhaseRecovery || recoverySnapshot.RecoveryInfo.Value.TransactionID != admission.ID {
+ t.Fatalf("restart did not expose exact recovery identity: %#v", recoverySnapshot)
+ }
+
+ locker, _ := NewLocker(resolver)
+ journalAfterRestart, _ := NewJournal(resolver, clock)
+ receipts, _ := NewReceiptStore(resolver, clock)
+ restartedEngine, err := engine.New(catalog.Default(), observer, clock, locker, journalAfterRestart, driver, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := restartedEngine.Apply(ctx, engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: restartedInvocation, Goal: goal, Authority: authority, Requested: "recovery.rollback"},
+ FlowID: "flow-recovery", Parameters: protocol.Parameters{{Name: "transaction_id", Value: admission.ID}}, AdmissionLifetime: time.Minute,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Target.Phase.Value != model.PhaseDormant || result.Target.Recovery.Value != model.RecoveryNone || result.Receipt.ID == "" {
+ t.Fatalf("rollback target=%#v receipt=%q", result.Target, result.Receipt.ID)
+ }
+ layout, _, _ := resolver.ResolveLayout(ctx, restartedInvocation)
+ pending := filepath.Join(layout.JournalRoot, admission.ID+".pending")
+ recovered := filepath.Join(layout.JournalRoot, admission.ID+".recovered")
+ if _, err := os.Stat(pending); !os.IsNotExist(err) {
+ t.Fatalf("interrupted journal remains pending: %v", err)
+ }
+ if _, err := os.Stat(recovered); err != nil {
+ t.Fatalf("recovered journal missing: %v", err)
+ }
+ if _, err := os.Stat(layout.StatePath); !os.IsNotExist(err) {
+ t.Fatalf("rollback did not restore absent state file: %v", err)
+ }
+}
diff --git a/boatstack/internal/effects/replace_unix.go b/boatstack/internal/effects/replace_unix.go
new file mode 100644
index 0000000..1da2fec
--- /dev/null
+++ b/boatstack/internal/effects/replace_unix.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package effects
+
+import "os"
+
+func replaceFile(source, destination string) error { return os.Rename(source, destination) }
diff --git a/boatstack/atomic_windows.go b/boatstack/internal/effects/replace_windows.go
similarity index 78%
rename from boatstack/atomic_windows.go
rename to boatstack/internal/effects/replace_windows.go
index a3d1e79..4e82033 100644
--- a/boatstack/atomic_windows.go
+++ b/boatstack/internal/effects/replace_windows.go
@@ -1,6 +1,6 @@
//go:build windows
-package boatstack
+package effects
import (
"fmt"
@@ -25,12 +25,11 @@ func replaceFile(source, destination string) error {
return err
}
result, _, callErr := moveFileExW.Call(
- uintptr(unsafe.Pointer(sourcePointer)),
- uintptr(unsafe.Pointer(destinationPointer)),
+ uintptr(unsafe.Pointer(sourcePointer)), uintptr(unsafe.Pointer(destinationPointer)),
uintptr(moveFileReplaceExisting|moveFileWriteThrough),
)
if result == 0 {
- return fmt.Errorf("atomic replace failed: %w", callErr)
+ return fmt.Errorf("MoveFileExW: %w", callErr)
}
return nil
}
diff --git a/boatstack/internal/kernel/catalog/completeness_test.go b/boatstack/internal/kernel/catalog/completeness_test.go
new file mode 100644
index 0000000..6673487
--- /dev/null
+++ b/boatstack/internal/kernel/catalog/completeness_test.go
@@ -0,0 +1,228 @@
+package catalog
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+func sourceRoot(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("cannot locate source inventory")
+ }
+ return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
+}
+
+func TestEveryControllingFacetAndEventIsClassifiedByTheRuntimeCatalog(t *testing.T) {
+ // control-law: runtime-catalog-is-the-complete-executable-model
+ registry := Default()
+ classified := map[model.FacetName]bool{model.FacetPhase: true}
+ families := map[string]int{}
+ for _, transition := range registry.All() {
+ for _, condition := range append(append([]FacetCondition(nil), transition.SourceConditions...), transition.TargetConditions...) {
+ classified[condition.Facet] = true
+ }
+ family := familyFor(transition.ID)
+ families[family]++
+ if transition.Controllable() && transition.Effect != EffectID(transition.ID) {
+ t.Errorf("transition %s effect=%s; effect identity must be exact", transition.ID, transition.Effect)
+ }
+ if transition.Class == EventOwnedExternal && !containsAuthority(transition.AuthorityAll, AuthorityProvider) {
+ t.Errorf("external transition %s does not require provider authority", transition.ID)
+ }
+ }
+ for _, facet := range model.ControllingFacets() {
+ if !classified[facet] {
+ t.Errorf("controlling facet %s is absent from executable predicates", facet)
+ }
+ }
+ want := map[string]int{"invocation-engagement": 6, "installation-runtime-configuration": 8, "goal-plan": 9, "workspace": 8, "gate-evidence-delivery": 8, "publication": 6, "recovery": 3, "external": 13}
+ for family, count := range want {
+ if families[family] != count {
+ t.Errorf("family %s=%d, want %d", family, families[family], count)
+ }
+ }
+}
+
+func containsAuthority(values []AuthorityClass, wanted AuthorityClass) bool {
+ for _, value := range values {
+ if value == wanted {
+ return true
+ }
+ }
+ return false
+}
+
+func familyFor(id TransitionID) string {
+ value := string(id)
+ switch {
+ case strings.HasPrefix(value, "engagement."), strings.HasPrefix(value, "invocation."), strings.HasPrefix(value, "repository."):
+ return "invocation-engagement"
+ case strings.HasPrefix(value, "installation."), strings.HasPrefix(value, "runtime."), strings.HasPrefix(value, "configuration."):
+ return "installation-runtime-configuration"
+ case strings.HasPrefix(value, "goal."), strings.HasPrefix(value, "plan."):
+ return "goal-plan"
+ case strings.HasPrefix(value, "workspace."):
+ return "workspace"
+ case strings.HasPrefix(value, "gate."), strings.HasPrefix(value, "evidence."), strings.HasPrefix(value, "delivery."):
+ return "gate-evidence-delivery"
+ case strings.HasPrefix(value, "publication."):
+ return "publication"
+ case strings.HasPrefix(value, "recovery."):
+ return "recovery"
+ case strings.HasPrefix(value, "external."):
+ return "external"
+ default:
+ return "unclassified"
+ }
+}
+
+func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *testing.T) {
+ // control-law: adapters-cannot-grow-a-second-controller-or-writer
+ root := sourceRoot(t)
+ writerCalls := map[string]bool{
+ "WriteFile": true, "Rename": true, "Remove": true, "RemoveAll": true, "Mkdir": true, "MkdirAll": true,
+ "Create": true, "CreateTemp": true, "MkdirTemp": true, "OpenFile": true,
+ }
+ classifiedFiles := 0
+ err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ relative, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ relative = filepath.ToSlash(relative)
+ if !classifiedProductionFile(relative) {
+ t.Errorf("production source %s has no V2 ownership classification", relative)
+ }
+ classifiedFiles++
+ parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
+ if err != nil {
+ return err
+ }
+ imports := map[string]string{}
+ for _, item := range parsed.Imports {
+ value, _ := strconv.Unquote(item.Path.Value)
+ name := filepath.Base(value)
+ if item.Name != nil {
+ name = item.Name.Name
+ }
+ imports[name] = value
+ if strings.Contains(value, "/internal/deliverycontrol") {
+ t.Errorf("%s imports deleted shadow controller %s", relative, value)
+ }
+ }
+ ast.Inspect(parsed, func(node ast.Node) bool {
+ selector, ok := node.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ owner, ok := selector.X.(*ast.Ident)
+ if !ok {
+ return true
+ }
+ importPath := imports[owner.Name]
+ if importPath == "os" && writerCalls[selector.Sel.Name] && !strings.HasPrefix(relative, "internal/effects/") {
+ 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" {
+ t.Errorf("unclassified command boundary in %s", relative)
+ }
+ }
+ if strings.HasSuffix(importPath, "/internal/kernel/model") && lifecycleSelector(selector.Sel.Name) &&
+ !strings.HasPrefix(relative, "internal/kernel/") && !strings.HasPrefix(relative, "internal/effects/") && !strings.HasPrefix(relative, "internal/plant/") {
+ t.Errorf("lifecycle selector model.%s escaped kernel/plant/effects in %s", selector.Sel.Name, relative)
+ }
+ return true
+ })
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if classifiedFiles == 0 {
+ t.Fatal("source inventory was empty")
+ }
+ if entries, err := os.ReadDir(filepath.Join(root, "internal", "deliverycontrol")); err == nil && len(entries) != 0 {
+ t.Fatalf("deleted shadow controller still contains %d entries", len(entries))
+ } else if err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+}
+
+func TestEveryControllableRuntimeEventHasAnExecutableStateReducer(t *testing.T) {
+ // control-law: registry-entry-cannot-exist-without-runtime-effect-reduction
+ path := filepath.Join(sourceRoot(t), "internal", "kernel", "reducer", "reducer.go")
+ parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ covered := map[TransitionID]bool{}
+ registry := Default()
+ for _, declaration := range parsed.Decls {
+ function, ok := declaration.(*ast.FuncDecl)
+ if !ok || function.Name.Name != "Apply" {
+ continue
+ }
+ ast.Inspect(function.Body, func(node ast.Node) bool {
+ clause, ok := node.(*ast.CaseClause)
+ if !ok {
+ return true
+ }
+ for _, expression := range clause.List {
+ literal, ok := expression.(*ast.BasicLit)
+ if !ok || literal.Kind != token.STRING {
+ continue
+ }
+ value, unquoteErr := strconv.Unquote(literal.Value)
+ if unquoteErr != nil {
+ t.Fatal(unquoteErr)
+ }
+ id := TransitionID(value)
+ if transition, exists := registry.Lookup(id); exists && transition.Controllable() {
+ covered[id] = true
+ }
+ }
+ return true
+ })
+ }
+ for _, transition := range registry.All() {
+ if transition.Controllable() && !covered[transition.ID] {
+ t.Errorf("controllable transition %s has no applyStateTransition reducer case", transition.ID)
+ }
+ }
+}
+
+func classifiedProductionFile(relative string) bool {
+ return relative == "v2_kernel.go" || strings.HasPrefix(relative, "cmd/boatstack-helper/") ||
+ strings.HasPrefix(relative, "internal/kernel/") || strings.HasPrefix(relative, "internal/plant/") ||
+ strings.HasPrefix(relative, "internal/effects/") || strings.HasPrefix(relative, "internal/surfaces/") ||
+ strings.HasPrefix(relative, "internal/retromine/") || strings.HasPrefix(relative, "sdk/") ||
+ strings.HasPrefix(relative, "analysis/")
+}
+
+func lifecycleSelector(name string) bool {
+ for _, prefix := range []string{"Phase", "Engagement", "Delivery", "Workspace", "Plan", "Configuration", "Runtime", "Publication", "Verification", "Recovery", "Transaction", "Terminal"} {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/boatstack/internal/kernel/catalog/default.go b/boatstack/internal/kernel/catalog/default.go
new file mode 100644
index 0000000..424e609
--- /dev/null
+++ b/boatstack/internal/kernel/catalog/default.go
@@ -0,0 +1,345 @@
+package catalog
+
+import (
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+const DefaultTransitionCount = 61
+
+type seed struct {
+ id TransitionID
+ class EventClass
+ source []model.ProtocolPhase
+ target []model.ProtocolPhase
+ authority []AuthorityClass
+ resource string
+ goals []model.GoalKind
+ priority int
+}
+
+var activeSources = []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseUnresolved}
+var managedSources = []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseRecovery, model.PhaseFrontier, model.PhaseUnresolved}
+
+func Default() Registry {
+ registry, err := New(defaultTransitions())
+ if err != nil {
+ panic(err)
+ }
+ if registry.Len() != DefaultTransitionCount {
+ panic("boatstack V2 default transition count drifted")
+ }
+ return registry
+}
+
+func defaultTransitions() []Transition {
+ allGoals := []model.GoalKind{model.GoalApprovedPlan, model.GoalVerified, model.GoalOpenPR, model.GoalMerged, model.GoalAbandoned}
+ seeds := []seed{
+ {"engagement.begin", EventAuthority, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "engagement", allGoals, 10},
+ {"engagement.renew", EventAuthority, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository, AuthorityAutonomy}, "engagement", allGoals, 70},
+ {"engagement.release", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseDormant}, []AuthorityClass{AuthorityRepository}, "engagement", allGoals, 95},
+ {"invocation.rebind", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityRepository}, "identity-binding", allGoals, 15},
+ {"repository.attach", EventOwnedLocal, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityHuman}, "repository-binding", allGoals, 12},
+ {"repository.detach", EventOwnedLocal, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseDormant}, []AuthorityClass{AuthorityHuman}, "repository-binding", allGoals, 96},
+
+ {"runtime.hydrate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityRepository}, "runtime", allGoals, 20},
+ {"runtime.replace", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseRecovery}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "runtime", allGoals, 25},
+ {"runtime.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseFrontier, model.PhaseTerminal}, []AuthorityClass{AuthorityRepository}, "runtime", allGoals, 4},
+ {"configuration.initialize", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "configuration", allGoals, 22},
+ {"configuration.mutate", EventOwnedLocal, activeSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "configuration", allGoals, 60},
+ {"configuration.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseFrontier, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "configuration", allGoals, 3},
+ {"installation.initialize", EventOwnedLocal, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityHuman}, "installation", allGoals, 11},
+ {"installation.update", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "installation", allGoals, 65},
+
+ {"goal.configure", EventAuthority, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "goal", allGoals, 30},
+ {"plan.create", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "plan", allGoals, 35},
+ {"plan.validate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityRepository}, "plan-evidence", allGoals, 40},
+ {"plan.approve", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "approval", allGoals, 45},
+ {"plan.activate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "delivery-state", []model.GoalKind{model.GoalVerified, model.GoalOpenPR, model.GoalMerged}, 50},
+ {"plan.amend", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "plan", allGoals, 42},
+ {"plan.approve-amendment", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "approval", allGoals, 46},
+ {"plan.invalidate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive, model.PhaseObserved}, []model.ProtocolPhase{model.PhaseFrontier}, []AuthorityClass{AuthorityRepository}, "plan-evidence", allGoals, 41},
+ {"plan.abandon", EventAuthority, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "plan", []model.GoalKind{model.GoalAbandoned}, 90},
+
+ {"workspace.cut", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "workspace", allGoals, 52},
+ {"workspace.sync", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "workspace", allGoals, 58},
+ {"workspace.activate", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "workspace", allGoals, 53},
+ {"workspace.publish", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "workspace-state", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 75},
+ {"workspace.cleanup", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal, model.PhaseAbandoned}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "workspace", allGoals, 92},
+ {"workspace.reap", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal, model.PhaseAbandoned}, []model.ProtocolPhase{model.PhaseObserved, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "workspace", allGoals, 98},
+ {"workspace.abandon", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "workspace", []model.GoalKind{model.GoalAbandoned}, 91},
+ {"workspace.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "workspace", allGoals, 2},
+
+ {"gate.build.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 61},
+ {"gate.test.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 62},
+ {"gate.review.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "gate-evidence", allGoals, 63},
+ {"gate.change.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 64},
+ {"gate.journey.record", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "gate-evidence", allGoals, 64},
+ {"evidence.visual.attach", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "evidence", allGoals, 66},
+ {"evidence.approval.revoke", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseFrontier}, []AuthorityClass{AuthorityHuman}, "approval", allGoals, 44},
+ {"delivery.slice.advance", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "delivery-state", allGoals, 68},
+
+ {"publication.preview", EventOwnedLocal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive}, []AuthorityClass{AuthorityRepository}, "publication-preview", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 72},
+ {"publication.execute", EventOwnedExternal, []model.ProtocolPhase{model.PhaseActive}, []model.ProtocolPhase{model.PhaseActive, model.PhaseRecovery}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "publication", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 76},
+ {"publication.observe", EventOwnedLocal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal, model.PhaseFrontier, model.PhaseUnresolved}, []AuthorityClass{AuthorityRepository}, "publication-evidence", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 77},
+ {"publication.reconcile", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseActive, model.PhaseTerminal, model.PhaseFrontier, model.PhaseUnresolved}, []AuthorityClass{AuthorityHuman, AuthorityProvider}, "publication", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 1},
+ {"publication.correct", EventOwnedExternal, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []model.ProtocolPhase{model.PhaseActive, model.PhaseRecovery}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy}, "publication", []model.GoalKind{model.GoalOpenPR, model.GoalMerged}, 80},
+ {"publication.abandon", EventAuthority, []model.ProtocolPhase{model.PhaseActive, model.PhaseFrontier}, []model.ProtocolPhase{model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman}, "publication", []model.GoalKind{model.GoalAbandoned}, 93},
+
+ {"recovery.resume", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery}, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityAutonomy, AuthorityRepository}, "recovery-journal", allGoals, 2},
+ {"recovery.rollback", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery}, []model.ProtocolPhase{model.PhaseDormant, model.PhaseObserved, model.PhaseActive, model.PhaseFrontier, model.PhaseTerminal, model.PhaseAbandoned}, []AuthorityClass{AuthorityHuman, AuthorityRepository}, "recovery-journal", allGoals, 3},
+ {"recovery.escalate", EventRecovery, []model.ProtocolPhase{model.PhaseRecovery, model.PhaseUnresolved}, []model.ProtocolPhase{model.PhaseFrontier}, []AuthorityClass{AuthorityRepository}, "recovery-journal", allGoals, 5},
+
+ {"external.files-changed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.head-changed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.branch-changed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.runtime-disappeared", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseRecovery}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.configuration-drifted", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseUnresolved}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.lease-expired", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseDormant, model.PhaseFrontier}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.host-interrupted", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseRecovery}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.ci-completed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.pr-opened", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.pr-updated", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.pr-closed", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseFrontier}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.pr-merged", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseObserved, model.PhaseActive, model.PhaseTerminal}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ {"external.provider-unavailable", EventObservedExternal, managedSources, []model.ProtocolPhase{model.PhaseUnresolved, model.PhaseRecovery}, []AuthorityClass{AuthorityNone}, "", allGoals, 100},
+ }
+
+ transitions := make([]Transition, 0, len(seeds))
+ for _, item := range seeds {
+ transitions = append(transitions, materialize(item))
+ }
+ return transitions
+}
+
+func materialize(item seed) Transition {
+ controllable := item.class.Controllable()
+ effect := EffectID("")
+ var localEffects, externalEffects []EffectID
+ resources := []string(nil)
+ reversibility := ObservationOnly
+ recovery := TransitionID("")
+ points := []string{"after-observation"}
+ if controllable {
+ effect = EffectID(item.id)
+ resources = []string{item.resource}
+ reversibility = Reversible
+ points = []string{"after-lock", "after-stage", "after-effect", "before-receipt"}
+ if item.class == EventOwnedExternal {
+ externalEffects = []EffectID{effect}
+ reversibility = Compensatable
+ points = []string{"before-request", "after-request", "before-settlement-observation", "before-receipt"}
+ } else {
+ localEffects = []EffectID{effect}
+ }
+ if item.class == EventRecovery {
+ recovery = "recovery.escalate"
+ } else {
+ recovery = interruptionRecovery(item)
+ }
+ }
+ transition := Transition{
+ ID: item.id, Version: 1, Class: item.class, SourcePhases: item.source, TargetPhases: item.target,
+ GoalKinds: item.goals, RequiredIdentity: invocationIdentityRequirements(), Authority: item.authority,
+ RequiredEvidence: []string{"invocation-context", "snapshot-fingerprint", "goal"},
+ OwnedResources: resources, Effect: effect, Idempotent: controllable,
+ LocalEffects: localEffects, ExternalEffects: externalEffects,
+ Prescription: Prescription{Operation: string(item.id), ExpectedPostcondition: "predicate:target-phase:" + string(item.id)},
+ SourcePredicate: "predicate:source-phase:" + string(item.id), AdmissionPredicate: "predicate:exact-admission:" + string(item.id), TargetPredicate: "predicate:target-phase:" + string(item.id),
+ Verifier: "verifier:fresh-observation:" + string(item.id),
+ Interruption: interruptionContract(item, points, recovery),
+ Reversibility: reversibility, TerminalEffect: terminalEffect(item.id), PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt",
+ CostClass: "declared-neutral", Priority: item.priority,
+ }
+ transition.SourceConditions = sourceConditions(item.id)
+ if item.class.Controllable() && item.class != EventRecovery && !isSafeAbandonment(item.id) {
+ transition.SourceConditions = append(transition.SourceConditions,
+ known(model.FacetRecovery, string(model.RecoveryNone)),
+ known(model.FacetTransaction, string(model.TransactionNone)),
+ )
+ }
+ if item.class.Controllable() && item.class != EventRecovery && item.id != "goal.configure" {
+ terminalValues := []string{string(model.TerminalNonterminal)}
+ if allowsStaleTerminalRepair(item.id) {
+ terminalValues = append(terminalValues, string(model.TerminalStale))
+ }
+ if allowsPostTerminalMaintenance(item.id) {
+ terminalValues = append(terminalValues, string(model.TerminalEstablished))
+ }
+ transition.SourceConditions = append(transition.SourceConditions, known(model.FacetTerminal, terminalValues...))
+ }
+ if requiresEngagement(item.id) {
+ transition.SourceConditions = append(transition.SourceConditions, known(model.FacetEngagement, string(model.EngagementCommand), string(model.EngagementActive)))
+ transition.SourceConditions = append(transition.SourceConditions, known(model.FacetGoal))
+ }
+ if requiresHealthyConfiguration(item.id) {
+ transition.SourceConditions = append(transition.SourceConditions, known(model.FacetConfiguration, string(model.ConfigurationVerified)))
+ }
+ if requiresHealthyRuntime(item.id) {
+ transition.SourceConditions = append(transition.SourceConditions, known(model.FacetRuntime, string(model.RuntimeVerified)))
+ }
+ if usesConfigurationPolicy(item.id) {
+ transition.SourceConditions = append(transition.SourceConditions, known(model.FacetConfigurationPolicy))
+ }
+ transition.TargetConditions = targetConditions(item.id)
+ transition.AuthorityAll = requiredAuthorities(item.id)
+ for _, condition := range transition.SourceConditions {
+ transition.RequiredEvidence = append(transition.RequiredEvidence, "facet:"+string(condition.Facet))
+ }
+ transition.Parameters = parameterSpecs(item.id)
+ if item.id == "repository.attach" || item.id == "repository.detach" || item.id == "invocation.rebind" {
+ transition.AllowsIdentityRebind = true
+ }
+ if item.id == "workspace.cut" || item.id == "workspace.cleanup" || item.id == "workspace.reap" || item.id == "workspace.reconcile" {
+ transition.AllowsWorktreeTransfer = true
+ }
+ return transition
+}
+
+func isSafeAbandonment(id TransitionID) bool {
+ return id == "goal.configure" || id == "plan.abandon" || id == "publication.abandon" || id == "workspace.abandon"
+}
+
+func invocationIdentityRequirements() []string {
+ return []string{"repository-id", "git-common-id", "worktree-id", "ref", "controller-id", "invoking-path", "runtime-path", "runtime-fingerprint", "topology", "host", "correlation-id"}
+}
+
+func interruptionContract(item seed, points []string, recovery TransitionID) InterruptionContract {
+ contract := InterruptionContract{
+ Points: points, PartialState: []string{"no-owned-partial-state"}, Detection: "fresh-canonical-observation",
+ ResumeContract: "not-applicable", RollbackContract: "not-applicable", CompensationContract: "not-applicable",
+ Recovery: recovery, RecoveryAuthority: "not-applicable", ResumptionPredicate: "predicate:target-phase:" + string(item.id),
+ }
+ if !item.class.Controllable() {
+ return contract
+ }
+ contract.PartialState = []string{"journal-begun", "effect-staged", "effect-possibly-installed", "postcondition-unreceipted"}
+ contract.Detection = "pending-journal-plus-fresh-canonical-observation"
+ contract.ResumeContract = "journal-target-replay-when-permitted"
+ contract.RollbackContract = "exact-prior-byte-replay-when-permitted"
+ contract.CompensationContract = "not-required-for-owned-local-effects"
+ contract.RecoveryAuthority = "declared-by:" + string(recovery)
+ contract.ResumptionPredicate = "recovery-contract-for:" + string(item.id)
+ if item.class == EventOwnedExternal {
+ contract.PartialState = []string{"request-not-sent", "request-possibly-accepted", "provider-settlement-unobserved"}
+ contract.Detection = "pending-journal-plus-fresh-provider-observation"
+ contract.ResumeContract = "forbidden-without-provider-observation"
+ contract.RollbackContract = "not-provable-after-external-request"
+ contract.CompensationContract = "provider-reconciliation-only"
+ }
+ if item.id == "workspace.cleanup" || item.id == "workspace.reap" {
+ contract.ResumeContract = "forbidden-after-destructive-git-effect"
+ contract.RollbackContract = "not-guaranteed-after-worktree-removal"
+ contract.CompensationContract = "no-generic-compensation"
+ }
+ if item.class == EventRecovery {
+ contract.ResumeContract = "never-blindly-retry-interrupted-recovery"
+ contract.RollbackContract = "preserve-original-transaction-group"
+ contract.CompensationContract = "escalation-only-after-nested-interruption"
+ }
+ return contract
+}
+
+func terminalEffect(id TransitionID) string {
+ value := string(id)
+ if strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "plan.approve" || id == "plan.approve-amendment" ||
+ id == "runtime.hydrate" || id == "runtime.replace" || id == "runtime.reconcile" || id == "configuration.initialize" ||
+ id == "configuration.mutate" || id == "configuration.reconcile" || id == "installation.update" ||
+ id == "publication.observe" || id == "publication.reconcile" || id == "workspace.cleanup" || id == "workspace.reap" {
+ return "may-establish-configured-goal-after-fresh-observation"
+ }
+ if id == "plan.abandon" || id == "publication.abandon" || id == "workspace.abandon" {
+ return "may-establish-safe-abandonment"
+ }
+ return "none"
+}
+
+func interruptionRecovery(item seed) TransitionID {
+ if item.class == EventOwnedExternal {
+ return "publication.reconcile"
+ }
+ switch item.id {
+ case "runtime.hydrate", "runtime.replace", "installation.initialize", "installation.update":
+ return "runtime.reconcile"
+ case "configuration.initialize", "configuration.mutate":
+ return "configuration.reconcile"
+ case "workspace.cut":
+ return "workspace.reconcile"
+ case "workspace.cleanup", "workspace.reap":
+ return "recovery.escalate"
+ default:
+ return "recovery.resume"
+ }
+}
+
+func allowsStaleTerminalRepair(id TransitionID) bool {
+ value := string(id)
+ return strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" ||
+ id == "plan.create" || id == "plan.validate" || id == "plan.amend" || id == "plan.invalidate" ||
+ id == "runtime.hydrate" || id == "runtime.replace" || id == "installation.update" ||
+ id == "configuration.initialize" || id == "configuration.mutate" || id == "publication.correct"
+}
+
+func allowsPostTerminalMaintenance(id TransitionID) bool {
+ switch id {
+ case "workspace.cleanup", "workspace.reap", "publication.correct":
+ return true
+ default:
+ return false
+ }
+}
+
+func parameterSpecs(id TransitionID) []ParameterSpec {
+ required := func(names ...string) []ParameterSpec {
+ result := make([]ParameterSpec, 0, len(names))
+ for _, name := range names {
+ result = append(result, ParameterSpec{Name: name, Required: true})
+ }
+ return result
+ }
+ switch id {
+ case "repository.attach":
+ return required("topology", "config_authority")
+ case "runtime.hydrate", "runtime.replace", "installation.update":
+ return required("source_revision", "runtime_path", "runtime_sha256")
+ case "runtime.reconcile":
+ return required("source_revision", "runtime_path", "runtime_sha256", "transaction_id")
+ case "installation.initialize":
+ return required("source_revision", "runtime_path", "runtime_sha256", "config_path", "config_sha256")
+ case "configuration.initialize", "configuration.mutate":
+ return required("config_path", "config_sha256")
+ case "configuration.reconcile", "workspace.reconcile":
+ return required("transaction_id")
+ case "goal.configure":
+ return required("goal_kind", "delivery_id")
+ case "plan.create", "plan.amend":
+ return required("source_path", "delivery_id")
+ case "plan.approve", "plan.approve-amendment":
+ return required("plan_fingerprint", "actor")
+ case "workspace.cut":
+ return required("branch", "base_ref", "destination")
+ case "workspace.sync", "workspace.activate", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon":
+ return required("branch")
+ case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record":
+ return required("source_revision", "evidence_path", "evidence_fingerprint")
+ case "evidence.visual.attach":
+ return required("manifest_path", "privacy_receipt", "source_revision")
+ case "delivery.slice.advance":
+ return required("slice_id", "source_revision")
+ case "publication.preview":
+ return required("base_ref", "head_ref", "body_path")
+ case "publication.execute":
+ return required("preview_fingerprint")
+ case "publication.observe":
+ return required("publication_id")
+ case "publication.reconcile":
+ return required("publication_id", "transaction_id")
+ case "publication.correct":
+ return required("publication_id", "body_path", "body_sha256")
+ case "recovery.resume", "recovery.rollback", "recovery.escalate":
+ return required("transaction_id")
+ default:
+ return nil
+ }
+}
diff --git a/boatstack/internal/kernel/catalog/default_test.go b/boatstack/internal/kernel/catalog/default_test.go
new file mode 100644
index 0000000..0725138
--- /dev/null
+++ b/boatstack/internal/kernel/catalog/default_test.go
@@ -0,0 +1,96 @@
+package catalog
+
+import (
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+func TestDefaultRegistryIsTheCompleteRuntimeCatalog(t *testing.T) {
+ // control-law: one-runtime-registry-owns-every-managed-event
+ registry := Default()
+ if got := registry.Len(); got != DefaultTransitionCount {
+ t.Fatalf("registry contains %d transitions, want %d", got, DefaultTransitionCount)
+ }
+ counts := map[EventClass]int{}
+ for _, transition := range registry.All() {
+ counts[transition.Class]++
+ if transition.Controllable() && transition.Effect == "" {
+ t.Fatalf("controllable transition %q has no effect", transition.ID)
+ }
+ if !transition.Controllable() && (transition.Effect != "" || len(transition.OwnedResources) != 0) {
+ t.Fatalf("observed event %q owns an effect", transition.ID)
+ }
+ }
+ if counts[EventObservedExternal] != 13 {
+ t.Fatalf("observed external events = %d, want 13", counts[EventObservedExternal])
+ }
+}
+
+func TestExternalEffectsRequireProviderAndHumanOrAutonomyAuthority(t *testing.T) {
+ transition, _ := Default().Lookup("publication.execute")
+ if (AuthoritySet{AuthorityHuman: true}).Satisfies(transition.Authority, transition.AuthorityAll) {
+ t.Fatal("human authority substituted for provider authority")
+ }
+ if (AuthoritySet{AuthorityProvider: true}).Satisfies(transition.Authority, transition.AuthorityAll) {
+ t.Fatal("provider authority substituted for human/autonomy authority")
+ }
+ if !(AuthoritySet{AuthorityHuman: true, AuthorityProvider: true}).Satisfies(transition.Authority, transition.AuthorityAll) {
+ t.Fatal("complete external authority set was rejected")
+ }
+}
+
+func TestEveryControllableTransitionNamesAnExecutableRecoveryOperator(t *testing.T) {
+ registry := Default()
+ for _, transition := range registry.All() {
+ if !transition.Controllable() {
+ continue
+ }
+ recovery, ok := registry.Lookup(transition.Interruption.Recovery)
+ if !ok || recovery.Class != EventRecovery {
+ t.Fatalf("transition %s recovery=%s is not executable", transition.ID, transition.Interruption.Recovery)
+ }
+ if transition.Interruption.Recovery == "recovery.enter" {
+ t.Fatalf("transition %s references deleted synthetic recovery entry", transition.ID)
+ }
+ }
+}
+
+func TestRegistryGraphEveryDeclaredPhaseCanReachMarkedOutcome(t *testing.T) {
+ // control-law: every-reachable-managed-state-is-coreachable
+ registry := Default()
+ edges := map[model.ProtocolPhase][]model.ProtocolPhase{}
+ states := map[model.ProtocolPhase]bool{}
+ for _, transition := range registry.All() {
+ for _, source := range transition.SourcePhases {
+ states[source] = true
+ for _, target := range transition.TargetPhases {
+ edges[source] = append(edges[source], target)
+ states[target] = true
+ }
+ }
+ }
+ marked := map[model.ProtocolPhase]bool{model.PhaseFrontier: true, model.PhaseTerminal: true, model.PhaseAbandoned: true}
+ for state := range states {
+ if !canReachMarked(state, edges, marked, map[model.ProtocolPhase]bool{}) {
+ t.Errorf("phase %s has no catalog path to a marked outcome", state)
+ }
+ }
+}
+
+func canReachMarked(state model.ProtocolPhase, edges map[model.ProtocolPhase][]model.ProtocolPhase, marked, visiting map[model.ProtocolPhase]bool) bool {
+ if marked[state] {
+ return true
+ }
+ if visiting[state] {
+ return false
+ }
+ visiting[state] = true
+ defer delete(visiting, state)
+ for _, next := range edges[state] {
+ if canReachMarked(next, edges, marked, visiting) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/boatstack/internal/kernel/catalog/historical_test.go b/boatstack/internal/kernel/catalog/historical_test.go
new file mode 100644
index 0000000..ef5c557
--- /dev/null
+++ b/boatstack/internal/kernel/catalog/historical_test.go
@@ -0,0 +1,199 @@
+package catalog_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor"
+)
+
+type historicalCorpus struct {
+ SchemaVersion int `json:"schema_version"`
+ FixtureCount int `json:"fixture_count"`
+ Fixtures []historicalFixture `json:"fixtures"`
+}
+
+type historicalFixture struct {
+ Name string `json:"name"`
+ InitialPlantFacts map[string]string `json:"initial_plant_facts"`
+ CanonicalObservation map[string]string `json:"canonical_observation"`
+ RequestedGoal model.Goal `json:"requested_goal"`
+ Event catalog.TransitionID `json:"event"`
+ Authority []catalog.AuthorityClass `json:"authority"`
+ ExpectedDecision supervisor.DecisionKind `json:"expected_decision"`
+ ExpectedAdmittedTransition catalog.TransitionID `json:"expected_admitted_transition"`
+ ExpectedPostcondition map[string]string `json:"expected_postcondition"`
+ ForbiddenTransition catalog.TransitionID `json:"forbidden_transition"`
+ SourceProvenance []string `json:"source_provenance"`
+ FailureClass string `json:"failure_class"`
+}
+
+func loadHistoricalCorpus(t *testing.T) historicalCorpus {
+ t.Helper()
+ raw, err := os.ReadFile("../../../testdata/v2-scenarios/historical.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var corpus historicalCorpus
+ decoder := json.NewDecoder(strings.NewReader(string(raw)))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&corpus); err != nil {
+ t.Fatal(err)
+ }
+ if corpus.SchemaVersion != 1 || corpus.FixtureCount != len(corpus.Fixtures) || len(corpus.Fixtures) < 18 {
+ t.Fatalf("invalid corpus header: schema=%d count=%d fixtures=%d", corpus.SchemaVersion, corpus.FixtureCount, len(corpus.Fixtures))
+ }
+ return corpus
+}
+
+func snapshotFromFixture(t *testing.T, fixture historicalFixture) model.Snapshot {
+ t.Helper()
+ facts := fixture.CanonicalObservation
+ evidence := model.Evidence{Source: "historical:" + fixture.Name, Fingerprint: "fixture-" + fixture.Name, ObservedAt: time.Unix(100, 0).UTC()}
+ invocation := model.InvocationContext{
+ RepositoryID: "repo", GitCommonID: "git-common", WorktreeID: "worktree", Ref: facts["ref"], ControllerID: "controller",
+ InvokingPath: filepath.Join(t.TempDir(), "fixture", "repository"), Topology: model.Topology(facts["topology"]), Host: "corpus", Correlation: "correlation-" + fixture.Name,
+ }
+ observation := model.Observation{
+ SchemaVersion: model.SnapshotSchemaVersion, Invocation: invocation,
+ Phase: model.Known(model.ProtocolPhase(facts["phase"]), evidence), Engagement: model.Known(model.EngagementState(facts["engagement"]), evidence),
+ Delivery: model.Known(model.DeliveryState(facts["delivery"]), evidence), Workspace: model.Known(model.WorkspaceState(facts["workspace"]), evidence),
+ Plan: model.Known(model.PlanState(facts["plan"]), evidence), Configuration: model.Known(model.ConfigurationState(facts["configuration"]), evidence),
+ ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli", "corpus"}}, evidence),
+ Runtime: model.Known(model.RuntimeState(facts["runtime"]), evidence), Publication: model.Known(model.PublicationState(facts["publication"]), evidence),
+ Verification: model.Known(model.VerificationState(facts["verification"]), evidence), Recovery: model.Known(model.RecoveryState(facts["recovery"]), evidence),
+ Transaction: model.Known(model.TransactionState(facts["transaction"]), evidence), Terminal: model.Known(model.TerminalStatus(facts["terminal"]), evidence),
+ Goal: model.Known(fixture.RequestedGoal, evidence), RecoveryInfo: model.Absent[model.RecoveryContext]("none", evidence),
+ TransactionInfo: model.Absent[model.TransactionContext]("none", evidence), ObservedAt: time.Unix(100, 0).UTC(),
+ }
+ if observation.Phase.Value == model.PhaseRecovery {
+ observation.RecoveryInfo = model.Known(model.RecoveryContext{
+ TransactionID: "adm-historical", Cause: fixture.FailureClass, SourcePhase: model.PhaseActive,
+ Permitted: []string{"recovery.resume", "recovery.rollback", "recovery.escalate"}, BudgetRemaining: 2, Resumption: model.PhaseActive,
+ }, evidence)
+ observation.TransactionInfo = model.Known(model.TransactionContext{ID: "adm-historical", TransitionID: "workspace.sync", Status: "recovery-required"}, evidence)
+ }
+ snapshot, err := model.Canonicalize(observation)
+ if err != nil {
+ t.Fatalf("%s: canonicalize: %v", fixture.Name, err)
+ }
+ return snapshot
+}
+
+func authoritySet(values []catalog.AuthorityClass) catalog.AuthoritySet {
+ result := catalog.AuthoritySet{}
+ for _, value := range values {
+ result[value] = true
+ }
+ return result
+}
+
+func TestHistoricalFailureCorpusUsesTheRuntimeControlLaw(t *testing.T) {
+ // control-law: historical failures bind to executable catalog predicates
+ corpus := loadHistoricalCorpus(t)
+ registry := catalog.Default()
+ control := supervisor.New(registry)
+ seenNames := map[string]bool{}
+ for _, fixture := range corpus.Fixtures {
+ fixture := fixture
+ t.Run(fixture.Name, func(t *testing.T) {
+ if fixture.Name == "" || fixture.FailureClass == "" || len(fixture.InitialPlantFacts) == 0 || len(fixture.CanonicalObservation) == 0 || len(fixture.SourceProvenance) == 0 || len(fixture.ExpectedPostcondition) == 0 {
+ t.Fatal("fixture is missing required historical fields")
+ }
+ if seenNames[fixture.Name] {
+ t.Fatal("duplicate fixture name")
+ }
+ seenNames[fixture.Name] = true
+ if fixture.ExpectedDecision == "IDENTITY_REFUSED" {
+ invocation := snapshotIdentityForFixture(t, fixture)
+ if err := invocation.Validate(true); err == nil {
+ t.Fatal("ambiguous/conflicting effect identity was accepted")
+ }
+ return
+ }
+ snapshot := snapshotFromFixture(t, fixture)
+ one := control.Resolve(snapshot, fixture.RequestedGoal, authoritySet(fixture.Authority), fixture.Event)
+ two := control.Resolve(snapshot, fixture.RequestedGoal, authoritySet(fixture.Authority), fixture.Event)
+ if !reflect.DeepEqual(one, two) {
+ t.Fatalf("resolution is nondeterministic: %#v != %#v", one, two)
+ }
+ if one.Kind != fixture.ExpectedDecision {
+ t.Fatalf("decision=%s reason=%s, want %s", one.Kind, one.Reason, fixture.ExpectedDecision)
+ }
+ if fixture.ExpectedAdmittedTransition != "" {
+ if one.Transition == nil || one.Transition.ID != fixture.ExpectedAdmittedTransition {
+ t.Fatalf("transition=%v, want %s", one.Transition, fixture.ExpectedAdmittedTransition)
+ }
+ assertPostconditionDeclared(t, *one.Transition, fixture.ExpectedPostcondition)
+ }
+ if fixture.ForbiddenTransition != "" && one.Transition != nil && one.Transition.ID == fixture.ForbiddenTransition {
+ t.Fatalf("forbidden transition %s was prescribed", fixture.ForbiddenTransition)
+ }
+ })
+ }
+}
+
+func snapshotIdentityForFixture(t *testing.T, fixture historicalFixture) model.InvocationContext {
+ t.Helper()
+ return model.InvocationContext{
+ RepositoryID: "repo", GitCommonID: "git", Ref: fixture.CanonicalObservation["ref"], ControllerID: "shared-alias",
+ InvokingPath: filepath.Join(t.TempDir(), "fixture", "repository"), Topology: model.Topology(fixture.CanonicalObservation["topology"]), Host: "corpus", Correlation: "identity-refusal",
+ }
+}
+
+func assertPostconditionDeclared(t *testing.T, transition catalog.Transition, expected map[string]string) {
+ t.Helper()
+ for facet, value := range expected {
+ if facet == "preserves" || facet == "terminal" {
+ continue
+ }
+ if facet == "phase" {
+ if !transition.DeclaresTargetPhase(model.ProtocolPhase(value)) {
+ t.Fatalf("transition %s does not declare target phase %s", transition.ID, value)
+ }
+ continue
+ }
+ found := false
+ for _, condition := range transition.TargetConditions {
+ if string(condition.Facet) != facet {
+ continue
+ }
+ for _, candidate := range condition.Values {
+ if candidate == value {
+ found = true
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("transition %s does not declare %s=%s as a target", transition.ID, facet, value)
+ }
+ }
+}
+
+func TestHistoricalCorpusCoversEveryPRFrom172Through185(t *testing.T) {
+ corpus := loadHistoricalCorpus(t)
+ pattern := regexp.MustCompile(`#(\d+)`)
+ covered := map[int]bool{}
+ for _, fixture := range corpus.Fixtures {
+ for _, source := range fixture.SourceProvenance {
+ for _, match := range pattern.FindAllStringSubmatch(source, -1) {
+ value, _ := strconv.Atoi(match[1])
+ covered[value] = true
+ }
+ }
+ }
+ for number := 172; number <= 185; number++ {
+ if !covered[number] {
+ t.Errorf("PR #%d has no historical V2 fixture", number)
+ }
+ }
+}
diff --git a/boatstack/internal/kernel/catalog/predicates.go b/boatstack/internal/kernel/catalog/predicates.go
new file mode 100644
index 0000000..e7af512
--- /dev/null
+++ b/boatstack/internal/kernel/catalog/predicates.go
@@ -0,0 +1,281 @@
+package catalog
+
+import (
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+func known(facet model.FacetName, values ...string) FacetCondition {
+ return FacetCondition{Facet: facet, Statuses: []model.FactStatus{model.FactKnown}, Values: values}
+}
+
+func statuses(facet model.FacetName, values ...model.FactStatus) FacetCondition {
+ return FacetCondition{Facet: facet, Statuses: values}
+}
+
+func requiresEngagement(id TransitionID) bool {
+ value := string(id)
+ return strings.HasPrefix(value, "plan.") || strings.HasPrefix(value, "workspace.") ||
+ strings.HasPrefix(value, "gate.") || strings.HasPrefix(value, "evidence.") ||
+ strings.HasPrefix(value, "delivery.") || strings.HasPrefix(value, "publication.") ||
+ id == "configuration.mutate" || id == "installation.update"
+}
+
+func requiresHealthyConfiguration(id TransitionID) bool {
+ if id == "engagement.begin" || id == "engagement.renew" || id == "engagement.release" || id == "installation.update" {
+ return true
+ }
+ value := string(id)
+ if strings.HasPrefix(value, "plan.") {
+ return id != "plan.invalidate" && id != "plan.abandon"
+ }
+ if strings.HasPrefix(value, "workspace.") {
+ return id != "workspace.abandon" && id != "workspace.reconcile"
+ }
+ if strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "delivery.slice.advance" {
+ return true
+ }
+ return strings.HasPrefix(value, "publication.") && id != "publication.abandon"
+}
+
+func requiresHealthyRuntime(id TransitionID) bool {
+ if id == "engagement.begin" || id == "engagement.renew" || id == "engagement.release" || id == "configuration.mutate" {
+ return true
+ }
+ value := string(id)
+ if strings.HasPrefix(value, "plan.") {
+ return id != "plan.invalidate" && id != "plan.abandon"
+ }
+ if strings.HasPrefix(value, "workspace.") {
+ return id != "workspace.abandon" && id != "workspace.reconcile"
+ }
+ if strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "delivery.slice.advance" {
+ return true
+ }
+ return strings.HasPrefix(value, "publication.") && id != "publication.abandon"
+}
+
+func usesConfigurationPolicy(id TransitionID) bool {
+ switch id {
+ case "plan.approve", "plan.approve-amendment", "gate.review.record", "evidence.visual.attach":
+ return true
+ default:
+ return false
+ }
+}
+
+func sourceConditions(id TransitionID) []FacetCondition {
+ switch id {
+ case "engagement.begin":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementDormant), string(model.EngagementCommand)), known(model.FacetGoal)}
+ case "engagement.renew":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive)), known(model.FacetGoal)}
+ case "engagement.release":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive), string(model.EngagementStale), string(model.EngagementCommand)), known(model.FacetGoal)}
+ case "invocation.rebind":
+ return []FacetCondition{known(model.FacetTopology)}
+ case "repository.attach":
+ return []FacetCondition{known(model.FacetTopology, string(model.TopologyEmbedded))}
+ case "repository.detach":
+ return []FacetCondition{known(model.FacetTopology, string(model.TopologyDetached), string(model.TopologyHybrid))}
+ case "runtime.hydrate":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeAbsent), string(model.RuntimeStale), string(model.RuntimeInvalid), string(model.RuntimeConflicting), string(model.RuntimeWrongSource), string(model.RuntimePartiallyPublished))}
+ case "runtime.replace", "installation.update":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified), string(model.RuntimeStale), string(model.RuntimeInvalid), string(model.RuntimeConflicting), string(model.RuntimeWrongSource), string(model.RuntimePartiallyPublished))}
+ case "runtime.reconcile", "configuration.reconcile", "workspace.reconcile":
+ return []FacetCondition{known(model.FacetRecoveryInfo)}
+ case "configuration.initialize":
+ return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationUnsupported), string(model.ConfigurationStale), string(model.ConfigurationConflicting))}
+ case "configuration.mutate":
+ return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified), string(model.ConfigurationStale), string(model.ConfigurationDivergent), string(model.ConfigurationConflicting))}
+ case "installation.initialize":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeAbsent), string(model.RuntimeInvalid), string(model.RuntimeStale))}
+ case "goal.configure":
+ return []FacetCondition{statuses(model.FacetGoal, model.FactKnown, model.FactAbsent)}
+ case "plan.create":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanAbsent), string(model.PlanInvalid), string(model.PlanStale))}
+ case "plan.validate":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanDraft))}
+ case "plan.approve":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanValid))}
+ case "plan.activate":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved))}
+ case "plan.amend":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved), string(model.PlanLocked), string(model.PlanStale), string(model.PlanAmendmentRequired))}
+ case "plan.approve-amendment":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanAmendmentRequired))}
+ case "plan.invalidate":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanDraft), string(model.PlanValid), string(model.PlanApproved), string(model.PlanLocked), string(model.PlanStale))}
+ case "plan.abandon", "publication.abandon":
+ return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryUninitialized), string(model.DeliveryPlanning), string(model.DeliveryApproved), string(model.DeliveryActive), string(model.DeliveryGatesPassed), string(model.DeliveryPublished), string(model.DeliveryAmendment), string(model.DeliveryInvalid), string(model.DeliveryRecovery))}
+ case "workspace.cut":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceAbsent))}
+ case "workspace.sync", "workspace.activate":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceCut), string(model.WorkspaceActive))}
+ case "workspace.publish":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceActive))}
+ case "workspace.cleanup", "workspace.reap":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceLanded), string(model.WorkspaceAbandoned))}
+ case "workspace.abandon":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceCut), string(model.WorkspaceActive), string(model.WorkspacePublished), string(model.WorkspaceAttentionRequired))}
+ case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanLocked)), known(model.FacetDelivery, string(model.DeliveryActive), string(model.DeliveryGatesPassed))}
+ case "evidence.visual.attach":
+ return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryActive), string(model.DeliveryGatesPassed), string(model.DeliveryPublished))}
+ case "evidence.approval.revoke":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved), string(model.PlanLocked))}
+ case "delivery.slice.advance":
+ return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryActive))}
+ case "publication.preview":
+ return []FacetCondition{
+ known(model.FacetPlan, string(model.PlanLocked)),
+ known(model.FacetVerification, string(model.VerificationCurrent)),
+ known(model.FacetWorkspace, string(model.WorkspaceActive), string(model.WorkspacePublished)),
+ }
+ case "publication.execute":
+ return []FacetCondition{
+ known(model.FacetPublication, string(model.PublicationCandidate)),
+ known(model.FacetVerification, string(model.VerificationCurrent)),
+ known(model.FacetWorkspace, string(model.WorkspaceActive), string(model.WorkspacePublished)),
+ }
+ case "publication.observe":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationCandidate), string(model.PublicationPublishedNotLanded), string(model.PublicationOpen), string(model.PublicationClosedUnmerged), string(model.PublicationUnavailable), string(model.PublicationConflicting))}
+ case "publication.reconcile":
+ return []FacetCondition{known(model.FacetRecoveryInfo), known(model.FacetPublication)}
+ case "publication.correct":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen)), known(model.FacetVerification, string(model.VerificationCurrent))}
+ case "recovery.resume", "recovery.rollback", "recovery.escalate":
+ return []FacetCondition{known(model.FacetRecoveryInfo)}
+ case "external.files-changed", "external.head-changed":
+ return []FacetCondition{known(model.FacetVerification)}
+ case "external.branch-changed":
+ return []FacetCondition{known(model.FacetWorkspace)}
+ case "external.runtime-disappeared":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified), string(model.RuntimeStale))}
+ case "external.configuration-drifted":
+ return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified))}
+ case "external.lease-expired":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive), string(model.EngagementCommand))}
+ case "external.host-interrupted":
+ return []FacetCondition{known(model.FacetTransaction, string(model.TransactionStaged), string(model.TransactionLocalApplied), string(model.TransactionExternalUncertain), string(model.TransactionVerifying)), known(model.FacetTransactionInfo)}
+ case "external.ci-completed":
+ return []FacetCondition{known(model.FacetVerification)}
+ case "external.pr-opened", "external.pr-updated", "external.pr-closed", "external.pr-merged", "external.provider-unavailable":
+ return []FacetCondition{known(model.FacetPublication)}
+ default:
+ return nil
+ }
+}
+
+func targetConditions(id TransitionID) []FacetCondition {
+ switch id {
+ case "engagement.begin":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementCommand))}
+ case "engagement.renew":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementActive))}
+ case "engagement.release", "repository.detach":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementDormant))}
+ case "invocation.rebind":
+ return []FacetCondition{known(model.FacetTopology)}
+ case "repository.attach":
+ return []FacetCondition{known(model.FacetTopology, string(model.TopologyDetached), string(model.TopologyHybrid))}
+ case "runtime.hydrate", "runtime.replace", "installation.update":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified))}
+ case "runtime.reconcile":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified)), known(model.FacetTransaction, string(model.TransactionNone))}
+ case "configuration.initialize", "configuration.mutate":
+ return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified))}
+ case "configuration.reconcile":
+ return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationVerified)), known(model.FacetTransaction, string(model.TransactionNone))}
+ case "installation.initialize":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeVerified)), known(model.FacetConfiguration, string(model.ConfigurationVerified))}
+ case "goal.configure":
+ return []FacetCondition{known(model.FacetGoal)}
+ case "plan.create":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanDraft))}
+ case "plan.validate":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanValid))}
+ case "plan.approve", "plan.approve-amendment":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanApproved))}
+ case "plan.activate":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanLocked))}
+ case "plan.amend":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanAmendmentRequired))}
+ case "plan.invalidate":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanInvalid))}
+ case "plan.abandon", "publication.abandon":
+ return []FacetCondition{
+ known(model.FacetDelivery, string(model.DeliveryDiscarded)),
+ known(model.FacetRecovery, string(model.RecoveryNone)),
+ known(model.FacetTransaction, string(model.TransactionNone)),
+ }
+ case "workspace.cut":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceCut))}
+ case "workspace.sync", "workspace.activate":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceActive))}
+ case "workspace.publish":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspacePublished))}
+ case "workspace.cleanup", "workspace.reap":
+ return []FacetCondition{known(model.FacetWorkspace, string(model.WorkspaceAbsent))}
+ case "workspace.abandon":
+ return []FacetCondition{
+ known(model.FacetWorkspace, string(model.WorkspaceAbandoned)),
+ known(model.FacetRecovery, string(model.RecoveryNone)),
+ known(model.FacetTransaction, string(model.TransactionNone)),
+ }
+ case "workspace.reconcile", "recovery.resume", "recovery.rollback":
+ return []FacetCondition{known(model.FacetRecovery, string(model.RecoveryNone)), known(model.FacetTransaction, string(model.TransactionNone))}
+ case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record":
+ return []FacetCondition{known(model.FacetVerification, string(model.VerificationCurrent))}
+ case "evidence.visual.attach":
+ return []FacetCondition{known(model.FacetDelivery)}
+ case "evidence.approval.revoke":
+ return []FacetCondition{known(model.FacetPlan, string(model.PlanValid))}
+ case "delivery.slice.advance":
+ return []FacetCondition{known(model.FacetDelivery, string(model.DeliveryActive))}
+ case "publication.preview":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationCandidate))}
+ case "publication.execute":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationPublishedNotLanded))}
+ case "publication.observe":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen), string(model.PublicationMerged), string(model.PublicationClosedUnmerged), string(model.PublicationUnavailable), string(model.PublicationConflicting))}
+ case "publication.reconcile":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen), string(model.PublicationMerged), string(model.PublicationClosedUnmerged), string(model.PublicationUnavailable), string(model.PublicationConflicting)), known(model.FacetTransaction, string(model.TransactionNone))}
+ case "publication.correct":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationPublishedNotLanded))}
+ case "recovery.escalate":
+ return []FacetCondition{known(model.FacetRecovery, string(model.RecoveryEscalated)), known(model.FacetTransaction, string(model.TransactionNone))}
+ case "external.files-changed", "external.head-changed", "external.ci-completed":
+ return []FacetCondition{known(model.FacetVerification)}
+ case "external.branch-changed":
+ return []FacetCondition{known(model.FacetWorkspace)}
+ case "external.runtime-disappeared":
+ return []FacetCondition{known(model.FacetRuntime, string(model.RuntimeAbsent), string(model.RuntimeStale))}
+ case "external.configuration-drifted":
+ return []FacetCondition{known(model.FacetConfiguration, string(model.ConfigurationStale), string(model.ConfigurationDivergent), string(model.ConfigurationConflicting))}
+ case "external.lease-expired":
+ return []FacetCondition{known(model.FacetEngagement, string(model.EngagementDormant), string(model.EngagementStale))}
+ case "external.host-interrupted":
+ return []FacetCondition{known(model.FacetRecovery)}
+ case "external.pr-opened", "external.pr-updated":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationOpen))}
+ case "external.pr-closed":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationClosedUnmerged), string(model.PublicationMerged))}
+ case "external.pr-merged":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationMerged))}
+ case "external.provider-unavailable":
+ return []FacetCondition{known(model.FacetPublication, string(model.PublicationUnavailable))}
+ default:
+ return nil
+ }
+}
+
+func requiredAuthorities(id TransitionID) []AuthorityClass {
+ switch id {
+ case "publication.execute", "publication.correct":
+ return []AuthorityClass{AuthorityProvider}
+ default:
+ return nil
+ }
+}
diff --git a/boatstack/internal/kernel/catalog/transition.go b/boatstack/internal/kernel/catalog/transition.go
new file mode 100644
index 0000000..55fd099
--- /dev/null
+++ b/boatstack/internal/kernel/catalog/transition.go
@@ -0,0 +1,385 @@
+package catalog
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+type TransitionID string
+type EffectID string
+
+type EventClass string
+
+const (
+ EventOwnedLocal EventClass = "owned-local"
+ EventOwnedExternal EventClass = "owned-external"
+ EventAuthority EventClass = "authority"
+ EventObservedExternal EventClass = "observed-external"
+ EventRecovery EventClass = "recovery"
+)
+
+func (c EventClass) Controllable() bool { return c != EventObservedExternal }
+
+func GateName(id TransitionID) (string, bool) {
+ value := string(id)
+ if !strings.HasPrefix(value, "gate.") || !strings.HasSuffix(value, ".record") {
+ return "", false
+ }
+ name := strings.TrimSuffix(strings.TrimPrefix(value, "gate."), ".record")
+ return name, name != ""
+}
+
+func (c EventClass) Valid() bool {
+ switch c {
+ case EventOwnedLocal, EventOwnedExternal, EventAuthority, EventObservedExternal, EventRecovery:
+ return true
+ default:
+ return false
+ }
+}
+
+type AuthorityClass string
+
+const (
+ AuthorityNone AuthorityClass = "none"
+ AuthorityRepository AuthorityClass = "repository-policy"
+ AuthorityHuman AuthorityClass = "human"
+ AuthorityAutonomy AuthorityClass = "autonomy"
+ AuthorityProvider AuthorityClass = "external-provider"
+)
+
+// AuthoritySet is the supervisor's minimal projection of verified authority.
+// Receipts and fingerprints remain in protocol.Admission.
+type AuthoritySet map[AuthorityClass]bool
+
+func (c AuthorityClass) Valid() bool {
+ switch c {
+ case AuthorityNone, AuthorityRepository, AuthorityHuman, AuthorityAutonomy, AuthorityProvider:
+ return true
+ default:
+ return false
+ }
+}
+
+// Satisfies applies an OR clause followed by mandatory AND clauses. This keeps
+// provider authority distinct from human/autonomy authority for external
+// effects instead of treating all receipts as substitutes.
+func (s AuthoritySet) Satisfies(alternatives, required []AuthorityClass) bool {
+ for _, authority := range required {
+ if authority != AuthorityNone && !s[authority] {
+ return false
+ }
+ }
+ if len(alternatives) == 0 {
+ return true
+ }
+ for _, authority := range alternatives {
+ if authority == AuthorityNone || s[authority] {
+ return true
+ }
+ }
+ return false
+}
+
+type Reversibility string
+
+const (
+ Reversible Reversibility = "reversible"
+ Compensatable Reversibility = "compensatable"
+ Irreversible Reversibility = "irreversible"
+ ObservationOnly Reversibility = "observation-only"
+)
+
+type Prescription struct {
+ Operation string `json:"operation"`
+ Arguments []string `json:"arguments,omitempty"`
+ AuthorityPrompt string `json:"authority_prompt,omitempty"`
+ ExpectedPostcondition string `json:"expected_postcondition"`
+}
+
+type ParameterSpec struct {
+ Name string `json:"name"`
+ Required bool `json:"required"`
+ Secret bool `json:"secret"`
+}
+
+type InterruptionContract struct {
+ Points []string `json:"points"`
+ PartialState []string `json:"partial_state"`
+ Detection string `json:"detection"`
+ ResumeContract string `json:"resume_contract"`
+ RollbackContract string `json:"rollback_contract"`
+ CompensationContract string `json:"compensation_contract"`
+ Recovery TransitionID `json:"recovery,omitempty"`
+ RecoveryAuthority string `json:"recovery_authority"`
+ ResumptionPredicate string `json:"resumption_predicate"`
+}
+
+// FacetCondition is an executable, serializable predicate over one canonical
+// control facet. Values are ORed; conditions on a transition are ANDed.
+type FacetCondition struct {
+ Facet model.FacetName `json:"facet"`
+ Statuses []model.FactStatus `json:"statuses"`
+ Values []string `json:"values,omitempty"`
+}
+
+func (c FacetCondition) Matches(snapshot model.Snapshot) bool {
+ status, value, ok := snapshot.Facet(c.Facet)
+ if !ok {
+ return false
+ }
+ statusMatch := false
+ for _, candidate := range c.Statuses {
+ if candidate == status {
+ statusMatch = true
+ break
+ }
+ }
+ if !statusMatch {
+ return false
+ }
+ if len(c.Values) == 0 {
+ return true
+ }
+ for _, candidate := range c.Values {
+ if candidate == value {
+ return true
+ }
+ }
+ return false
+}
+
+// Transition is both the executable runtime declaration and the source for the
+// generated finite-state model. No second graph is maintained.
+type Transition struct {
+ ID TransitionID `json:"id"`
+ Version int `json:"version"`
+ Class EventClass `json:"class"`
+ SourcePhases []model.ProtocolPhase `json:"source_phases"`
+ TargetPhases []model.ProtocolPhase `json:"target_phases"`
+ GoalKinds []model.GoalKind `json:"goal_kinds,omitempty"`
+ RequiredIdentity []string `json:"required_identity"`
+ Authority []AuthorityClass `json:"authority"`
+ AuthorityAll []AuthorityClass `json:"authority_all,omitempty"`
+ RequiredEvidence []string `json:"required_evidence"`
+ OwnedResources []string `json:"owned_resources,omitempty"`
+ Effect EffectID `json:"effect,omitempty"`
+ LocalEffects []EffectID `json:"local_effects,omitempty"`
+ ExternalEffects []EffectID `json:"external_effects,omitempty"`
+ Idempotent bool `json:"idempotent"`
+ Parameters []ParameterSpec `json:"parameters,omitempty"`
+ Prescription Prescription `json:"prescription"`
+ SourcePredicate string `json:"source_predicate"`
+ SourceConditions []FacetCondition `json:"source_conditions"`
+ AdmissionPredicate string `json:"admission_predicate"`
+ TargetPredicate string `json:"target_predicate"`
+ TargetConditions []FacetCondition `json:"target_conditions"`
+ Verifier string `json:"verifier"`
+ Interruption InterruptionContract `json:"interruption"`
+ Reversibility Reversibility `json:"reversibility"`
+ TerminalEffect string `json:"terminal_effect,omitempty"`
+ PrivacyClassification string `json:"privacy_classification"`
+ TelemetryClassification string `json:"telemetry_classification"`
+ CostClass string `json:"cost_class"`
+ Priority int `json:"priority"`
+ AllowsIdentityRebind bool `json:"allows_identity_rebind,omitempty"`
+ AllowsWorktreeTransfer bool `json:"allows_worktree_transfer,omitempty"`
+}
+
+func (t Transition) Controllable() bool { return t.Class.Controllable() }
+
+func (t Transition) SupportsGoal(goal model.Goal) bool {
+ if len(t.GoalKinds) == 0 {
+ return true
+ }
+ for _, kind := range t.GoalKinds {
+ if kind == goal.Kind {
+ return true
+ }
+ }
+ return false
+}
+
+func containsPhase(phases []model.ProtocolPhase, phase model.ProtocolPhase) bool {
+ for _, candidate := range phases {
+ if candidate == phase {
+ return true
+ }
+ }
+ return false
+}
+
+func (t Transition) SourceMatches(snapshot model.Snapshot) bool {
+ if snapshot.Phase.Status != model.FactKnown || !containsPhase(t.SourcePhases, snapshot.Phase.Value) {
+ return false
+ }
+ for _, condition := range t.SourceConditions {
+ if !condition.Matches(snapshot) {
+ return false
+ }
+ }
+ return true
+}
+
+func (t Transition) TargetMatches(snapshot model.Snapshot) bool {
+ if snapshot.Phase.Status != model.FactKnown || !containsPhase(t.TargetPhases, snapshot.Phase.Value) {
+ return false
+ }
+ for _, condition := range t.TargetConditions {
+ if !condition.Matches(snapshot) {
+ return false
+ }
+ }
+ return true
+}
+
+func (t Transition) DeclaresTargetPhase(phase model.ProtocolPhase) bool {
+ return containsPhase(t.TargetPhases, phase)
+}
+
+type Registry struct {
+ ordered []Transition
+ byID map[TransitionID]Transition
+}
+
+var semanticID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$`)
+
+func New(transitions []Transition) (Registry, error) {
+ registry := Registry{ordered: append([]Transition(nil), transitions...), byID: make(map[TransitionID]Transition, len(transitions))}
+ for index, transition := range registry.ordered {
+ if err := validateTransition(transition); err != nil {
+ return Registry{}, fmt.Errorf("transition %d: %w", index, err)
+ }
+ if _, exists := registry.byID[transition.ID]; exists {
+ return Registry{}, fmt.Errorf("duplicate transition id %q", transition.ID)
+ }
+ registry.byID[transition.ID] = transition
+ }
+ for _, transition := range registry.ordered {
+ if recovery := transition.Interruption.Recovery; recovery != "" {
+ candidate, ok := registry.byID[recovery]
+ if !ok || candidate.Class != EventRecovery {
+ return Registry{}, fmt.Errorf("transition %q references non-recovery transition %q", transition.ID, recovery)
+ }
+ }
+ }
+ sort.SliceStable(registry.ordered, func(i, j int) bool {
+ if registry.ordered[i].Priority != registry.ordered[j].Priority {
+ return registry.ordered[i].Priority < registry.ordered[j].Priority
+ }
+ return registry.ordered[i].ID < registry.ordered[j].ID
+ })
+ return registry, nil
+}
+
+func validateTransition(t Transition) error {
+ if t.ID == "" || !semanticID.MatchString(string(t.ID)) {
+ return fmt.Errorf("invalid semantic id %q", t.ID)
+ }
+ if !t.Class.Valid() {
+ return fmt.Errorf("%s: invalid event class %q", t.ID, t.Class)
+ }
+ if t.Version < 1 || len(t.SourcePhases) == 0 || len(t.TargetPhases) == 0 {
+ return fmt.Errorf("%s: version, source phases, and target phases are required", t.ID)
+ }
+ for _, phase := range append(append([]model.ProtocolPhase(nil), t.SourcePhases...), t.TargetPhases...) {
+ if !phase.Valid() {
+ return fmt.Errorf("%s: invalid phase %q", t.ID, phase)
+ }
+ }
+ if len(t.Authority) == 0 || len(t.RequiredEvidence) == 0 {
+ return fmt.Errorf("%s: authority and evidence declarations are required", t.ID)
+ }
+ for _, authority := range append(append([]AuthorityClass(nil), t.Authority...), t.AuthorityAll...) {
+ if !authority.Valid() {
+ return fmt.Errorf("%s: invalid authority class %q", t.ID, authority)
+ }
+ }
+ if t.Controllable() && t.Effect == "" {
+ return fmt.Errorf("%s: controllable transition has no owned effect", t.ID)
+ }
+ if !t.Controllable() && (t.Effect != "" || len(t.OwnedResources) != 0) {
+ return fmt.Errorf("%s: observed external transition cannot own effects", t.ID)
+ }
+ if t.Controllable() && len(t.OwnedResources) == 0 {
+ return fmt.Errorf("%s: controllable transition has no owned resource", t.ID)
+ }
+ if len(t.RequiredIdentity) == 0 || t.SourcePredicate == "" || t.AdmissionPredicate == "" || t.TargetPredicate == "" || t.Verifier == "" || t.Prescription.Operation == "" || t.Prescription.ExpectedPostcondition == "" {
+ return fmt.Errorf("%s: identity, predicates, verifier, and typed prescription are required", t.ID)
+ }
+ if len(t.SourceConditions) == 0 || len(t.TargetConditions) == 0 {
+ return fmt.Errorf("%s: source and target facet conditions are required", t.ID)
+ }
+ for _, condition := range append(append([]FacetCondition(nil), t.SourceConditions...), t.TargetConditions...) {
+ if !condition.Facet.Valid() || condition.Facet == model.FacetPhase || len(condition.Statuses) == 0 {
+ return fmt.Errorf("%s: invalid or phase-duplicating facet condition", t.ID)
+ }
+ for _, status := range condition.Statuses {
+ if !status.Valid() {
+ return fmt.Errorf("%s: invalid fact status %q", t.ID, status)
+ }
+ }
+ }
+ if t.Class == EventOwnedExternal {
+ if len(t.LocalEffects) != 0 || len(t.ExternalEffects) != 1 || t.ExternalEffects[0] != t.Effect {
+ return fmt.Errorf("%s: owned external effect scope is incomplete", t.ID)
+ }
+ } else if t.Controllable() {
+ if len(t.LocalEffects) != 1 || t.LocalEffects[0] != t.Effect || len(t.ExternalEffects) != 0 {
+ return fmt.Errorf("%s: owned local effect scope is incomplete", t.ID)
+ }
+ } else if len(t.LocalEffects) != 0 || len(t.ExternalEffects) != 0 {
+ return fmt.Errorf("%s: observed event declares an owned effect scope", t.ID)
+ }
+ contract := t.Interruption
+ if len(contract.Points) == 0 || len(contract.PartialState) == 0 || contract.Detection == "" || contract.ResumeContract == "" ||
+ contract.RollbackContract == "" || contract.CompensationContract == "" || contract.RecoveryAuthority == "" || contract.ResumptionPredicate == "" {
+ return fmt.Errorf("%s: interruption and recovery contract is incomplete", t.ID)
+ }
+ if t.Controllable() && contract.Recovery == "" {
+ return fmt.Errorf("%s: controllable transition has no interruption recovery transition", t.ID)
+ }
+ if !t.Controllable() && contract.Recovery != "" {
+ return fmt.Errorf("%s: observed transition cannot own an interruption recovery transition", t.ID)
+ }
+ if t.TerminalEffect == "" {
+ return fmt.Errorf("%s: terminal effect declaration is required", t.ID)
+ }
+ parameterNames := map[string]bool{}
+ for _, parameter := range t.Parameters {
+ if parameter.Name == "" || parameterNames[parameter.Name] {
+ return fmt.Errorf("%s: parameter names must be non-empty and unique", t.ID)
+ }
+ parameterNames[parameter.Name] = true
+ }
+ if t.Reversibility == "" || t.PrivacyClassification == "" || t.TelemetryClassification == "" || t.CostClass == "" {
+ return fmt.Errorf("%s: recovery/telemetry metadata are incomplete", t.ID)
+ }
+ if t.Priority < 1 {
+ return fmt.Errorf("%s: priority must be positive", t.ID)
+ }
+ return nil
+}
+
+func (r Registry) Len() int { return len(r.ordered) }
+
+func (r Registry) All() []Transition { return append([]Transition(nil), r.ordered...) }
+
+func (r Registry) Lookup(id TransitionID) (Transition, bool) {
+ transition, ok := r.byID[id]
+ return transition, ok
+}
+
+func (r Registry) Admissible(snapshot model.Snapshot, goal model.Goal) []Transition {
+ var result []Transition
+ for _, transition := range r.ordered {
+ if transition.Controllable() && transition.SourceMatches(snapshot) && transition.SupportsGoal(goal) {
+ result = append(result, transition)
+ }
+ }
+ return result
+}
diff --git a/boatstack/internal/kernel/durable/binding.go b/boatstack/internal/kernel/durable/binding.go
new file mode 100644
index 0000000..d074900
--- /dev/null
+++ b/boatstack/internal/kernel/durable/binding.go
@@ -0,0 +1,64 @@
+package durable
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+const BindingSchemaVersion = 1
+
+type Binding struct {
+ SchemaVersion int `json:"schema_version"`
+ RepositoryID string `json:"repository_id"`
+ GitCommonID string `json:"git_common_id"`
+ Topology model.Topology `json:"topology"`
+ ControllerID string `json:"controller_id"`
+ ConfigAuthority string `json:"config_authority"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func (b Binding) Validate() error {
+ if b.SchemaVersion != BindingSchemaVersion || b.RepositoryID == "" || b.GitCommonID == "" || b.ControllerID == "" || b.ConfigAuthority == "" || b.CreatedAt.IsZero() {
+ return fmt.Errorf("binding schema and identity fields are required")
+ }
+ if b.Topology != model.TopologyDetached && b.Topology != model.TopologyHybrid {
+ return fmt.Errorf("external binding requires detached or hybrid topology")
+ }
+ if b.ConfigAuthority != "repository" && b.ConfigAuthority != "external" {
+ return fmt.Errorf("external binding has invalid configuration authority %q", b.ConfigAuthority)
+ }
+ return nil
+}
+
+func EncodeBinding(binding Binding) ([]byte, error) {
+ if err := binding.Validate(); err != nil {
+ return nil, err
+ }
+ value, err := json.MarshalIndent(binding, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append(value, '\n'), nil
+}
+
+func DecodeBinding(value []byte) (Binding, error) {
+ var binding Binding
+ decoder := json.NewDecoder(bytes.NewReader(value))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&binding); err != nil {
+ return Binding{}, err
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return Binding{}, fmt.Errorf("binding contains trailing JSON")
+ }
+ if err := binding.Validate(); err != nil {
+ return Binding{}, err
+ }
+ return binding, nil
+}
diff --git a/boatstack/internal/kernel/durable/state.go b/boatstack/internal/kernel/durable/state.go
new file mode 100644
index 0000000..dcd8c4d
--- /dev/null
+++ b/boatstack/internal/kernel/durable/state.go
@@ -0,0 +1,179 @@
+package durable
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "sort"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+const StateSchemaVersion = 1
+
+type GateEvidence struct {
+ Gate string `json:"gate"`
+ Revision string `json:"revision"`
+ Fingerprint string `json:"fingerprint"`
+}
+
+type State struct {
+ SchemaVersion int `json:"schema_version"`
+ RepositoryID string `json:"repository_id"`
+ GitCommonID string `json:"git_common_id"`
+ WorktreeID string `json:"worktree_id"`
+ Revision uint64 `json:"revision"`
+ Phase model.ProtocolPhase `json:"phase"`
+ Engagement model.EngagementState `json:"engagement"`
+ Delivery model.DeliveryState `json:"delivery"`
+ Workspace model.WorkspaceState `json:"workspace"`
+ Plan model.PlanState `json:"plan"`
+ Configuration model.ConfigurationState `json:"configuration"`
+ Runtime model.RuntimeState `json:"runtime"`
+ Publication model.PublicationState `json:"publication"`
+ Verification model.VerificationState `json:"verification"`
+ Recovery model.RecoveryState `json:"recovery"`
+ Transaction model.TransactionState `json:"transaction"`
+ Terminal model.TerminalStatus `json:"terminal"`
+ Goal model.Goal `json:"goal"`
+ SourceRevision string `json:"source_revision,omitempty"`
+ WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"`
+ ConfigFingerprint string `json:"config_fingerprint,omitempty"`
+ PlanApprovalPolicy string `json:"plan_approval_policy,omitempty"`
+ VisualEvidencePolicy string `json:"visual_evidence_policy,omitempty"`
+ ExternalEffectPolicy string `json:"external_effect_policy,omitempty"`
+ IndependentReview bool `json:"independent_review_for_high_risk,omitempty"`
+ EnabledHosts []string `json:"enabled_hosts,omitempty"`
+ RuntimeFingerprint string `json:"runtime_fingerprint,omitempty"`
+ RuntimePath string `json:"runtime_path,omitempty"`
+ RuntimeSource string `json:"runtime_source_revision,omitempty"`
+ PlanFingerprint string `json:"plan_fingerprint,omitempty"`
+ WorkspaceBranch string `json:"workspace_branch,omitempty"`
+ WorkspacePath string `json:"workspace_path,omitempty"`
+ WorkspaceBaseRef string `json:"workspace_base_ref,omitempty"`
+ WorkspaceSourcePath string `json:"workspace_source_path,omitempty"`
+ WorkspaceSourceID string `json:"workspace_source_worktree_id,omitempty"`
+ WorkspaceSourceRef string `json:"workspace_source_ref,omitempty"`
+ PublicationID string `json:"publication_id,omitempty"`
+ PublicationURL string `json:"publication_url,omitempty"`
+ PreviewFingerprint string `json:"preview_fingerprint,omitempty"`
+ TransactionID string `json:"transaction_id,omitempty"`
+ TransactionTransition string `json:"transaction_transition,omitempty"`
+ RecoveryCause string `json:"recovery_cause,omitempty"`
+ RecoverySourcePhase model.ProtocolPhase `json:"recovery_source_phase,omitempty"`
+ RecoveryResumption model.ProtocolPhase `json:"recovery_resumption,omitempty"`
+ RecoveryBudget int `json:"recovery_budget_remaining,omitempty"`
+ LastTransition catalog.TransitionID `json:"last_transition,omitempty"`
+ Gates []GateEvidence `json:"gates,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func Default(invocation model.InvocationContext, now time.Time) State {
+ return State{
+ SchemaVersion: StateSchemaVersion, RepositoryID: invocation.RepositoryID, GitCommonID: invocation.GitCommonID, WorktreeID: invocation.WorktreeID,
+ Revision: 1, Phase: model.PhaseDormant, Engagement: model.EngagementDormant, Delivery: model.DeliveryUninitialized,
+ Workspace: model.WorkspaceAbsent, Plan: model.PlanAbsent, Configuration: model.ConfigurationUnsupported,
+ Runtime: model.RuntimeAbsent, Publication: model.PublicationNone, Verification: model.VerificationUnverified,
+ Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, UpdatedAt: now.UTC(),
+ }
+}
+
+func (s State) Validate() error {
+ if s.SchemaVersion != StateSchemaVersion {
+ return fmt.Errorf("durable state schema %d, want %d", s.SchemaVersion, StateSchemaVersion)
+ }
+ if s.RepositoryID == "" || s.GitCommonID == "" || s.WorktreeID == "" || s.Revision == 0 || s.UpdatedAt.IsZero() {
+ return fmt.Errorf("durable state identity, revision, and update time are required")
+ }
+ if !s.Phase.Valid() || !s.Engagement.Valid() || !s.Delivery.Valid() || !s.Workspace.Valid() || !s.Plan.Valid() ||
+ !s.Configuration.Valid() || !s.Runtime.Valid() || !s.Publication.Valid() || !s.Verification.Valid() ||
+ !s.Recovery.Valid() || !s.Transaction.Valid() || !s.Terminal.Valid() {
+ return fmt.Errorf("durable state contains an invalid controlling value")
+ }
+ if s.Phase == model.PhaseRecovery && s.Recovery == model.RecoveryNone {
+ return fmt.Errorf("durable recovery phase has no recovery classification")
+ }
+ if s.Recovery != model.RecoveryNone && (s.TransactionID == "" || s.RecoveryCause == "" || !s.RecoverySourcePhase.Valid() || !s.RecoveryResumption.Valid() || s.RecoveryBudget < 0) {
+ return fmt.Errorf("durable recovery state has incomplete recovery context")
+ }
+ 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.Configuration == model.ConfigurationVerified && s.ConfigFingerprint == "" {
+ return fmt.Errorf("verified configuration requires a fingerprint")
+ }
+ if s.Configuration == model.ConfigurationVerified {
+ if err := s.ConfigurationPolicy().Validate(); err != nil {
+ return fmt.Errorf("verified configuration policy: %w", err)
+ }
+ }
+ switch s.Workspace {
+ case model.WorkspaceCut, model.WorkspaceActive, model.WorkspacePublished, model.WorkspaceLanded, model.WorkspaceAttentionRequired, model.WorkspaceAbandoned:
+ if s.WorkspacePath == "" || s.WorkspaceBranch == "" || s.WorkspaceSourcePath == "" || s.WorkspaceSourceID == "" || s.WorkspaceSourceRef == "" {
+ return fmt.Errorf("managed workspace state requires destination and source identity")
+ }
+ }
+ if s.Terminal == model.TerminalEstablished && s.Phase != model.PhaseTerminal && s.Phase != model.PhaseAbandoned {
+ return fmt.Errorf("durable terminal evidence has a nonterminal phase")
+ }
+ seenGates := map[string]bool{}
+ for _, gate := range s.Gates {
+ if gate.Gate == "" || gate.Revision == "" || gate.Fingerprint == "" || seenGates[gate.Gate] {
+ return fmt.Errorf("durable gate evidence must be complete and unique")
+ }
+ seenGates[gate.Gate] = true
+ }
+ return nil
+}
+
+func (s State) Canonical() State {
+ result := s
+ result.Gates = append([]GateEvidence(nil), s.Gates...)
+ result.EnabledHosts = append([]string(nil), s.EnabledHosts...)
+ sort.Strings(result.EnabledHosts)
+ sort.Slice(result.Gates, func(i, j int) bool { return result.Gates[i].Gate < result.Gates[j].Gate })
+ return result
+}
+
+func (s State) ConfigurationPolicy() model.ConfigurationPolicy {
+ return model.ConfigurationPolicy{
+ PlanApproval: s.PlanApprovalPolicy, IndependentReviewForHighRisk: s.IndependentReview,
+ VisualEvidence: s.VisualEvidencePolicy, ExternalEffectAuthority: s.ExternalEffectPolicy,
+ Hosts: append([]string(nil), s.EnabledHosts...),
+ }.Canonical()
+}
+
+func EncodeState(state State) ([]byte, error) {
+ state = state.Canonical()
+ if err := state.Validate(); err != nil {
+ return nil, err
+ }
+ value, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append(value, '\n'), nil
+}
+
+func DecodeState(value []byte) (State, error) {
+ var state State
+ decoder := json.NewDecoder(bytes.NewReader(value))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&state); err != nil {
+ return State{}, err
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return State{}, fmt.Errorf("durable state contains trailing JSON")
+ }
+ if err := state.Validate(); err != nil {
+ return State{}, err
+ }
+ return state.Canonical(), nil
+}
diff --git a/boatstack/internal/kernel/engine/engine.go b/boatstack/internal/kernel/engine/engine.go
new file mode 100644
index 0000000..19626d1
--- /dev/null
+++ b/boatstack/internal/kernel/engine/engine.go
@@ -0,0 +1,361 @@
+package engine
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "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/kernel/supervisor"
+)
+
+type Engine struct {
+ registry catalog.Registry
+ control supervisor.Supervisor
+ observer ports.Observer
+ clock ports.Clock
+ locker ports.Locker
+ journal ports.Journal
+ effects ports.EffectDriver
+ receipts ports.ReceiptStore
+}
+
+func New(registry catalog.Registry, observer ports.Observer, clock ports.Clock, locker ports.Locker, journal ports.Journal, effects ports.EffectDriver, receipts ports.ReceiptStore) (Engine, error) {
+ if registry.Len() == 0 || observer == nil || clock == nil || locker == nil || journal == nil || effects == nil || receipts == nil {
+ return Engine{}, fmt.Errorf("kernel engine requires registry, observer, clock, locker, journal, effects, and receipt store")
+ }
+ return Engine{registry: registry, control: supervisor.New(registry), observer: observer, clock: clock, locker: locker, journal: journal, effects: effects, receipts: receipts}, nil
+}
+
+type ResolveRequest struct {
+ Invocation model.InvocationContext
+ Goal model.Goal
+ Authority protocol.AuthorityBundle
+ Requested catalog.TransitionID
+}
+
+type Resolution struct {
+ Snapshot model.Snapshot
+ Goal model.Goal
+ Decision supervisor.Decision
+}
+
+func (e Engine) Resolve(ctx context.Context, request ResolveRequest) (Resolution, error) {
+ if err := request.Invocation.Validate(false); err != nil {
+ return Resolution{}, err
+ }
+ observation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation})
+ if err != nil {
+ return Resolution{}, fmt.Errorf("observe plant: %w", err)
+ }
+ snapshot, err := model.Canonicalize(observation)
+ if err != nil {
+ return Resolution{}, fmt.Errorf("canonicalize observation: %w", err)
+ }
+ if snapshot.Invocation != request.Invocation {
+ return Resolution{}, fmt.Errorf("observer returned a different invocation identity")
+ }
+ goal := request.Goal
+ if err := goal.Validate(); err != nil {
+ if snapshot.Goal.Status != model.FactKnown {
+ return Resolution{}, fmt.Errorf("no valid requested or configured goal: %w", err)
+ }
+ goal = snapshot.Goal.Value
+ }
+ now := e.clock.Now()
+ if err := request.Authority.Validate(now); err != nil {
+ return Resolution{}, err
+ }
+ decision := e.control.Resolve(snapshot, goal, request.Authority.Set(now), request.Requested)
+ return Resolution{Snapshot: snapshot, Goal: goal, Decision: decision}, nil
+}
+
+type ApplyRequest struct {
+ ResolveRequest
+ FlowID string
+ Parameters protocol.Parameters
+ IdempotencyKey string
+ AdmissionLifetime time.Duration
+}
+
+type ApplyResult struct {
+ Source model.Snapshot
+ Target model.Snapshot
+ Goal model.Goal
+ Decision supervisor.Decision
+ Admission protocol.Admission
+ Receipt protocol.TransitionReceipt
+ Replayed bool
+}
+
+type DecisionError struct{ Decision supervisor.Decision }
+
+func (e DecisionError) Error() string {
+ return fmt.Sprintf("kernel decision %s: %s", e.Decision.Kind, e.Decision.Reason)
+}
+
+type StaleAdmissionError struct{ Err error }
+
+func (e StaleAdmissionError) Error() string { return "stale admission: " + e.Err.Error() }
+func (e StaleAdmissionError) Unwrap() error { return e.Err }
+
+type PostconditionError struct {
+ Transition catalog.TransitionID
+ Recovery catalog.TransitionID
+}
+
+func (e PostconditionError) Error() string {
+ return fmt.Sprintf("transition %q did not establish its target predicate; recovery %q is required", e.Transition, e.Recovery)
+}
+
+type ExternalOutcomeUnknownError struct {
+ Transition catalog.TransitionID
+ Recovery catalog.TransitionID
+}
+
+type ReplayRecoveryError struct{ ReceiptID string }
+
+func (e ReplayRecoveryError) Error() string {
+ return fmt.Sprintf("idempotency receipt %q exists, but its transaction state still requires recovery", e.ReceiptID)
+}
+
+func (e ExternalOutcomeUnknownError) Error() string {
+ return fmt.Sprintf("transition %q has an unknown external outcome; reconcile with %q", e.Transition, e.Recovery)
+}
+
+func (e Engine) Apply(ctx context.Context, request ApplyRequest) (result ApplyResult, returnErr error) {
+ if request.FlowID == "" {
+ return result, fmt.Errorf("kernel apply requires flow identity")
+ }
+ if request.IdempotencyKey != "" {
+ prior, ok, err := e.receipts.FindByIdempotency(ctx, request.Invocation, request.IdempotencyKey)
+ if err != nil {
+ return result, fmt.Errorf("check supplied idempotency key: %w", err)
+ }
+ if ok {
+ if err := validateReplayRequest(prior, request); err != nil {
+ return result, err
+ }
+ observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation})
+ if observeErr != nil {
+ return result, observeErr
+ }
+ snapshot, canonicalErr := model.Canonicalize(observation)
+ if canonicalErr != nil {
+ return result, canonicalErr
+ }
+ if !replayStateSettled(snapshot) {
+ return result, ReplayRecoveryError{ReceiptID: prior.ID}
+ }
+ result.Source, result.Target, result.Goal, result.Receipt, result.Replayed = snapshot, snapshot, request.Goal, prior, true
+ if result.Goal.Validate() != nil && snapshot.Goal.Status == model.FactKnown {
+ result.Goal = snapshot.Goal.Value
+ }
+ return result, nil
+ }
+ }
+ if request.AdmissionLifetime <= 0 {
+ request.AdmissionLifetime = 2 * time.Minute
+ }
+ resolution, err := e.Resolve(ctx, request.ResolveRequest)
+ if err != nil {
+ return result, err
+ }
+ result.Source, result.Goal, result.Decision = resolution.Snapshot, resolution.Goal, resolution.Decision
+ request.Goal = resolution.Goal
+ if resolution.Decision.Kind != supervisor.DecisionPrescribed || resolution.Decision.Transition == nil {
+ return result, DecisionError{Decision: resolution.Decision}
+ }
+ transition := *resolution.Decision.Transition
+ now := e.clock.Now()
+ admission, err := protocol.NewAdmission(resolution.Snapshot, request.Goal, transition, request.Authority, request.Parameters, now, request.AdmissionLifetime)
+ if err != nil {
+ return result, err
+ }
+ result.Admission = admission
+ if request.IdempotencyKey != "" && request.IdempotencyKey != admission.IdempotencyKey {
+ return result, fmt.Errorf("supplied idempotency key does not match the exact admitted request")
+ }
+ if err := e.receipts.Bind(ctx, request.FlowID, admission); err != nil {
+ return result, err
+ }
+ defer e.receipts.Unbind(request.FlowID)
+ if prior, ok, err := e.receipts.FindByIdempotency(ctx, request.Invocation, admission.IdempotencyKey); err != nil {
+ return result, fmt.Errorf("check idempotency: %w", err)
+ } else if ok {
+ if err := validateReplayRequest(prior, request); err != nil {
+ return result, err
+ }
+ observation, observeErr := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation})
+ if observeErr != nil {
+ return result, observeErr
+ }
+ current, canonicalErr := model.Canonicalize(observation)
+ if canonicalErr != nil {
+ return result, canonicalErr
+ }
+ if !replayStateSettled(current) {
+ return result, ReplayRecoveryError{ReceiptID: prior.ID}
+ }
+ result.Target, result.Receipt, result.Replayed = current, prior, true
+ return result, nil
+ }
+
+ lock, err := e.locker.Acquire(ctx, request.Invocation, transition.OwnedResources)
+ if err != nil {
+ return result, fmt.Errorf("acquire effect lock: %w", err)
+ }
+ defer func() {
+ if releaseErr := lock.Release(); releaseErr != nil && returnErr == nil {
+ returnErr = fmt.Errorf("release effect lock: %w", releaseErr)
+ }
+ }()
+
+ lockedObservation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: request.Invocation})
+ if err != nil {
+ return result, err
+ }
+ lockedSnapshot, err := model.Canonicalize(lockedObservation)
+ if err != nil {
+ return result, err
+ }
+ if prior, ok, findErr := e.receipts.FindByIdempotency(ctx, request.Invocation, admission.IdempotencyKey); findErr != nil {
+ return result, fmt.Errorf("check locked idempotency: %w", findErr)
+ } else if ok {
+ if err := validateReplayRequest(prior, request); err != nil {
+ return result, err
+ }
+ if !replayStateSettled(lockedSnapshot) {
+ return result, ReplayRecoveryError{ReceiptID: prior.ID}
+ }
+ result.Target, result.Receipt, result.Replayed = lockedSnapshot, prior, true
+ return result, nil
+ }
+ if err := admission.ValidateCurrent(lockedSnapshot, request.Goal, transition, e.clock.Now()); err != nil {
+ return result, StaleAdmissionError{Err: err}
+ }
+ if err := e.journal.Begin(ctx, admission, transition); err != nil {
+ return result, fmt.Errorf("begin transaction journal: %w", err)
+ }
+ abort := func(reason string, cause error) error {
+ abortErr := e.journal.Abort(ctx, admission.ID, reason)
+ if abortErr != nil {
+ return errors.Join(cause, fmt.Errorf("abort transaction journal: %w", abortErr))
+ }
+ return cause
+ }
+ requireRecovery := func(reason string, cause error) error {
+ recoveryErr := e.journal.RequireRecovery(ctx, admission.ID, reason)
+ if recoveryErr != nil {
+ return errors.Join(cause, fmt.Errorf("mark transaction recovery-required: %w", recoveryErr))
+ }
+ return cause
+ }
+ if err := e.journal.Mark(ctx, admission.ID, "executing"); err != nil {
+ return result, abort("journal mark failed", err)
+ }
+ prepared, err := e.effects.Prepare(ctx, admission, transition)
+ if err != nil {
+ return result, abort("effect preparation failed", err)
+ }
+ if err := e.journal.Stage(ctx, admission.ID, prepared.Manifest()); err != nil {
+ return result, abort("effect staging journal failed", err)
+ }
+ startedAt := e.clock.Now()
+ effectResult, effectErr := prepared.Execute(ctx)
+ if effectErr != nil {
+ rollbackErr := prepared.Rollback(ctx)
+ if rollbackErr != nil {
+ return result, requireRecovery("effect and rollback failed", errors.Join(effectErr, rollbackErr))
+ }
+ return result, abort("effect failed and rolled back", effectErr)
+ }
+ if err := e.journal.Mark(ctx, admission.ID, "verifying"); err != nil {
+ return result, requireRecovery("journal mark failed after effect", err)
+ }
+ verificationInvocation := request.Invocation
+ if transferred, ok := prepared.VerificationInvocation(); ok {
+ verificationInvocation = transferred
+ }
+ targetObservation, err := e.observer.Observe(ctx, ports.ObservationRequest{Invocation: verificationInvocation, IgnoreAdmissionID: admission.ID})
+ if err != nil {
+ return result, requireRecovery("target observation failed after effect", err)
+ }
+ target, err := model.Canonicalize(targetObservation)
+ if err != nil {
+ return result, requireRecovery("target canonicalization failed after effect", err)
+ }
+ result.Target = target
+ if !sameInvocation(target.Invocation, admission.Invocation, transition) {
+ return result, requireRecovery("target identity changed", PostconditionError{Transition: transition.ID, Recovery: transition.Interruption.Recovery})
+ }
+ if !transition.TargetMatches(target) {
+ if effectResult.Settlement == ports.EffectUnknown {
+ unknown := ExternalOutcomeUnknownError{Transition: transition.ID, Recovery: transition.Interruption.Recovery}
+ return result, requireRecovery("external outcome unknown", unknown)
+ }
+ rollbackErr := prepared.Rollback(ctx)
+ postcondition := PostconditionError{Transition: transition.ID, Recovery: transition.Interruption.Recovery}
+ if rollbackErr != nil {
+ return result, requireRecovery("postcondition and rollback failed", errors.Join(postcondition, rollbackErr))
+ }
+ return result, abort("postcondition failed and effect rolled back", postcondition)
+ }
+ sequence, err := e.receipts.NextSequence(ctx, request.FlowID)
+ if err != nil {
+ return result, requireRecovery("sequence allocation failed after verified effect", err)
+ }
+ completedAt := e.clock.Now()
+ receipt, err := protocol.NewReceipt(request.FlowID, sequence, admission, transition, target, startedAt, completedAt, protocol.OutcomeSucceeded, "")
+ if err != nil {
+ return result, requireRecovery("receipt construction failed after verified effect", err)
+ }
+ if err := e.receipts.Append(ctx, receipt); err != nil {
+ return result, requireRecovery("receipt append failed after verified effect", err)
+ }
+ if err := e.journal.Commit(ctx, receipt); err != nil {
+ return result, requireRecovery("journal commit failed after receipt", err)
+ }
+ result.Receipt = receipt
+ return result, nil
+}
+
+func validateReplayRequest(prior protocol.TransitionReceipt, request ApplyRequest) error {
+ if prior.FlowID != request.FlowID {
+ return fmt.Errorf("idempotency receipt belongs to flow %q, not %q", prior.FlowID, request.FlowID)
+ }
+ if request.Goal.Validate() == nil && (prior.GoalID != request.Goal.ID || prior.GoalKind != request.Goal.Kind || prior.DeliveryID != request.Goal.DeliveryID) {
+ return fmt.Errorf("idempotency receipt belongs to a different configured goal")
+ }
+ if request.Requested != "" && prior.TransitionID != request.Requested {
+ return fmt.Errorf("idempotency receipt belongs to transition %q, not %q", prior.TransitionID, request.Requested)
+ }
+ return nil
+}
+
+func replayStateSettled(snapshot model.Snapshot) bool {
+ return snapshot.Phase.Status == model.FactKnown && snapshot.Phase.Value != model.PhaseRecovery &&
+ snapshot.Recovery.Status == model.FactKnown && snapshot.Recovery.Value == model.RecoveryNone &&
+ snapshot.Transaction.Status == model.FactKnown && snapshot.Transaction.Value == model.TransactionNone
+}
+
+func sameInvocation(target, source model.InvocationContext, transition catalog.Transition) bool {
+ if target == source {
+ return true
+ }
+ if transition.AllowsWorktreeTransfer {
+ return target.RepositoryID == source.RepositoryID && target.GitCommonID == source.GitCommonID &&
+ target.ControllerID == source.ControllerID && target.Topology == source.Topology &&
+ target.Host == source.Host && target.Correlation == source.Correlation
+ }
+ if transition.AllowsIdentityRebind {
+ return target.RepositoryID == source.RepositoryID && target.GitCommonID == source.GitCommonID &&
+ target.WorktreeID == source.WorktreeID && target.Ref == source.Ref && target.InvokingPath == source.InvokingPath &&
+ target.Host == source.Host && target.Correlation == source.Correlation
+ }
+ return false
+}
diff --git a/boatstack/internal/kernel/engine/engine_test.go b/boatstack/internal/kernel/engine/engine_test.go
new file mode 100644
index 0000000..568b25c
--- /dev/null
+++ b/boatstack/internal/kernel/engine/engine_test.go
@@ -0,0 +1,317 @@
+package engine
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+type fixedClock struct{ now time.Time }
+
+func (c fixedClock) Now() time.Time { return c.now }
+
+func fixtureAbsolutePath(parts ...string) string {
+ path, err := filepath.Abs(filepath.Join(parts...))
+ if err != nil {
+ panic(err)
+ }
+ return path
+}
+
+type sequenceObserver struct {
+ mu sync.Mutex
+ items []model.Observation
+}
+
+func (o *sequenceObserver) Observe(context.Context, ports.ObservationRequest) (model.Observation, error) {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if len(o.items) == 0 {
+ return model.Observation{}, errors.New("unexpected observation")
+ }
+ item := o.items[0]
+ o.items = o.items[1:]
+ return item, nil
+}
+
+type fakeLock struct{ released bool }
+
+func (l *fakeLock) Release() error { l.released = true; return nil }
+
+type fakeLocker struct{ lock *fakeLock }
+
+func (l fakeLocker) Acquire(context.Context, model.InvocationContext, []string) (ports.Lock, error) {
+ return l.lock, nil
+}
+
+type fakeJournal struct {
+ begun, committed, aborted, recovery int
+ failMark string
+}
+
+func (j *fakeJournal) Begin(context.Context, protocol.Admission, catalog.Transition) error {
+ j.begun++
+ return nil
+}
+func (j *fakeJournal) Stage(context.Context, string, []ports.ResourceMutation) error { return nil }
+func (j *fakeJournal) Mark(_ context.Context, _ string, status string) error {
+ if status == j.failMark {
+ return errors.New("injected journal mark failure")
+ }
+ return nil
+}
+func (j *fakeJournal) Commit(context.Context, protocol.TransitionReceipt) error {
+ j.committed++
+ return nil
+}
+func (j *fakeJournal) Abort(context.Context, string, string) error { j.aborted++; return nil }
+func (j *fakeJournal) RequireRecovery(context.Context, string, string) error {
+ j.recovery++
+ return nil
+}
+
+type fakeEffects struct {
+ executions, rollbacks int
+ result ports.EffectResult
+}
+
+func (e *fakeEffects) Prepare(context.Context, protocol.Admission, catalog.Transition) (ports.PreparedEffect, error) {
+ return e, nil
+}
+func (e *fakeEffects) Manifest() []ports.ResourceMutation { return nil }
+func (e *fakeEffects) VerificationInvocation() (model.InvocationContext, bool) {
+ return model.InvocationContext{}, false
+}
+func (e *fakeEffects) Execute(context.Context) (ports.EffectResult, error) {
+ e.executions++
+ return e.result, nil
+}
+func (e *fakeEffects) Rollback(context.Context) error {
+ e.rollbacks++
+ return nil
+}
+
+type memoryReceipts struct {
+ next uint64
+ values []protocol.TransitionReceipt
+}
+
+func (s *memoryReceipts) Bind(context.Context, string, protocol.Admission) error { return nil }
+func (s *memoryReceipts) Unbind(string) {}
+
+func (s *memoryReceipts) NextSequence(context.Context, string) (uint64, error) {
+ s.next++
+ return s.next, nil
+}
+func (s *memoryReceipts) FindByIdempotency(_ context.Context, _ model.InvocationContext, key string) (protocol.TransitionReceipt, bool, error) {
+ for _, receipt := range s.values {
+ if receipt.IdempotencyKey == key {
+ return receipt, true, nil
+ }
+ }
+ return protocol.TransitionReceipt{}, false, nil
+}
+func (s *memoryReceipts) Append(_ context.Context, receipt protocol.TransitionReceipt) error {
+ s.values = append(s.values, receipt)
+ return nil
+}
+
+func observation(phase model.ProtocolPhase, fingerprint string) model.Observation {
+ e := model.Evidence{Source: "fixture", Fingerprint: fingerprint, ObservedAt: time.Unix(20, 0).UTC()}
+ configurationEvidence := model.Evidence{Source: "configuration:/repo/.boatstack/project.json", Fingerprint: "config-fingerprint", ObservedAt: time.Unix(20, 0).UTC()}
+ 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"},
+ 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),
+ Publication: model.Known(model.PublicationNone, e), Verification: model.Known(model.VerificationUnverified, e), Recovery: model.Known(model.RecoveryNone, e),
+ Transaction: model.Known(model.TransactionNone, e), RecoveryInfo: model.Absent[model.RecoveryContext]("none", e), TransactionInfo: model.Absent[model.TransactionContext]("none", e),
+ Terminal: model.Known(model.TerminalNonterminal, e), Goal: model.Known(model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"}, e), ObservedAt: time.Unix(20, 0).UTC(),
+ }
+}
+
+func recoveryObservation(fingerprint string) model.Observation {
+ value := observation(model.PhaseRecovery, fingerprint)
+ evidence := value.Phase.Evidence[0]
+ value.Recovery = model.Known(model.RecoveryReconcile, evidence)
+ value.Transaction = model.Known(model.TransactionLocalApplied, evidence)
+ value.RecoveryInfo = model.Known(model.RecoveryContext{
+ TransactionID: "adm-pending", Cause: "receipt commit interrupted", SourcePhase: model.PhaseObserved,
+ Permitted: []string{"recovery.escalate"}, BudgetRemaining: 1, Resumption: model.PhaseObserved,
+ }, evidence)
+ value.TransactionInfo = model.Known(model.TransactionContext{ID: "adm-pending", TransitionID: "test.advance", Status: "recovery-required"}, evidence)
+ value.Terminal = model.Known(model.TerminalStale, evidence)
+ return value
+}
+
+func testRegistry(t *testing.T) catalog.Registry {
+ t.Helper()
+ identity := []string{"repository-id", "git-common-id", "worktree-id"}
+ interruption := func(recovery catalog.TransitionID) catalog.InterruptionContract {
+ return catalog.InterruptionContract{
+ Points: []string{"after-effect"}, PartialState: []string{"effect-possibly-installed"}, Detection: "test-observation",
+ ResumeContract: "test-resume", RollbackContract: "test-rollback", CompensationContract: "not-required",
+ Recovery: recovery, RecoveryAuthority: "test-authority", ResumptionPredicate: "test-resumption",
+ }
+ }
+ r, err := catalog.New([]catalog.Transition{{
+ ID: "test.advance", Version: 1, Class: catalog.EventOwnedLocal,
+ SourcePhases: []model.ProtocolPhase{model.PhaseObserved}, TargetPhases: []model.ProtocolPhase{model.PhaseActive},
+ RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.advance", LocalEffects: []catalog.EffectID{"test.advance"}, Idempotent: true,
+ Prescription: catalog.Prescription{Operation: "test.advance", ExpectedPostcondition: "active"}, SourcePredicate: "observed", AdmissionPredicate: "exact-admission", TargetPredicate: "active", Verifier: "fresh-active",
+ SourceConditions: []catalog.FacetCondition{{Facet: model.FacetEngagement, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.EngagementActive)}}},
+ TargetConditions: []catalog.FacetCondition{{Facet: model.FacetEngagement, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.EngagementActive)}}},
+ Interruption: interruption("test.recover"), Reversibility: catalog.Reversible, TerminalEffect: "none",
+ PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "test", Priority: 1,
+ }, {
+ ID: "test.recover", Version: 1, Class: catalog.EventRecovery,
+ SourcePhases: []model.ProtocolPhase{model.PhaseRecovery}, TargetPhases: []model.ProtocolPhase{model.PhaseFrontier},
+ RequiredIdentity: identity, Authority: []catalog.AuthorityClass{catalog.AuthorityRepository}, RequiredEvidence: []string{"snapshot"}, OwnedResources: []string{"state"}, Effect: "test.recover", LocalEffects: []catalog.EffectID{"test.recover"}, Idempotent: true,
+ Prescription: catalog.Prescription{Operation: "test.recover", ExpectedPostcondition: "frontier"}, SourcePredicate: "recovery", AdmissionPredicate: "exact-recovery-admission", TargetPredicate: "frontier", Verifier: "fresh-frontier",
+ SourceConditions: []catalog.FacetCondition{{Facet: model.FacetRecovery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.RecoveryReconcile)}}},
+ TargetConditions: []catalog.FacetCondition{{Facet: model.FacetRecovery, Statuses: []model.FactStatus{model.FactKnown}, Values: []string{string(model.RecoveryEscalated)}}},
+ Interruption: interruption("test.recover"), Reversibility: catalog.Reversible, TerminalEffect: "none",
+ PrivacyClassification: "metadata-only", TelemetryClassification: "transition-receipt", CostClass: "test", Priority: 2,
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func request(now time.Time) ApplyRequest {
+ invocation := observation(model.PhaseObserved, "source").Invocation
+ return ApplyRequest{ResolveRequest: ResolveRequest{
+ Invocation: invocation, Goal: model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"}, Requested: "test.advance",
+ Authority: protocol.AuthorityBundle{Receipts: []protocol.AuthorityReceipt{{ID: "auth", Class: catalog.AuthorityRepository, Subject: "repo", Fingerprint: "config-fingerprint", IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}}},
+ }, FlowID: "flow", AdmissionLifetime: time.Minute}
+}
+
+func TestApplyCrossesAdmissionEffectVerificationAndReceiptBoundary(t *testing.T) {
+ // control-law: managed-effect-requires-exact-admission-and-postcondition
+ now := time.Unix(30, 0).UTC()
+ observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseActive, "target"), observation(model.PhaseActive, "target")}}
+ journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{}
+ kernel, err := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := kernel.Apply(context.Background(), request(now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if effects.executions != 1 || effects.rollbacks != 0 || journal.committed != 1 || journal.aborted != 0 || len(receipts.values) != 1 || result.Receipt.ID == "" || !lock.released {
+ t.Fatalf("unexpected boundary evidence: effects=%+v journal=%+v receipts=%d receipt=%q released=%v", effects, journal, len(receipts.values), result.Receipt.ID, lock.released)
+ }
+ retry := request(now)
+ retry.IdempotencyKey = result.Admission.IdempotencyKey
+ replayed, err := kernel.Apply(context.Background(), retry)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !replayed.Replayed || replayed.Receipt.ID != result.Receipt.ID || effects.executions != 1 || journal.begun != 1 {
+ t.Fatalf("idempotent replay crossed effect boundary: replay=%+v effects=%d journals=%d", replayed, effects.executions, journal.begun)
+ }
+}
+
+func TestIdempotencyReceiptCannotHideUncommittedRecoveryJournal(t *testing.T) {
+ // control-law: receipt-before-journal-commit-is-not-a-clean-replay
+ now := time.Unix(30, 0).UTC()
+ observer := &sequenceObserver{items: []model.Observation{
+ observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseActive, "target"),
+ recoveryObservation("recovery"),
+ }}
+ journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{}
+ kernel, err := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ completed, err := kernel.Apply(context.Background(), request(now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ retry := request(now)
+ retry.IdempotencyKey = completed.Admission.IdempotencyKey
+ _, err = kernel.Apply(context.Background(), retry)
+ var recovery ReplayRecoveryError
+ if !errors.As(err, &recovery) {
+ t.Fatalf("replay error=%v, want ReplayRecoveryError", err)
+ }
+ if effects.executions != 1 {
+ t.Fatalf("recovery replay executed effect %d times", effects.executions)
+ }
+}
+
+func TestApplyRejectsSnapshotDriftBeforeEffect(t *testing.T) {
+ // control-law: stale-prescription-fails-before-mutation
+ now := time.Unix(30, 0).UTC()
+ observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "drifted")}}
+ journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{}, &memoryReceipts{}, &fakeLock{}
+ kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts)
+ _, err := kernel.Apply(context.Background(), request(now))
+ var stale StaleAdmissionError
+ if !errors.As(err, &stale) {
+ t.Fatalf("error = %v, want StaleAdmissionError", err)
+ }
+ if effects.executions != 0 || journal.aborted != 0 || journal.begun != 0 {
+ t.Fatalf("effect executions=%d aborted=%d", effects.executions, journal.aborted)
+ }
+}
+
+func TestApplyRollsBackFailedPostconditionAndDoesNotReceipt(t *testing.T) {
+ // control-law: successful-effect-call-is-not-transition-success
+ now := time.Unix(30, 0).UTC()
+ observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "unchanged")}}
+ journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{}
+ kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts)
+ _, err := kernel.Apply(context.Background(), request(now))
+ var postcondition PostconditionError
+ if !errors.As(err, &postcondition) {
+ t.Fatalf("error = %v, want PostconditionError", err)
+ }
+ if effects.executions != 1 || effects.rollbacks != 1 || len(receipts.values) != 0 || journal.aborted != 1 {
+ t.Fatalf("effects=%+v receipts=%d journal=%+v", effects, len(receipts.values), journal)
+ }
+}
+
+func TestApplyRequiresRecoveryWhenJournalFailsAfterEffect(t *testing.T) {
+ // control-law: post-effect-journal-failure-cannot-be-collapsed-to-abort
+ now := time.Unix(30, 0).UTC()
+ observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source")}}
+ journal := &fakeJournal{failMark: "verifying"}
+ effects, receipts, lock := &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectSettled}}, &memoryReceipts{}, &fakeLock{}
+ kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts)
+ _, err := kernel.Apply(context.Background(), request(now))
+ if err == nil || !strings.Contains(err.Error(), "injected journal mark failure") {
+ t.Fatalf("error=%v, want injected post-effect journal failure", err)
+ }
+ if effects.executions != 1 || effects.rollbacks != 0 || journal.recovery != 1 || journal.aborted != 0 || len(receipts.values) != 0 {
+ t.Fatalf("post-effect journal failure was not preserved: effects=%+v journal=%+v receipts=%d", effects, journal, len(receipts.values))
+ }
+}
+
+func TestApplyPreservesUnknownExternalOutcomeForReconciliation(t *testing.T) {
+ // control-law: unknown-external-outcome-is-never-retried-or-collapsed-to-false
+ now := time.Unix(30, 0).UTC()
+ observer := &sequenceObserver{items: []model.Observation{observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "source"), observation(model.PhaseObserved, "unchanged")}}
+ journal, effects, receipts, lock := &fakeJournal{}, &fakeEffects{result: ports.EffectResult{Settlement: ports.EffectUnknown}}, &memoryReceipts{}, &fakeLock{}
+ kernel, _ := New(testRegistry(t), observer, fixedClock{now}, fakeLocker{lock}, journal, effects, receipts)
+ _, err := kernel.Apply(context.Background(), request(now))
+ var unknown ExternalOutcomeUnknownError
+ if !errors.As(err, &unknown) {
+ t.Fatalf("error=%v, want ExternalOutcomeUnknownError", err)
+ }
+ if effects.executions != 1 || effects.rollbacks != 0 || journal.recovery != 1 || len(receipts.values) != 0 {
+ t.Fatalf("external uncertainty was not preserved: effects=%+v journal=%+v receipts=%d", effects, journal, len(receipts.values))
+ }
+}
diff --git a/boatstack/internal/kernel/model/facet.go b/boatstack/internal/kernel/model/facet.go
new file mode 100644
index 0000000..62a282b
--- /dev/null
+++ b/boatstack/internal/kernel/model/facet.go
@@ -0,0 +1,100 @@
+package model
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// FacetName is the closed inventory of values that may affect admissibility.
+// Adding a controlling fact requires adding it here, which makes completeness
+// tests fail until the catalog classifies it.
+type FacetName string
+
+const (
+ FacetPhase FacetName = "phase"
+ FacetTopology FacetName = "topology"
+ FacetEngagement FacetName = "engagement"
+ FacetDelivery FacetName = "delivery"
+ FacetWorkspace FacetName = "workspace"
+ FacetPlan FacetName = "plan"
+ FacetConfiguration FacetName = "configuration"
+ FacetConfigurationPolicy FacetName = "configuration-policy"
+ FacetRuntime FacetName = "runtime"
+ FacetPublication FacetName = "publication"
+ FacetVerification FacetName = "verification"
+ FacetRecovery FacetName = "recovery"
+ FacetTransaction FacetName = "transaction"
+ FacetRecoveryInfo FacetName = "recovery-info"
+ FacetTransactionInfo FacetName = "transaction-info"
+ FacetTerminal FacetName = "terminal"
+ FacetGoal FacetName = "goal"
+)
+
+var controllingFacets = []FacetName{
+ FacetPhase, FacetTopology, FacetEngagement, FacetDelivery, FacetWorkspace,
+ FacetPlan, FacetConfiguration, FacetConfigurationPolicy, FacetRuntime, FacetPublication,
+ FacetVerification, FacetRecovery, FacetTransaction, FacetRecoveryInfo,
+ FacetTransactionInfo, FacetTerminal, FacetGoal,
+}
+
+func ControllingFacets() []FacetName { return append([]FacetName(nil), controllingFacets...) }
+
+func (f FacetName) Valid() bool {
+ for _, candidate := range controllingFacets {
+ if f == candidate {
+ return true
+ }
+ }
+ return false
+}
+
+// Facet returns the status and canonical scalar value used by catalog
+// predicates. Composite contexts are represented by stable, sorted identity
+// fields; their evidence remains in the snapshot fingerprint.
+func (s Snapshot) Facet(name FacetName) (FactStatus, string, bool) {
+ switch name {
+ case FacetPhase:
+ return s.Phase.Status, string(s.Phase.Value), true
+ case FacetTopology:
+ return FactKnown, string(s.Invocation.Topology), true
+ case FacetEngagement:
+ return s.Engagement.Status, string(s.Engagement.Value), true
+ case FacetDelivery:
+ return s.Delivery.Status, string(s.Delivery.Value), true
+ case FacetWorkspace:
+ return s.Workspace.Status, string(s.Workspace.Value), true
+ case FacetPlan:
+ return s.Plan.Status, string(s.Plan.Value), true
+ case FacetConfiguration:
+ return s.Configuration.Status, string(s.Configuration.Value), true
+ case FacetConfigurationPolicy:
+ value := s.ConfigurationPolicy.Value.Canonical()
+ return s.ConfigurationPolicy.Status, strings.Join([]string{value.PlanApproval, value.VisualEvidence, value.ExternalEffectAuthority, fmt.Sprint(value.IndependentReviewForHighRisk), fmt.Sprint(value.HighRiskChange), strings.Join(value.Hosts, ",")}, "|"), true
+ case FacetRuntime:
+ return s.Runtime.Status, string(s.Runtime.Value), true
+ case FacetPublication:
+ return s.Publication.Status, string(s.Publication.Value), true
+ case FacetVerification:
+ return s.Verification.Status, string(s.Verification.Value), true
+ case FacetRecovery:
+ return s.Recovery.Status, string(s.Recovery.Value), true
+ case FacetTransaction:
+ return s.Transaction.Status, string(s.Transaction.Value), true
+ case FacetRecoveryInfo:
+ value := s.RecoveryInfo.Value
+ return s.RecoveryInfo.Status, strings.Join([]string{value.TransactionID, value.Cause, string(value.SourcePhase), string(value.Resumption)}, "|"), true
+ case FacetTransactionInfo:
+ value := s.TransactionInfo.Value
+ resources := append([]string(nil), value.ResourceDigests...)
+ sort.Strings(resources)
+ return s.TransactionInfo.Status, strings.Join([]string{value.ID, value.TransitionID, value.Status, strings.Join(resources, ","), fmt.Sprint(value.ExternalPossible)}, "|"), true
+ case FacetTerminal:
+ return s.Terminal.Status, string(s.Terminal.Value), true
+ case FacetGoal:
+ value := s.Goal.Value
+ return s.Goal.Status, strings.Join([]string{value.ID, string(value.Kind), value.DeliveryID, value.EvidenceFingerprint, fmt.Sprint(value.FrontierIsStop)}, "|"), true
+ default:
+ return "", "", false
+ }
+}
diff --git a/boatstack/internal/kernel/model/fact.go b/boatstack/internal/kernel/model/fact.go
new file mode 100644
index 0000000..f07791b
--- /dev/null
+++ b/boatstack/internal/kernel/model/fact.go
@@ -0,0 +1,71 @@
+package model
+
+import (
+ "fmt"
+ "time"
+)
+
+// FactStatus keeps absence, uncertainty, staleness, ambiguity, and conflict
+// distinct. A zero status is invalid so omitted controlling facts fail closed.
+type FactStatus string
+
+const (
+ FactKnown FactStatus = "known"
+ FactAbsent FactStatus = "absent"
+ FactUnknown FactStatus = "unknown"
+ FactStale FactStatus = "stale"
+ FactAmbiguous FactStatus = "ambiguous"
+ FactConflicting FactStatus = "conflicting"
+)
+
+func (s FactStatus) Valid() bool {
+ switch s {
+ case FactKnown, FactAbsent, FactUnknown, FactStale, FactAmbiguous, FactConflicting:
+ return true
+ default:
+ return false
+ }
+}
+
+// Evidence identifies the observation that supports a controlling fact.
+type Evidence struct {
+ Source string `json:"source"`
+ Fingerprint string `json:"fingerprint"`
+ Revision string `json:"revision,omitempty"`
+ ObservedAt time.Time `json:"observed_at,omitempty"`
+}
+
+// Fact is the only representation for controlling observed values.
+type Fact[T any] struct {
+ Status FactStatus `json:"status"`
+ Value T `json:"value,omitempty"`
+ Evidence []Evidence `json:"evidence,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+
+func Known[T any](value T, evidence Evidence) Fact[T] {
+ return Fact[T]{Status: FactKnown, Value: value, Evidence: []Evidence{evidence}}
+}
+
+func Absent[T any](detail string, evidence ...Evidence) Fact[T] {
+ return Fact[T]{Status: FactAbsent, Detail: detail, Evidence: evidence}
+}
+
+func Unknown[T any](status FactStatus, detail string, evidence ...Evidence) Fact[T] {
+ return Fact[T]{Status: status, Detail: detail, Evidence: evidence}
+}
+
+func (f Fact[T]) Validate(name string) error {
+ if !f.Status.Valid() {
+ return fmt.Errorf("%s: invalid or missing fact status %q", name, f.Status)
+ }
+ if f.Status == FactKnown && len(f.Evidence) == 0 {
+ return fmt.Errorf("%s: known controlling fact has no evidence", name)
+ }
+ for i, evidence := range f.Evidence {
+ if evidence.Source == "" || evidence.Fingerprint == "" {
+ return fmt.Errorf("%s: evidence %d requires source and fingerprint", name, i)
+ }
+ }
+ return nil
+}
diff --git a/boatstack/internal/kernel/model/goal.go b/boatstack/internal/kernel/model/goal.go
new file mode 100644
index 0000000..39ff2e5
--- /dev/null
+++ b/boatstack/internal/kernel/model/goal.go
@@ -0,0 +1,45 @@
+package model
+
+import (
+ "fmt"
+ "regexp"
+)
+
+type GoalKind string
+
+const (
+ GoalApprovedPlan GoalKind = "approved-plan"
+ GoalVerified GoalKind = "verified-implementation"
+ GoalOpenPR GoalKind = "open-or-updated-pr"
+ GoalMerged GoalKind = "merged-delivery"
+ GoalAbandoned GoalKind = "safely-abandoned"
+)
+
+func (k GoalKind) Valid() bool {
+ switch k {
+ case GoalApprovedPlan, GoalVerified, GoalOpenPR, GoalMerged, GoalAbandoned:
+ return true
+ default:
+ return false
+ }
+}
+
+type Goal struct {
+ ID string `json:"id"`
+ Kind GoalKind `json:"kind"`
+ DeliveryID string `json:"delivery_id"`
+ EvidenceFingerprint string `json:"evidence_fingerprint,omitempty"`
+ FrontierIsStop bool `json:"frontier_is_stop,omitempty"`
+}
+
+var safeGoalIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+func (g Goal) Validate() error {
+ if !safeGoalIdentity.MatchString(g.ID) || !safeGoalIdentity.MatchString(g.DeliveryID) {
+ return fmt.Errorf("goal: id and delivery identity must be safe semantic segments")
+ }
+ if !g.Kind.Valid() {
+ return fmt.Errorf("goal: invalid kind %q", g.Kind)
+ }
+ return nil
+}
diff --git a/boatstack/internal/kernel/model/identity.go b/boatstack/internal/kernel/model/identity.go
new file mode 100644
index 0000000..d1857cc
--- /dev/null
+++ b/boatstack/internal/kernel/model/identity.go
@@ -0,0 +1,67 @@
+package model
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "path/filepath"
+)
+
+type Topology string
+
+const (
+ TopologyEmbedded Topology = "embedded"
+ TopologyDetached Topology = "detached"
+ TopologyHybrid Topology = "hybrid"
+)
+
+func (t Topology) Valid() bool {
+ return t == TopologyEmbedded || t == TopologyDetached || t == TopologyHybrid
+}
+
+// InvocationContext is carried unchanged from a public surface to every
+// observation, admission, effect, verification, and receipt.
+type InvocationContext struct {
+ RepositoryID string `json:"repository_id"`
+ GitCommonID string `json:"git_common_id"`
+ WorktreeID string `json:"worktree_id"`
+ Ref string `json:"ref"`
+ ControllerID string `json:"controller_id"`
+ InvokingPath string `json:"invoking_path"`
+ RuntimePath string `json:"runtime_path"`
+ RuntimeFingerprint string `json:"runtime_fingerprint"`
+ Topology Topology `json:"topology"`
+ Host string `json:"host"`
+ Correlation string `json:"correlation_id"`
+}
+
+// DeriveWorktreeID binds a worktree to both its Git-common identity and its
+// canonical repository root. The destination of workspace.cut can therefore
+// be named before Git creates its administrative directory, while a path by
+// itself can never become effect authority.
+func DeriveWorktreeID(gitCommonID, repositoryRoot string) (string, error) {
+ if gitCommonID == "" || repositoryRoot == "" || !filepath.IsAbs(repositoryRoot) {
+ return "", fmt.Errorf("worktree identity requires git-common identity and an absolute repository root")
+ }
+ digest := sha256.Sum256([]byte(gitCommonID + "\x00" + filepath.Clean(repositoryRoot)))
+ return hex.EncodeToString(digest[:])[:12], nil
+}
+
+func (c InvocationContext) Validate(effectful bool) error {
+ if !c.Topology.Valid() {
+ return fmt.Errorf("invocation: invalid topology %q", c.Topology)
+ }
+ if c.RepositoryID == "" || c.GitCommonID == "" || c.ControllerID == "" {
+ return fmt.Errorf("invocation: repository, git-common, and controller identity are required")
+ }
+ if c.InvokingPath == "" || !filepath.IsAbs(c.InvokingPath) {
+ return fmt.Errorf("invocation: invoking path must be explicit and absolute")
+ }
+ 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")
+ }
+ return nil
+}
diff --git a/boatstack/internal/kernel/model/state.go b/boatstack/internal/kernel/model/state.go
new file mode 100644
index 0000000..d8178c4
--- /dev/null
+++ b/boatstack/internal/kernel/model/state.go
@@ -0,0 +1,550 @@
+package model
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "time"
+)
+
+const SnapshotSchemaVersion = 2
+
+type ProtocolPhase string
+
+const (
+ PhaseDormant ProtocolPhase = "DORMANT"
+ PhaseObserved ProtocolPhase = "OBSERVED"
+ PhasePrescribed ProtocolPhase = "PRESCRIBED"
+ PhaseAdmitted ProtocolPhase = "ADMITTED"
+ PhaseExecutingLocal ProtocolPhase = "EXECUTING_LOCAL"
+ PhaseExecutingExternal ProtocolPhase = "EXECUTING_EXTERNAL"
+ PhaseVerifying ProtocolPhase = "VERIFYING"
+ PhaseActive ProtocolPhase = "ACTIVE"
+ PhaseRecovery ProtocolPhase = "RECOVERY"
+ PhaseUnresolved ProtocolPhase = "UNRESOLVED"
+ PhaseFrontier ProtocolPhase = "FRONTIER"
+ PhaseTerminal ProtocolPhase = "TERMINAL"
+ PhaseAbandoned ProtocolPhase = "ABANDONED"
+)
+
+var orderedProtocolPhases = []ProtocolPhase{
+ PhaseDormant, PhaseObserved, PhasePrescribed, PhaseAdmitted,
+ PhaseExecutingLocal, PhaseExecutingExternal, PhaseVerifying,
+ PhaseActive, PhaseRecovery, PhaseUnresolved, PhaseFrontier,
+ PhaseTerminal, PhaseAbandoned,
+}
+
+var protocolPhases = func() map[ProtocolPhase]struct{} {
+ result := make(map[ProtocolPhase]struct{}, len(orderedProtocolPhases))
+ for _, phase := range orderedProtocolPhases {
+ result[phase] = struct{}{}
+ }
+ return result
+}()
+
+func (p ProtocolPhase) Valid() bool { _, ok := protocolPhases[p]; return ok }
+
+// ProtocolPhases returns the kernel-owned canonical protocol order. Surfaces
+// consume this projection instead of defining their own lifecycle ordering.
+func ProtocolPhases() []ProtocolPhase {
+ return append([]ProtocolPhase(nil), orderedProtocolPhases...)
+}
+
+// IsCompletionTarget identifies phases that close nonblockingness without
+// pretending that frontier or abandonment is successful delivery.
+func (p ProtocolPhase) IsCompletionTarget() bool {
+ return p == PhaseFrontier || p == PhaseTerminal || p == PhaseAbandoned
+}
+
+type EngagementState string
+
+const (
+ EngagementDormant EngagementState = "dormant"
+ EngagementCommand EngagementState = "command"
+ EngagementActive EngagementState = "active"
+ EngagementStale EngagementState = "stale"
+ EngagementConflicting EngagementState = "conflicting"
+ EngagementInvalid EngagementState = "invalid"
+)
+
+func (s EngagementState) Valid() bool {
+ switch s {
+ case EngagementDormant, EngagementCommand, EngagementActive, EngagementStale, EngagementConflicting, EngagementInvalid:
+ return true
+ default:
+ return false
+ }
+}
+
+type DeliveryState string
+
+const (
+ DeliveryUninitialized DeliveryState = "uninitialized"
+ DeliveryPlanning DeliveryState = "planning"
+ DeliveryApproved DeliveryState = "approved"
+ DeliveryActive DeliveryState = "active"
+ DeliveryGatesPassed DeliveryState = "gates-passed"
+ DeliveryPublished DeliveryState = "published"
+ DeliveryAmendment DeliveryState = "amendment"
+ DeliveryInvalid DeliveryState = "invalid"
+ DeliveryRecovery DeliveryState = "recovery"
+ DeliveryDiscarded DeliveryState = "discarded"
+ DeliveryTerminal DeliveryState = "terminal"
+)
+
+func (s DeliveryState) Valid() bool {
+ switch s {
+ case DeliveryUninitialized, DeliveryPlanning, DeliveryApproved, DeliveryActive, DeliveryGatesPassed, DeliveryPublished, DeliveryAmendment, DeliveryInvalid, DeliveryRecovery, DeliveryDiscarded, DeliveryTerminal:
+ return true
+ default:
+ return false
+ }
+}
+
+type WorkspaceState string
+
+const (
+ WorkspaceAbsent WorkspaceState = "absent"
+ WorkspaceCut WorkspaceState = "cut"
+ WorkspaceActive WorkspaceState = "active"
+ WorkspacePublished WorkspaceState = "published"
+ WorkspaceLanded WorkspaceState = "landed"
+ WorkspaceAbandoned WorkspaceState = "abandoned"
+ WorkspaceAttentionRequired WorkspaceState = "attention-required"
+)
+
+func (s WorkspaceState) Valid() bool {
+ switch s {
+ case WorkspaceAbsent, WorkspaceCut, WorkspaceActive, WorkspacePublished, WorkspaceLanded, WorkspaceAbandoned, WorkspaceAttentionRequired:
+ return true
+ default:
+ return false
+ }
+}
+
+type PlanState string
+
+const (
+ PlanAbsent PlanState = "absent"
+ PlanDraft PlanState = "draft"
+ PlanValid PlanState = "valid"
+ PlanApproved PlanState = "approved"
+ PlanLocked PlanState = "locked"
+ PlanStale PlanState = "stale"
+ PlanInvalid PlanState = "invalid"
+ PlanAmendmentRequired PlanState = "amendment-required"
+)
+
+func (s PlanState) Valid() bool {
+ switch s {
+ case PlanAbsent, PlanDraft, PlanValid, PlanApproved, PlanLocked, PlanStale, PlanInvalid, PlanAmendmentRequired:
+ return true
+ default:
+ return false
+ }
+}
+
+type ConfigurationState string
+
+const (
+ ConfigurationVerified ConfigurationState = "verified"
+ ConfigurationStale ConfigurationState = "stale"
+ ConfigurationDivergent ConfigurationState = "divergent"
+ ConfigurationConflicting ConfigurationState = "conflicting"
+ ConfigurationUnsupported ConfigurationState = "unsupported"
+)
+
+func (s ConfigurationState) Valid() bool {
+ switch s {
+ case ConfigurationVerified, ConfigurationStale, ConfigurationDivergent, ConfigurationConflicting, ConfigurationUnsupported:
+ return true
+ default:
+ return false
+ }
+}
+
+// ConfigurationPolicy is the control-relevant projection of the strict V2
+// project document. Keeping it in the canonical snapshot prevents policy bytes
+// from being validated but then ignored by admission or terminal logic.
+type ConfigurationPolicy struct {
+ PlanApproval string `json:"plan_approval"`
+ IndependentReviewForHighRisk bool `json:"independent_review_for_high_risk,omitempty"`
+ HighRiskChange bool `json:"high_risk_change,omitempty"`
+ VisualEvidence string `json:"visual_evidence"`
+ ExternalEffectAuthority string `json:"external_effect_authority"`
+ Hosts []string `json:"hosts"`
+}
+
+func (p ConfigurationPolicy) Canonical() ConfigurationPolicy {
+ p.Hosts = append([]string(nil), p.Hosts...)
+ sort.Strings(p.Hosts)
+ return p
+}
+
+func (p ConfigurationPolicy) Validate() error {
+ if p.PlanApproval != "human" && p.PlanApproval != "human-or-autonomy" {
+ return fmt.Errorf("configuration policy has invalid plan approval %q", p.PlanApproval)
+ }
+ if p.VisualEvidence != "off" && p.VisualEvidence != "optional" && p.VisualEvidence != "required" {
+ return fmt.Errorf("configuration policy has invalid visual evidence %q", p.VisualEvidence)
+ }
+ if p.ExternalEffectAuthority != "human-or-autonomy-plus-provider" {
+ return fmt.Errorf("configuration policy has invalid external authority %q", p.ExternalEffectAuthority)
+ }
+ if len(p.Hosts) == 0 {
+ return fmt.Errorf("configuration policy requires enabled hosts")
+ }
+ seen := map[string]bool{}
+ for _, host := range p.Hosts {
+ if host == "" || seen[host] {
+ return fmt.Errorf("configuration policy has empty or duplicate host %q", host)
+ }
+ seen[host] = true
+ }
+ if !seen["cli"] {
+ return fmt.Errorf("configuration policy requires cli host")
+ }
+ return nil
+}
+
+type RuntimeState string
+
+const (
+ RuntimeAbsent RuntimeState = "absent"
+ RuntimeHydrating RuntimeState = "hydrating"
+ RuntimeVerified RuntimeState = "verified"
+ RuntimeStale RuntimeState = "stale"
+ RuntimeInvalid RuntimeState = "invalid"
+ RuntimeConflicting RuntimeState = "conflicting"
+ RuntimeWrongSource RuntimeState = "wrong-source"
+ RuntimePartiallyPublished RuntimeState = "partially-published"
+)
+
+func (s RuntimeState) Valid() bool {
+ switch s {
+ case RuntimeAbsent, RuntimeHydrating, RuntimeVerified, RuntimeStale, RuntimeInvalid, RuntimeConflicting, RuntimeWrongSource, RuntimePartiallyPublished:
+ return true
+ default:
+ return false
+ }
+}
+
+type PublicationState string
+
+const (
+ PublicationNone PublicationState = "none"
+ PublicationCandidate PublicationState = "candidate"
+ PublicationOpen PublicationState = "open"
+ PublicationClosedUnmerged PublicationState = "closed-unmerged"
+ PublicationMerged PublicationState = "merged"
+ PublicationUnavailable PublicationState = "unavailable"
+ PublicationConflicting PublicationState = "conflicting"
+ PublicationPublishedNotLanded PublicationState = "published-not-landed"
+)
+
+func (s PublicationState) Valid() bool {
+ switch s {
+ case PublicationNone, PublicationCandidate, PublicationOpen, PublicationClosedUnmerged, PublicationMerged, PublicationUnavailable, PublicationConflicting, PublicationPublishedNotLanded:
+ return true
+ default:
+ return false
+ }
+}
+
+type VerificationState string
+
+const (
+ VerificationUnverified VerificationState = "unverified"
+ VerificationCurrent VerificationState = "current"
+ VerificationStale VerificationState = "stale"
+ VerificationFailed VerificationState = "failed"
+ VerificationUnresolved VerificationState = "unresolved"
+)
+
+func (s VerificationState) Valid() bool {
+ switch s {
+ case VerificationUnverified, VerificationCurrent, VerificationStale, VerificationFailed, VerificationUnresolved:
+ return true
+ default:
+ return false
+ }
+}
+
+type RecoveryState string
+
+const (
+ RecoveryNone RecoveryState = "none"
+ RecoveryResumable RecoveryState = "resumable"
+ RecoveryRollback RecoveryState = "rollback"
+ RecoveryCompensation RecoveryState = "compensation"
+ RecoveryReconcile RecoveryState = "reconcile"
+ RecoveryEscalated RecoveryState = "escalated"
+)
+
+func (s RecoveryState) Valid() bool {
+ switch s {
+ case RecoveryNone, RecoveryResumable, RecoveryRollback, RecoveryCompensation, RecoveryReconcile, RecoveryEscalated:
+ return true
+ default:
+ return false
+ }
+}
+
+type TransactionState string
+
+const (
+ TransactionNone TransactionState = "none"
+ TransactionStaged TransactionState = "staged"
+ TransactionLocalApplied TransactionState = "local-applied"
+ TransactionExternalUncertain TransactionState = "external-uncertain"
+ TransactionVerifying TransactionState = "verifying"
+ TransactionCommitted TransactionState = "committed"
+ TransactionCompensating TransactionState = "compensating"
+)
+
+func (s TransactionState) Valid() bool {
+ switch s {
+ case TransactionNone, TransactionStaged, TransactionLocalApplied, TransactionExternalUncertain, TransactionVerifying, TransactionCommitted, TransactionCompensating:
+ return true
+ default:
+ return false
+ }
+}
+
+// RecoveryContext records why the controller entered recovery and the bounded
+// exits that remain legal. It is separate from RecoveryState so a coarse label
+// can never erase transaction identity or the resumption target.
+type RecoveryContext struct {
+ TransactionID string `json:"transaction_id"`
+ Cause string `json:"cause"`
+ SourcePhase ProtocolPhase `json:"source_phase"`
+ Permitted []string `json:"permitted"`
+ BudgetRemaining int `json:"budget_remaining"`
+ Resumption ProtocolPhase `json:"resumption"`
+}
+
+func (c RecoveryContext) Validate() error {
+ if c.TransactionID == "" || c.Cause == "" || !c.SourcePhase.Valid() || !c.Resumption.Valid() || len(c.Permitted) == 0 || c.BudgetRemaining < 0 {
+ return fmt.Errorf("recovery context requires transaction, cause, source, permitted exits, budget, and resumption target")
+ }
+ return nil
+}
+
+// TransactionContext identifies the exact interrupted transition and resources
+// without embedding their bytes in the canonical snapshot.
+type TransactionContext struct {
+ ID string `json:"id"`
+ TransitionID string `json:"transition_id"`
+ Status string `json:"status"`
+ ResourceDigests []string `json:"resource_digests,omitempty"`
+ ExternalPossible bool `json:"external_possible,omitempty"`
+}
+
+func (c TransactionContext) Validate() error {
+ if c.ID == "" || c.TransitionID == "" || c.Status == "" {
+ return fmt.Errorf("transaction context requires id, transition, and status")
+ }
+ return nil
+}
+
+type TerminalStatus string
+
+const (
+ TerminalNonterminal TerminalStatus = "nonterminal"
+ TerminalEstablished TerminalStatus = "established"
+ TerminalStale TerminalStatus = "stale"
+ TerminalUnknown TerminalStatus = "unknown"
+ TerminalConflicting TerminalStatus = "conflicting"
+)
+
+func (s TerminalStatus) Valid() bool {
+ switch s {
+ case TerminalNonterminal, TerminalEstablished, TerminalStale, TerminalUnknown, TerminalConflicting:
+ return true
+ default:
+ return false
+ }
+}
+
+// Observation is the read-only plant result before canonical validation and
+// fingerprinting.
+type Observation struct {
+ SchemaVersion int `json:"schema_version"`
+ Invocation InvocationContext `json:"invocation"`
+ Phase Fact[ProtocolPhase] `json:"phase"`
+ Engagement Fact[EngagementState] `json:"engagement"`
+ Delivery Fact[DeliveryState] `json:"delivery"`
+ Workspace Fact[WorkspaceState] `json:"workspace"`
+ Plan Fact[PlanState] `json:"plan"`
+ Configuration Fact[ConfigurationState] `json:"configuration"`
+ ConfigurationPolicy Fact[ConfigurationPolicy] `json:"configuration_policy"`
+ Runtime Fact[RuntimeState] `json:"runtime"`
+ Publication Fact[PublicationState] `json:"publication"`
+ Verification Fact[VerificationState] `json:"verification"`
+ Recovery Fact[RecoveryState] `json:"recovery"`
+ Transaction Fact[TransactionState] `json:"transaction"`
+ RecoveryInfo Fact[RecoveryContext] `json:"recovery_info"`
+ TransactionInfo Fact[TransactionContext] `json:"transaction_info"`
+ Terminal Fact[TerminalStatus] `json:"terminal"`
+ Goal Fact[Goal] `json:"goal"`
+ ObservedAt time.Time `json:"observed_at"`
+}
+
+type Snapshot struct {
+ Observation
+ Fingerprint string `json:"fingerprint"`
+}
+
+func Canonicalize(observation Observation) (Snapshot, error) {
+ if observation.SchemaVersion != SnapshotSchemaVersion {
+ return Snapshot{}, fmt.Errorf("snapshot: schema version %d, want %d", observation.SchemaVersion, SnapshotSchemaVersion)
+ }
+ if err := observation.Invocation.Validate(false); err != nil {
+ return Snapshot{}, err
+ }
+ checks := []struct {
+ name string
+ err error
+ }{
+ {"phase", observation.Phase.Validate("phase")},
+ {"engagement", observation.Engagement.Validate("engagement")},
+ {"delivery", observation.Delivery.Validate("delivery")},
+ {"workspace", observation.Workspace.Validate("workspace")},
+ {"plan", observation.Plan.Validate("plan")},
+ {"configuration", observation.Configuration.Validate("configuration")},
+ {"configuration_policy", observation.ConfigurationPolicy.Validate("configuration_policy")},
+ {"runtime", observation.Runtime.Validate("runtime")},
+ {"publication", observation.Publication.Validate("publication")},
+ {"verification", observation.Verification.Validate("verification")},
+ {"recovery", observation.Recovery.Validate("recovery")},
+ {"transaction", observation.Transaction.Validate("transaction")},
+ {"recovery_info", observation.RecoveryInfo.Validate("recovery_info")},
+ {"transaction_info", observation.TransactionInfo.Validate("transaction_info")},
+ {"terminal", observation.Terminal.Validate("terminal")},
+ {"goal", observation.Goal.Validate("goal")},
+ }
+ for _, check := range checks {
+ if check.err != nil {
+ return Snapshot{}, check.err
+ }
+ }
+ if observation.Phase.Status == FactKnown && !observation.Phase.Value.Valid() {
+ return Snapshot{}, fmt.Errorf("snapshot: invalid protocol phase %q", observation.Phase.Value)
+ }
+ valueChecks := []struct {
+ name string
+ known bool
+ valid bool
+ }{
+ {"engagement", observation.Engagement.Status == FactKnown, observation.Engagement.Value.Valid()},
+ {"delivery", observation.Delivery.Status == FactKnown, observation.Delivery.Value.Valid()},
+ {"workspace", observation.Workspace.Status == FactKnown, observation.Workspace.Value.Valid()},
+ {"plan", observation.Plan.Status == FactKnown, observation.Plan.Value.Valid()},
+ {"configuration", observation.Configuration.Status == FactKnown, observation.Configuration.Value.Valid()},
+ {"runtime", observation.Runtime.Status == FactKnown, observation.Runtime.Value.Valid()},
+ {"publication", observation.Publication.Status == FactKnown, observation.Publication.Value.Valid()},
+ {"verification", observation.Verification.Status == FactKnown, observation.Verification.Value.Valid()},
+ {"recovery", observation.Recovery.Status == FactKnown, observation.Recovery.Value.Valid()},
+ {"transaction", observation.Transaction.Status == FactKnown, observation.Transaction.Value.Valid()},
+ {"terminal", observation.Terminal.Status == FactKnown, observation.Terminal.Value.Valid()},
+ }
+ for _, check := range valueChecks {
+ if check.known && !check.valid {
+ return Snapshot{}, fmt.Errorf("snapshot: invalid %s value", check.name)
+ }
+ }
+ if observation.ObservedAt.IsZero() {
+ return Snapshot{}, fmt.Errorf("snapshot: observed time is required")
+ }
+ if observation.Phase.Status == FactKnown && observation.Phase.Value == PhaseRecovery && observation.Recovery.Status == FactKnown && observation.Recovery.Value == RecoveryNone {
+ return Snapshot{}, fmt.Errorf("snapshot: recovery phase requires a recovery state")
+ }
+ if observation.Phase.Status == FactKnown && observation.Phase.Value == PhaseRecovery {
+ if observation.RecoveryInfo.Status != FactKnown || observation.RecoveryInfo.Value.Validate() != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: recovery phase requires complete recovery context")
+ }
+ }
+ if observation.Recovery.Status == FactKnown && observation.Recovery.Value != RecoveryNone {
+ if observation.RecoveryInfo.Status != FactKnown || observation.RecoveryInfo.Value.Validate() != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: non-empty recovery state requires complete recovery context")
+ }
+ }
+ if observation.Transaction.Status == FactKnown && observation.Transaction.Value != TransactionNone {
+ if observation.TransactionInfo.Status != FactKnown || observation.TransactionInfo.Value.Validate() != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: active transaction requires complete transaction context")
+ }
+ }
+ if observation.Terminal.Status == FactKnown && observation.Terminal.Value == TerminalEstablished && observation.Phase.Status == FactKnown && observation.Phase.Value != PhaseTerminal && observation.Phase.Value != PhaseAbandoned {
+ return Snapshot{}, fmt.Errorf("snapshot: established terminal evidence requires terminal or abandoned phase")
+ }
+ if observation.Terminal.Status == FactKnown && observation.Terminal.Value == TerminalEstablished {
+ if observation.Goal.Status != FactKnown || observation.Goal.Value.Validate() != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: established terminal evidence requires an exact configured goal")
+ }
+ if observation.Delivery.Status == FactKnown && observation.Delivery.Value != DeliveryTerminal && observation.Delivery.Value != DeliveryDiscarded {
+ return Snapshot{}, fmt.Errorf("snapshot: established terminal evidence requires terminal or discarded delivery")
+ }
+ }
+ if observation.Workspace.Status == FactKnown && observation.Workspace.Value == WorkspaceLanded && observation.Publication.Status == FactKnown && observation.Publication.Value != PublicationMerged {
+ return Snapshot{}, fmt.Errorf("snapshot: landed workspace requires merged publication evidence")
+ }
+ if observation.Phase.Status == FactKnown && observation.Phase.Value == PhaseActive && observation.Engagement.Status == FactKnown && observation.Engagement.Value == EngagementDormant {
+ return Snapshot{}, fmt.Errorf("snapshot: active protocol phase cannot have dormant engagement")
+ }
+ if observation.Goal.Status == FactKnown {
+ if err := observation.Goal.Value.Validate(); err != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: invalid goal fact: %w", err)
+ }
+ }
+ if observation.ConfigurationPolicy.Status == FactKnown {
+ if err := observation.ConfigurationPolicy.Value.Validate(); err != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: invalid configuration policy: %w", err)
+ }
+ observation.ConfigurationPolicy.Value = observation.ConfigurationPolicy.Value.Canonical()
+ }
+ if observation.RecoveryInfo.Status == FactKnown {
+ if err := observation.RecoveryInfo.Value.Validate(); err != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: invalid recovery context: %w", err)
+ }
+ }
+ if observation.TransactionInfo.Status == FactKnown {
+ if err := observation.TransactionInfo.Value.Validate(); err != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: invalid transaction context: %w", err)
+ }
+ }
+ snapshot := Snapshot{Observation: observation}
+ projection := observation
+ projection.ObservedAt = time.Time{}
+ zeroEvidenceTimes(&projection.Phase)
+ zeroEvidenceTimes(&projection.Engagement)
+ zeroEvidenceTimes(&projection.Delivery)
+ zeroEvidenceTimes(&projection.Workspace)
+ zeroEvidenceTimes(&projection.Plan)
+ zeroEvidenceTimes(&projection.Configuration)
+ zeroEvidenceTimes(&projection.ConfigurationPolicy)
+ zeroEvidenceTimes(&projection.Runtime)
+ zeroEvidenceTimes(&projection.Publication)
+ zeroEvidenceTimes(&projection.Verification)
+ zeroEvidenceTimes(&projection.Recovery)
+ zeroEvidenceTimes(&projection.Transaction)
+ zeroEvidenceTimes(&projection.RecoveryInfo)
+ zeroEvidenceTimes(&projection.TransactionInfo)
+ zeroEvidenceTimes(&projection.Terminal)
+ zeroEvidenceTimes(&projection.Goal)
+ raw, err := json.Marshal(Snapshot{Observation: projection})
+ if err != nil {
+ return Snapshot{}, fmt.Errorf("snapshot: canonical encoding: %w", err)
+ }
+ digest := sha256.Sum256(raw)
+ snapshot.Fingerprint = hex.EncodeToString(digest[:])
+ return snapshot, nil
+}
+
+func zeroEvidenceTimes[T any](fact *Fact[T]) {
+ fact.Evidence = append([]Evidence(nil), fact.Evidence...)
+ for index := range fact.Evidence {
+ fact.Evidence[index].ObservedAt = time.Time{}
+ }
+}
diff --git a/boatstack/internal/kernel/model/state_test.go b/boatstack/internal/kernel/model/state_test.go
new file mode 100644
index 0000000..8d9d4d2
--- /dev/null
+++ b/boatstack/internal/kernel/model/state_test.go
@@ -0,0 +1,174 @@
+package model
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func testAbsolutePath(parts ...string) string {
+ path, err := filepath.Abs(filepath.Join(parts...))
+ if err != nil {
+ panic(err)
+ }
+ return path
+}
+
+func testEvidence() Evidence {
+ return Evidence{Source: "fixture", Fingerprint: "sha256:fixture", Revision: "deadbeef", ObservedAt: time.Unix(100, 0).UTC()}
+}
+
+func testObservation(phase ProtocolPhase) Observation {
+ evidence := testEvidence()
+ return 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",
+ Topology: TopologyEmbedded, Host: "cli", Correlation: "corr-1",
+ },
+ Phase: Known(phase, evidence), Engagement: Known(EngagementActive, evidence), Delivery: Known(DeliveryActive, evidence),
+ Workspace: Known(WorkspaceActive, evidence), Plan: Known(PlanApproved, evidence),
+ Configuration: Known(ConfigurationVerified, evidence), Runtime: Known(RuntimeVerified, evidence),
+ ConfigurationPolicy: Known(ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}}, evidence),
+ Publication: Known(PublicationNone, evidence), Verification: Known(VerificationUnverified, evidence),
+ Recovery: Known(RecoveryNone, evidence), Transaction: Known(TransactionNone, evidence),
+ RecoveryInfo: Absent[RecoveryContext]("none", evidence), TransactionInfo: Absent[TransactionContext]("none", evidence),
+ Terminal: Known(TerminalNonterminal, evidence), Goal: Absent[Goal]("not configured", evidence), ObservedAt: time.Unix(100, 0).UTC(),
+ }
+}
+
+func TestCanonicalizeIsDeterministicAndControlSensitive(t *testing.T) {
+ // control-law: canonical-snapshot-is-the-only-state-authority
+ one, err := Canonicalize(testObservation(PhaseObserved))
+ if err != nil {
+ t.Fatal(err)
+ }
+ two, err := Canonicalize(testObservation(PhaseObserved))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if one.Fingerprint != two.Fingerprint {
+ t.Fatalf("same observation produced %q and %q", one.Fingerprint, two.Fingerprint)
+ }
+ changed := testObservation(PhaseActive)
+ three, err := Canonicalize(changed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if one.Fingerprint == three.Fingerprint {
+ t.Fatal("controlling phase change did not change snapshot fingerprint")
+ }
+}
+
+func TestCanonicalizeRejectsKnownFactWithoutEvidence(t *testing.T) {
+ // control-law: controlling-facts-carry-evidence
+ observation := testObservation(PhaseObserved)
+ observation.Publication.Evidence = nil
+ if _, err := Canonicalize(observation); err == nil {
+ t.Fatal("known publication without evidence was accepted")
+ }
+}
+
+func TestInvocationContextRejectsPathOnlyEffectIdentity(t *testing.T) {
+ // control-law: effectful-identity-is-explicit-and-injective
+ context := testObservation(PhaseObserved).Invocation
+ context.WorktreeID = ""
+ if err := context.Validate(true); err == nil {
+ t.Fatal("effectful invocation without worktree identity was accepted")
+ }
+}
+
+func TestInvocationContextRejectsEffectWithoutExactRuntimeIdentity(t *testing.T) {
+ context := testObservation(PhaseObserved).Invocation
+ context.RuntimeFingerprint = ""
+ if err := context.Validate(true); err == nil {
+ t.Fatal("effectful invocation without exact executing runtime was accepted")
+ }
+}
+
+func TestCanonicalizeRejectsEstablishedTerminalInActivePhase(t *testing.T) {
+ // control-law: terminal-is-an-evidence-backed-goal-state
+ observation := testObservation(PhaseActive)
+ observation.Terminal.Value = TerminalEstablished
+ if _, err := Canonicalize(observation); err == nil {
+ t.Fatal("established terminal evidence in active phase was accepted")
+ }
+}
+
+func TestEveryControllingFacetChangesCanonicalIdentity(t *testing.T) {
+ // control-law: snapshot-fingerprint-covers-every-admissibility-input
+ baseObservation := testObservation(PhaseObserved)
+ base, err := Canonicalize(baseObservation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ evidence := testEvidence()
+ mutations := map[FacetName]func(*Observation){
+ FacetPhase: func(o *Observation) { o.Phase = Known(PhaseActive, evidence) },
+ FacetTopology: func(o *Observation) { o.Invocation.Topology = TopologyDetached },
+ FacetEngagement: func(o *Observation) { o.Engagement = Known(EngagementCommand, evidence) },
+ FacetDelivery: func(o *Observation) { o.Delivery = Known(DeliveryApproved, evidence) },
+ FacetWorkspace: func(o *Observation) { o.Workspace = Known(WorkspaceCut, evidence) },
+ FacetPlan: func(o *Observation) { o.Plan = Known(PlanValid, evidence) },
+ FacetConfiguration: func(o *Observation) { o.Configuration = Known(ConfigurationStale, evidence) },
+ FacetConfigurationPolicy: func(o *Observation) {
+ o.ConfigurationPolicy = Known(ConfigurationPolicy{PlanApproval: "human-or-autonomy", VisualEvidence: "required", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}}, evidence)
+ },
+ FacetRuntime: func(o *Observation) { o.Runtime = Known(RuntimeStale, evidence) },
+ FacetPublication: func(o *Observation) { o.Publication = Known(PublicationOpen, evidence) },
+ FacetVerification: func(o *Observation) { o.Verification = Known(VerificationCurrent, evidence) },
+ FacetRecovery: func(o *Observation) {
+ o.Phase = Known(PhaseRecovery, evidence)
+ o.Recovery = Known(RecoveryReconcile, evidence)
+ o.Transaction = Known(TransactionExternalUncertain, evidence)
+ o.RecoveryInfo = Known(RecoveryContext{TransactionID: "adm-1", Cause: "interrupted", SourcePhase: PhaseActive, Permitted: []string{"recovery.rollback"}, BudgetRemaining: 1, Resumption: PhaseActive}, evidence)
+ o.TransactionInfo = Known(TransactionContext{ID: "adm-1", TransitionID: "plan.create", Status: "recovery-required"}, evidence)
+ },
+ FacetTransaction: func(o *Observation) {
+ o.Transaction = Known(TransactionStaged, evidence)
+ o.TransactionInfo = Known(TransactionContext{ID: "adm-2", TransitionID: "plan.create", Status: "staged"}, evidence)
+ },
+ FacetRecoveryInfo: func(o *Observation) { o.RecoveryInfo = Unknown[RecoveryContext](FactStale, "stale context", evidence) },
+ FacetTransactionInfo: func(o *Observation) {
+ o.TransactionInfo = Unknown[TransactionContext](FactStale, "stale context", evidence)
+ },
+ FacetTerminal: func(o *Observation) { o.Terminal = Known(TerminalStale, evidence) },
+ FacetGoal: func(o *Observation) {
+ o.Goal = Known(Goal{ID: "goal", Kind: GoalVerified, DeliveryID: "delivery"}, evidence)
+ },
+ }
+ for _, facet := range ControllingFacets() {
+ mutate, ok := mutations[facet]
+ if !ok {
+ t.Errorf("facet %s has no fingerprint fixture", facet)
+ continue
+ }
+ changedObservation := baseObservation
+ mutate(&changedObservation)
+ changed, err := Canonicalize(changedObservation)
+ if err != nil {
+ t.Fatalf("facet %s: %v", facet, err)
+ }
+ if changed.Fingerprint == base.Fingerprint {
+ t.Errorf("facet %s did not change canonical identity", facet)
+ }
+ }
+}
+
+func TestProtocolPhaseProjectionIsOrderedAndDefensive(t *testing.T) {
+ phases := ProtocolPhases()
+ if len(phases) != 13 || phases[0] != PhaseDormant || phases[len(phases)-1] != PhaseAbandoned {
+ t.Fatalf("protocol phases = %v", phases)
+ }
+ phases[0] = PhaseTerminal
+ if ProtocolPhases()[0] != PhaseDormant {
+ t.Fatal("ProtocolPhases exposed mutable kernel state")
+ }
+ for _, phase := range ProtocolPhases() {
+ wantMarked := phase == PhaseFrontier || phase == PhaseTerminal || phase == PhaseAbandoned
+ if phase.IsCompletionTarget() != wantMarked {
+ t.Errorf("completion target for %s = %v, want %v", phase, phase.IsCompletionTarget(), wantMarked)
+ }
+ }
+}
diff --git a/boatstack/internal/kernel/ports/ports.go b/boatstack/internal/kernel/ports/ports.go
new file mode 100644
index 0000000..e3f6fa9
--- /dev/null
+++ b/boatstack/internal/kernel/ports/ports.go
@@ -0,0 +1,101 @@
+package ports
+
+import (
+ "context"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+type Observer interface {
+ Observe(context.Context, ObservationRequest) (model.Observation, error)
+}
+
+type ObservationRequest struct {
+ Invocation model.InvocationContext
+ IgnoreAdmissionID string
+}
+
+type ControllerLayout struct {
+ RepositoryRoot string
+ GitCommonRoot string
+ StateRoot string
+ SharedRoot string
+ FlowRoot string
+ EmbeddedStateRoot string
+ ExternalStateRoot string
+ StatePath string
+ BindingPath string
+ JournalRoot string
+ ReceiptPath string
+ EventPath string
+ LockRoot string
+ ConfigPath string
+ ConfigAuthority string
+ EvidenceRoot string
+}
+
+type InvocationResolver interface {
+ ResolveInvocation(context.Context, string, string, string) (model.InvocationContext, error)
+ ResolveLayout(context.Context, model.InvocationContext) (ControllerLayout, model.InvocationContext, error)
+}
+
+type Clock interface{ Now() time.Time }
+
+type Lock interface{ Release() error }
+
+type Locker interface {
+ Acquire(context.Context, model.InvocationContext, []string) (Lock, error)
+}
+
+type Journal interface {
+ Begin(context.Context, protocol.Admission, catalog.Transition) error
+ Stage(context.Context, string, []ResourceMutation) error
+ Mark(context.Context, string, string) error
+ Commit(context.Context, protocol.TransitionReceipt) error
+ Abort(context.Context, string, string) error
+ RequireRecovery(context.Context, string, string) error
+}
+
+type EffectSettlement string
+
+const (
+ EffectSettled EffectSettlement = "settled"
+ EffectUnknown EffectSettlement = "unknown"
+)
+
+type EffectResult struct {
+ Settlement EffectSettlement
+ Detail string
+}
+
+type ResourceMutation struct {
+ Path string `json:"path"`
+ Prior []byte `json:"prior,omitempty"`
+ Target []byte `json:"target,omitempty"`
+ PriorExists bool `json:"prior_exists"`
+ Mode uint32 `json:"mode"`
+ InstallLast bool `json:"install_last,omitempty"`
+ Delete bool `json:"delete,omitempty"`
+}
+
+type PreparedEffect interface {
+ Manifest() []ResourceMutation
+ VerificationInvocation() (model.InvocationContext, bool)
+ Execute(context.Context) (EffectResult, error)
+ Rollback(context.Context) error
+}
+
+type EffectDriver interface {
+ Prepare(context.Context, protocol.Admission, catalog.Transition) (PreparedEffect, error)
+}
+
+type ReceiptStore interface {
+ Bind(context.Context, string, protocol.Admission) error
+ Unbind(string)
+ NextSequence(context.Context, string) (uint64, error)
+ FindByIdempotency(context.Context, model.InvocationContext, string) (protocol.TransitionReceipt, bool, error)
+ Append(context.Context, protocol.TransitionReceipt) error
+}
diff --git a/boatstack/internal/kernel/protocol/admission.go b/boatstack/internal/kernel/protocol/admission.go
new file mode 100644
index 0000000..0750e64
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/admission.go
@@ -0,0 +1,288 @@
+package protocol
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+const AdmissionSchemaVersion = 2
+
+type Admission struct {
+ SchemaVersion int `json:"schema_version"`
+ ID string `json:"id"`
+ TransitionID catalog.TransitionID `json:"transition_id"`
+ TransitionVersion int `json:"transition_version"`
+ SnapshotFingerprint string `json:"snapshot_fingerprint"`
+ SourceRevision string `json:"source_revision,omitempty"`
+ WorktreeFingerprint string `json:"worktree_fingerprint,omitempty"`
+ SourcePhase model.ProtocolPhase `json:"source_phase"`
+ Invocation model.InvocationContext `json:"invocation"`
+ Goal model.Goal `json:"goal"`
+ Authority AuthorityBundle `json:"authority"`
+ Parameters Parameters `json:"parameters,omitempty"`
+ Evidence []string `json:"evidence"`
+ IdempotencyKey string `json:"idempotency_key"`
+ IssuedAt time.Time `json:"issued_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func NewAdmission(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition, authority AuthorityBundle, parameters Parameters, now time.Time, lifetime time.Duration) (Admission, error) {
+ if !transition.Controllable() {
+ return Admission{}, fmt.Errorf("transition %q is uncontrollable and cannot be admitted", transition.ID)
+ }
+ if err := snapshot.Invocation.Validate(true); err != nil {
+ return Admission{}, err
+ }
+ if err := goal.Validate(); err != nil {
+ return Admission{}, err
+ }
+ if snapshot.Fingerprint == "" || !transition.SourceMatches(snapshot) || !transition.SupportsGoal(goal) {
+ return Admission{}, fmt.Errorf("transition %q is not admissible from snapshot %q", transition.ID, snapshot.Fingerprint)
+ }
+ if lifetime <= 0 {
+ return Admission{}, fmt.Errorf("admission lifetime must be positive")
+ }
+ if err := authority.Validate(now); err != nil {
+ return Admission{}, err
+ }
+ if err := validateAuthorityEvidence(snapshot, authority); err != nil {
+ return Admission{}, err
+ }
+ if err := parameters.Validate(transition); err != nil {
+ return Admission{}, err
+ }
+ if err := validateProviderAuthorityBinding(authority, transition, parameters); err != nil {
+ return Admission{}, err
+ }
+ sourceRevision, worktreeFingerprint := gitBinding(snapshot)
+ if transitionBindsSourceRevision(transition.ID) {
+ declared, _ := parameters.Get("source_revision")
+ if sourceRevision == "" || worktreeFingerprint == "" || declared != sourceRevision {
+ return Admission{}, fmt.Errorf("transition %q must bind the current Git revision and worktree fingerprint", transition.ID)
+ }
+ }
+ if !authority.Set(now).Satisfies(transition.Authority, transition.AuthorityAll) {
+ return Admission{}, fmt.Errorf("transition %q lacks required authority", transition.ID)
+ }
+ if err := validatePolicyAuthority(snapshot, transition, authority.Set(now)); err != nil {
+ return Admission{}, err
+ }
+ if err := validateRecoveryPermission(snapshot, transition); err != nil {
+ return Admission{}, err
+ }
+ a := Admission{
+ SchemaVersion: AdmissionSchemaVersion, TransitionID: transition.ID, TransitionVersion: transition.Version,
+ SnapshotFingerprint: snapshot.Fingerprint, SourceRevision: sourceRevision, WorktreeFingerprint: worktreeFingerprint,
+ SourcePhase: snapshot.Phase.Value, Invocation: snapshot.Invocation, Goal: goal, Authority: authority.canonical(),
+ Evidence: append([]string(nil), transition.RequiredEvidence...), Parameters: parameters.Canonical(), IssuedAt: now.UTC(), ExpiresAt: now.Add(lifetime).UTC(),
+ }
+ key, err := contentID("idem-", struct {
+ Transition catalog.TransitionID `json:"transition"`
+ Snapshot string `json:"snapshot"`
+ Invocation model.InvocationContext `json:"invocation"`
+ Goal model.Goal `json:"goal"`
+ Parameters Parameters `json:"parameters"`
+ }{transition.ID, snapshot.Fingerprint, snapshot.Invocation, goal, parameters.Canonical()})
+ if err != nil {
+ return Admission{}, err
+ }
+ a.IdempotencyKey = key
+ identity := a
+ identity.ID = ""
+ a.ID, err = contentID("adm-", identity)
+ if err != nil {
+ return Admission{}, err
+ }
+ return a, nil
+}
+
+func (a Admission) ValidateCurrent(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition, now time.Time) error {
+ if err := a.ValidateIdentity(); err != nil {
+ return err
+ }
+ if !now.Before(a.ExpiresAt) {
+ return fmt.Errorf("admission %q expired", a.ID)
+ }
+ if a.TransitionID != transition.ID || a.TransitionVersion != transition.Version {
+ return fmt.Errorf("admission %q is bound to a different transition", a.ID)
+ }
+ if a.SnapshotFingerprint != snapshot.Fingerprint {
+ return fmt.Errorf("admission %q is stale: snapshot changed", a.ID)
+ }
+ if snapshot.Phase.Status != model.FactKnown || a.SourcePhase != snapshot.Phase.Value {
+ return fmt.Errorf("admission %q is bound to a different source phase", a.ID)
+ }
+ if a.Invocation != snapshot.Invocation {
+ return fmt.Errorf("admission %q is bound to a different invocation", a.ID)
+ }
+ if a.Goal != goal {
+ return fmt.Errorf("admission %q is bound to a different goal", a.ID)
+ }
+ if err := a.Authority.Validate(now); err != nil {
+ return err
+ }
+ if err := validateAuthorityEvidence(snapshot, a.Authority); err != nil {
+ return err
+ }
+ if !a.Authority.Set(now).Satisfies(transition.Authority, transition.AuthorityAll) {
+ return fmt.Errorf("admission %q no longer has required authority", a.ID)
+ }
+ if err := validatePolicyAuthority(snapshot, transition, a.Authority.Set(now)); err != nil {
+ return err
+ }
+ if err := validateRecoveryPermission(snapshot, transition); err != nil {
+ return err
+ }
+ sourceRevision, worktreeFingerprint := gitBinding(snapshot)
+ if a.SourceRevision != sourceRevision || a.WorktreeFingerprint != worktreeFingerprint {
+ return fmt.Errorf("admission %q Git binding changed", a.ID)
+ }
+ if transitionBindsSourceRevision(transition.ID) {
+ declared, _ := a.Parameters.Get("source_revision")
+ if declared != sourceRevision || sourceRevision == "" || worktreeFingerprint == "" {
+ return fmt.Errorf("admission %q is not bound to the current Git revision", a.ID)
+ }
+ }
+ if err := a.Parameters.Validate(transition); err != nil {
+ return err
+ }
+ if err := validateProviderAuthorityBinding(a.Authority, transition, a.Parameters); err != nil {
+ return err
+ }
+ return nil
+}
+
+func gitBinding(snapshot model.Snapshot) (string, string) {
+ for _, facts := range [][]model.Evidence{snapshot.Delivery.Evidence, snapshot.Verification.Evidence} {
+ for _, evidence := range facts {
+ if strings.HasPrefix(evidence.Source, "git:") && evidence.Revision != "" && evidence.Fingerprint != "" {
+ return evidence.Revision, evidence.Fingerprint
+ }
+ }
+ }
+ return "", ""
+}
+
+func transitionBindsSourceRevision(id catalog.TransitionID) bool {
+ value := string(id)
+ return strings.HasPrefix(value, "gate.") || id == "evidence.visual.attach" || id == "delivery.slice.advance"
+}
+
+func validateRecoveryPermission(snapshot model.Snapshot, transition catalog.Transition) error {
+ if transition.Class != catalog.EventRecovery {
+ return nil
+ }
+ if snapshot.RecoveryInfo.Status != model.FactKnown {
+ return fmt.Errorf("transition %q requires exact recovery context", transition.ID)
+ }
+ for _, candidate := range snapshot.RecoveryInfo.Value.Permitted {
+ if candidate == string(transition.ID) {
+ return nil
+ }
+ }
+ return fmt.Errorf("transition %q is not permitted for transaction %q", transition.ID, snapshot.RecoveryInfo.Value.TransactionID)
+}
+
+func validateProviderAuthorityBinding(authority AuthorityBundle, transition catalog.Transition, parameters Parameters) error {
+ var expected string
+ switch transition.ID {
+ case "publication.execute":
+ expected, _ = parameters.Get("preview_fingerprint")
+ case "publication.correct":
+ expected, _ = parameters.Get("body_sha256")
+ case "publication.reconcile":
+ expected, _ = parameters.Get("publication_id")
+ }
+ for _, receipt := range authority.Receipts {
+ if receipt.Class != catalog.AuthorityProvider {
+ continue
+ }
+ if expected == "" {
+ return fmt.Errorf("transition %q does not accept provider authority", transition.ID)
+ }
+ if receipt.Fingerprint != expected {
+ return fmt.Errorf("provider authority for transition %q does not bind the exact admitted request", transition.ID)
+ }
+ }
+ return nil
+}
+
+func validatePolicyAuthority(snapshot model.Snapshot, transition catalog.Transition, authority catalog.AuthoritySet) error {
+ if snapshot.ConfigurationPolicy.Status == model.FactKnown {
+ enabled := false
+ for _, host := range snapshot.ConfigurationPolicy.Value.Hosts {
+ if host == snapshot.Invocation.Host {
+ enabled = true
+ break
+ }
+ }
+ if !enabled {
+ return fmt.Errorf("transition %q is unavailable to disabled host %q", transition.ID, snapshot.Invocation.Host)
+ }
+ }
+ requiresPolicy := transition.ID == "plan.approve" || transition.ID == "plan.approve-amendment" ||
+ transition.ID == "gate.review.record" || transition.ID == "evidence.visual.attach"
+ if !requiresPolicy {
+ return nil
+ }
+ if snapshot.ConfigurationPolicy.Status != model.FactKnown {
+ return fmt.Errorf("transition %q requires known configuration policy", transition.ID)
+ }
+ policy := snapshot.ConfigurationPolicy.Value
+ if transition.ID == "evidence.visual.attach" && policy.VisualEvidence == "off" {
+ return fmt.Errorf("transition %q is disabled by repository policy", transition.ID)
+ }
+ if (transition.ID == "plan.approve" || transition.ID == "plan.approve-amendment") && policy.PlanApproval == "human" && !authority[catalog.AuthorityHuman] {
+ return fmt.Errorf("transition %q requires human approval under repository policy", transition.ID)
+ }
+ if transition.ID == "gate.review.record" && policy.IndependentReviewForHighRisk && policy.HighRiskChange && !authority[catalog.AuthorityHuman] {
+ return fmt.Errorf("transition %q requires independent human review for a high-risk change", transition.ID)
+ }
+ return nil
+}
+
+func validateAuthorityEvidence(snapshot model.Snapshot, authority AuthorityBundle) error {
+ for _, receipt := range authority.Receipts {
+ if receipt.Class != catalog.AuthorityRepository {
+ continue
+ }
+ matched := false
+ for _, evidence := range snapshot.Configuration.Evidence {
+ if strings.HasPrefix(evidence.Source, "configuration:") && evidence.Fingerprint == receipt.Fingerprint {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return fmt.Errorf("repository authority receipt %q is not bound to current configuration evidence", receipt.ID)
+ }
+ }
+ return nil
+}
+
+func (a Admission) ValidateIdentity() error {
+ if a.SchemaVersion != AdmissionSchemaVersion || a.ID == "" || a.TransitionID == "" || a.TransitionVersion < 1 || a.SnapshotFingerprint == "" || !a.SourcePhase.Valid() || a.IdempotencyKey == "" || a.IssuedAt.IsZero() || a.ExpiresAt.Before(a.IssuedAt) {
+ return fmt.Errorf("admission: invalid schema, identity, source, or lifetime")
+ }
+ if err := a.Invocation.Validate(true); err != nil {
+ return err
+ }
+ if err := a.Goal.Validate(); err != nil {
+ return err
+ }
+ identity := a
+ want := identity.ID
+ identity.ID = ""
+ got, err := contentID("adm-", identity)
+ if err != nil {
+ return err
+ }
+ if got != want {
+ return fmt.Errorf("admission %q failed content identity verification", a.ID)
+ }
+ return nil
+}
diff --git a/boatstack/internal/kernel/protocol/authority.go b/boatstack/internal/kernel/protocol/authority.go
new file mode 100644
index 0000000..f8ebbbe
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/authority.go
@@ -0,0 +1,96 @@
+package protocol
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+type AuthorityReceipt struct {
+ ID string `json:"id"`
+ Class catalog.AuthorityClass `json:"class"`
+ Subject string `json:"subject"`
+ Fingerprint string `json:"fingerprint"`
+ IssuedAt time.Time `json:"issued_at"`
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
+}
+
+func (r AuthorityReceipt) Validate(now time.Time) error {
+ if r.ID == "" || r.Subject == "" || r.Fingerprint == "" || r.IssuedAt.IsZero() {
+ return fmt.Errorf("authority receipt requires id, subject, fingerprint, and issue time")
+ }
+ if !r.Class.Valid() || r.Class == catalog.AuthorityNone {
+ return fmt.Errorf("authority receipt has invalid class %q", r.Class)
+ }
+ if !r.ExpiresAt.IsZero() && !now.Before(r.ExpiresAt) {
+ return fmt.Errorf("authority receipt %q expired", r.ID)
+ }
+ return nil
+}
+
+type AuthorityBundle struct {
+ Receipts []AuthorityReceipt `json:"receipts"`
+}
+
+func (b AuthorityBundle) Validate(now time.Time) error {
+ seen := map[string]bool{}
+ for _, receipt := range b.Receipts {
+ if err := receipt.Validate(now); err != nil {
+ return err
+ }
+ if seen[receipt.ID] {
+ return fmt.Errorf("authority receipt %q is duplicated", receipt.ID)
+ }
+ seen[receipt.ID] = true
+ }
+ return nil
+}
+
+func (b AuthorityBundle) Set(now time.Time) catalog.AuthoritySet {
+ set := catalog.AuthoritySet{}
+ for _, receipt := range b.Receipts {
+ if receipt.Validate(now) == nil {
+ set[receipt.Class] = true
+ }
+ }
+ return set
+}
+
+func (b AuthorityBundle) canonical() AuthorityBundle {
+ result := AuthorityBundle{Receipts: append([]AuthorityReceipt(nil), b.Receipts...)}
+ sort.Slice(result.Receipts, func(i, j int) bool { return result.Receipts[i].ID < result.Receipts[j].ID })
+ return result
+}
+
+func DeriveRepositoryAuthority(snapshot model.Snapshot, bundle AuthorityBundle, now time.Time) (AuthorityBundle, error) {
+ for _, receipt := range bundle.Receipts {
+ if receipt.Class == catalog.AuthorityRepository {
+ return AuthorityBundle{}, fmt.Errorf("repository authority must be derived once by the V2 kernel")
+ }
+ }
+ if snapshot.Configuration.Status != model.FactKnown || snapshot.Configuration.Value != model.ConfigurationVerified {
+ return AuthorityBundle{}, fmt.Errorf("repository authority requires current verified configuration evidence")
+ }
+ var source, fingerprint string
+ for _, evidence := range snapshot.Configuration.Evidence {
+ if strings.HasPrefix(evidence.Source, "configuration:") {
+ source, fingerprint = evidence.Source, evidence.Fingerprint
+ break
+ }
+ }
+ if source == "" || fingerprint == "" {
+ return AuthorityBundle{}, fmt.Errorf("repository authority has no exact configuration evidence")
+ }
+ digest := sha256.Sum256([]byte(snapshot.Invocation.RepositoryID + "\x00" + snapshot.Invocation.GitCommonID + "\x00" + source + "\x00" + fingerprint))
+ bundle.Receipts = append(bundle.Receipts, AuthorityReceipt{
+ ID: "policy-" + hex.EncodeToString(digest[:])[:16], Class: catalog.AuthorityRepository,
+ Subject: source, Fingerprint: fingerprint, IssuedAt: now.UTC(), ExpiresAt: now.Add(2 * time.Minute).UTC(),
+ })
+ return bundle, nil
+}
diff --git a/boatstack/internal/kernel/protocol/config.go b/boatstack/internal/kernel/protocol/config.go
new file mode 100644
index 0000000..183283e
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/config.go
@@ -0,0 +1,129 @@
+package protocol
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "sort"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+const ConfigSchemaVersion = 2
+
+type ProjectSettings struct {
+ Name string `json:"name"`
+ DefaultBranch string `json:"default_branch"`
+ Context []string `json:"context,omitempty"`
+ Commands map[string]string `json:"commands"`
+ HighRiskPaths []string `json:"high_risk_paths,omitempty"`
+}
+
+type PolicySettings struct {
+ PlanApproval string `json:"plan_approval"`
+ IndependentReviewForHighRisk bool `json:"independent_review_for_high_risk,omitempty"`
+ VisualEvidence string `json:"visual_evidence"`
+ ExternalEffectAuthority string `json:"external_effect_authority,omitempty"`
+}
+
+type ProjectConfig struct {
+ SchemaVersion int `json:"schema_version"`
+ Project ProjectSettings `json:"project"`
+ Policy PolicySettings `json:"policy"`
+ Hosts []string `json:"hosts"`
+}
+
+var canonicalHosts = []string{"claude", "cli", "codex", "cursor", "gemini", "mcp"}
+
+func CanonicalHosts() []string { return append([]string(nil), canonicalHosts...) }
+
+func (c ProjectConfig) ControlPolicy() model.ConfigurationPolicy {
+ external := c.Policy.ExternalEffectAuthority
+ if external == "" {
+ external = "human-or-autonomy-plus-provider"
+ }
+ return model.ConfigurationPolicy{
+ PlanApproval: c.Policy.PlanApproval, IndependentReviewForHighRisk: c.Policy.IndependentReviewForHighRisk,
+ VisualEvidence: c.Policy.VisualEvidence, ExternalEffectAuthority: external, Hosts: append([]string(nil), c.Hosts...),
+ }.Canonical()
+}
+
+func DecodeProjectConfig(value []byte) (ProjectConfig, error) {
+ var config ProjectConfig
+ decoder := json.NewDecoder(bytes.NewReader(value))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&config); err != nil {
+ return ProjectConfig{}, fmt.Errorf("decode V2 project configuration: %w", err)
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return ProjectConfig{}, fmt.Errorf("V2 project configuration contains trailing JSON")
+ }
+ if err := config.Validate(); err != nil {
+ return ProjectConfig{}, err
+ }
+ return config, nil
+}
+
+// ProjectConfigFingerprint binds configuration authority to strict schema-2
+// semantics rather than checkout-specific JSON bytes. Formatting, object-key
+// order, line endings, the defaulted external-effect policy, and host ordering
+// therefore cannot make an otherwise identical configuration stale.
+func ProjectConfigFingerprint(value []byte) (ProjectConfig, string, error) {
+ config, err := DecodeProjectConfig(value)
+ if err != nil {
+ return ProjectConfig{}, "", err
+ }
+ canonical := config
+ canonical.Hosts = append([]string(nil), config.Hosts...)
+ sort.Strings(canonical.Hosts)
+ if canonical.Policy.ExternalEffectAuthority == "" {
+ canonical.Policy.ExternalEffectAuthority = "human-or-autonomy-plus-provider"
+ }
+ encoded, err := json.Marshal(canonical)
+ if err != nil {
+ return ProjectConfig{}, "", fmt.Errorf("encode canonical V2 project configuration: %w", err)
+ }
+ digest := sha256.Sum256(encoded)
+ return config, hex.EncodeToString(digest[:]), nil
+}
+
+func (c ProjectConfig) Validate() error {
+ if c.SchemaVersion != ConfigSchemaVersion || c.Project.Name == "" || c.Project.DefaultBranch == "" || c.Project.Commands == nil {
+ return fmt.Errorf("V2 project configuration requires schema 2, project name, default branch, and commands")
+ }
+ if err := ValidateGitBranch(c.Project.DefaultBranch); err != nil {
+ return fmt.Errorf("invalid default branch: %w", err)
+ }
+ switch c.Policy.PlanApproval {
+ case "human", "human-or-autonomy":
+ default:
+ return fmt.Errorf("unsupported plan approval policy %q", c.Policy.PlanApproval)
+ }
+ switch c.Policy.VisualEvidence {
+ case "off", "optional", "required":
+ default:
+ return fmt.Errorf("unsupported visual evidence policy %q", c.Policy.VisualEvidence)
+ }
+ if c.Policy.ExternalEffectAuthority != "" && c.Policy.ExternalEffectAuthority != "human-or-autonomy-plus-provider" {
+ return fmt.Errorf("unsupported external effect authority policy %q", c.Policy.ExternalEffectAuthority)
+ }
+ allowed := map[string]bool{}
+ for _, host := range canonicalHosts {
+ allowed[host] = true
+ }
+ seen := map[string]bool{}
+ for _, host := range c.Hosts {
+ if !allowed[host] || seen[host] {
+ return fmt.Errorf("unsupported or duplicated host %q", host)
+ }
+ seen[host] = true
+ }
+ if !seen["cli"] {
+ return fmt.Errorf("V2 project configuration must enable the canonical CLI surface")
+ }
+ return nil
+}
diff --git a/boatstack/internal/kernel/protocol/config_test.go b/boatstack/internal/kernel/protocol/config_test.go
new file mode 100644
index 0000000..8d1549d
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/config_test.go
@@ -0,0 +1,68 @@
+package protocol
+
+import "testing"
+
+func TestProjectConfigurationIsStrictAndVersioned(t *testing.T) {
+ valid := []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli","codex"]}`)
+ if _, err := DecodeProjectConfig(valid); err != nil {
+ t.Fatal(err)
+ }
+ invalid := [][]byte{
+ []byte(`{"schema_version":1,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`),
+ []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["unknown"]}`),
+ []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"],"legacy":true}`),
+ []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"--upload-pack=bad","commands":{}},"policy":{"plan_approval":"human","visual_evidence":"optional"},"hosts":["cli"]}`),
+ }
+ for _, value := range invalid {
+ if _, err := DecodeProjectConfig(value); err == nil {
+ t.Fatalf("invalid configuration was accepted: %s", value)
+ }
+ }
+}
+
+func TestProjectConfigurationFingerprintIsSemanticAndStrict(t *testing.T) {
+ one := []byte("{\n \"schema_version\": 2,\n \"project\": {\"name\": \"product\", \"default_branch\": \"main\", \"commands\": {\"test\": \"go test ./...\"}},\n \"policy\": {\"plan_approval\": \"human\", \"visual_evidence\": \"optional\"},\n \"hosts\": [\"codex\", \"cli\"]\n}\n")
+ two := []byte("{\r\n\"hosts\":[\"cli\",\"codex\"],\r\n\"policy\":{\"external_effect_authority\":\"human-or-autonomy-plus-provider\",\"visual_evidence\":\"optional\",\"plan_approval\":\"human\"},\r\n\"project\":{\"commands\":{\"test\":\"go test ./...\"},\"default_branch\":\"main\",\"name\":\"product\"},\r\n\"schema_version\":2\r\n}\r\n")
+ _, oneFingerprint, err := ProjectConfigFingerprint(one)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, twoFingerprint, err := ProjectConfigFingerprint(two)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if oneFingerprint != twoFingerprint {
+ t.Fatalf("representation changed semantic fingerprint: %s != %s", oneFingerprint, twoFingerprint)
+ }
+
+ changed := []byte(`{"schema_version":2,"project":{"name":"product","default_branch":"main","commands":{"test":"go test ./..."}},"policy":{"plan_approval":"human","visual_evidence":"required"},"hosts":["cli","codex"]}`)
+ _, changedFingerprint, err := ProjectConfigFingerprint(changed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if changedFingerprint == oneFingerprint {
+ t.Fatal("controlling policy change retained the old configuration fingerprint")
+ }
+
+ unknown := append([]byte(nil), one[:len(one)-2]...)
+ unknown = append(unknown, []byte(",\"unknown\":true}\n")...)
+ if _, _, err := ProjectConfigFingerprint(unknown); err == nil {
+ t.Fatal("unknown configuration field acquired a semantic fingerprint")
+ }
+}
+
+func TestGitReferencesRejectOptionsAndRevisionExpressions(t *testing.T) {
+ for _, value := range []string{"main", "feature/v2", "HEAD", "0123456789abcdef"} {
+ if err := ValidateGitReference(value); err != nil {
+ t.Fatalf("valid reference %q: %v", value, err)
+ }
+ }
+ for _, value := range []string{"--help", "HEAD~1", "main..other", "refs/heads/.hidden", "main.lock"} {
+ if err := ValidateGitReference(value); err == nil {
+ t.Fatalf("unsafe reference %q was accepted", value)
+ }
+ }
+ if err := ValidateGitBranch("HEAD"); err == nil {
+ t.Fatal("reserved HEAD was accepted as a branch")
+ }
+}
diff --git a/boatstack/internal/kernel/protocol/hash.go b/boatstack/internal/kernel/protocol/hash.go
new file mode 100644
index 0000000..254bc25
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/hash.go
@@ -0,0 +1,17 @@
+package protocol
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+)
+
+func contentID(prefix string, value any) (string, error) {
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return "", fmt.Errorf("canonical encoding: %w", err)
+ }
+ digest := sha256.Sum256(raw)
+ return prefix + hex.EncodeToString(digest[:]), nil
+}
diff --git a/boatstack/internal/kernel/protocol/parameters.go b/boatstack/internal/kernel/protocol/parameters.go
new file mode 100644
index 0000000..5c2214d
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/parameters.go
@@ -0,0 +1,79 @@
+package protocol
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+)
+
+type Parameter struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+type Parameters []Parameter
+
+func (p Parameters) Canonical() Parameters {
+ result := append(Parameters(nil), p...)
+ sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
+ return result
+}
+
+func (p Parameters) Validate(transition catalog.Transition) error {
+ allowed := map[string]catalog.ParameterSpec{}
+ for _, spec := range transition.Parameters {
+ allowed[spec.Name] = spec
+ }
+ seen := map[string]bool{}
+ for _, parameter := range p {
+ if parameter.Name == "" || parameter.Value == "" {
+ return fmt.Errorf("transition %q parameters require non-empty names and values", transition.ID)
+ }
+ if seen[parameter.Name] {
+ return fmt.Errorf("transition %q parameter %q is duplicated", transition.ID, parameter.Name)
+ }
+ if _, ok := allowed[parameter.Name]; !ok {
+ return fmt.Errorf("transition %q does not accept parameter %q", transition.ID, parameter.Name)
+ }
+ seen[parameter.Name] = true
+ switch parameter.Name {
+ case "source_path", "runtime_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)
+ }
+ case "branch", "head_ref":
+ if err := ValidateGitBranch(parameter.Value); err != nil {
+ return fmt.Errorf("transition %q parameter %q: %w", transition.ID, parameter.Name, err)
+ }
+ case "base_ref":
+ if err := ValidateGitReference(parameter.Value); err != nil {
+ return fmt.Errorf("transition %q parameter %q: %w", transition.ID, parameter.Name, err)
+ }
+ case "topology":
+ if parameter.Value != "detached" && parameter.Value != "hybrid" {
+ return fmt.Errorf("transition %q requires detached or hybrid topology", transition.ID)
+ }
+ case "config_authority":
+ if parameter.Value != "repository" && parameter.Value != "external" {
+ return fmt.Errorf("transition %q requires repository or external configuration authority", transition.ID)
+ }
+ }
+ }
+ for _, spec := range transition.Parameters {
+ if spec.Required && !seen[spec.Name] {
+ return fmt.Errorf("transition %q requires parameter %q", transition.ID, spec.Name)
+ }
+ }
+ return nil
+}
+
+func (p Parameters) Get(name string) (string, bool) {
+ for _, parameter := range p {
+ if parameter.Name == name {
+ return parameter.Value, true
+ }
+ }
+ return "", false
+}
diff --git a/boatstack/internal/kernel/protocol/parameters_test.go b/boatstack/internal/kernel/protocol/parameters_test.go
new file mode 100644
index 0000000..e6ccede
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/parameters_test.go
@@ -0,0 +1,19 @@
+package protocol
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+)
+
+func TestEffectPathsAndGitNamesAreValidatedAtAdmissionBoundary(t *testing.T) {
+ plan, _ := catalog.Default().Lookup("plan.create")
+ if err := (Parameters{{Name: "source_path", Value: "relative.md"}, {Name: "delivery_id", Value: "delivery"}}).Validate(plan); err == nil {
+ t.Fatal("relative source path was accepted")
+ }
+ workspace, _ := catalog.Default().Lookup("workspace.cut")
+ if err := (Parameters{{Name: "branch", Value: "--force"}, {Name: "base_ref", Value: "HEAD~1"}, {Name: "destination", Value: filepath.Join(t.TempDir(), "worktree")}}).Validate(workspace); err == nil {
+ t.Fatal("unsafe Git parameters were accepted")
+ }
+}
diff --git a/boatstack/internal/kernel/protocol/policy_test.go b/boatstack/internal/kernel/protocol/policy_test.go
new file mode 100644
index 0000000..ebf6fc6
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/policy_test.go
@@ -0,0 +1,75 @@
+package protocol
+
+import (
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+func policySnapshot(policy model.ConfigurationPolicy) model.Snapshot {
+ return model.Snapshot{Observation: model.Observation{
+ Invocation: model.InvocationContext{Host: "cli"},
+ ConfigurationPolicy: model.Fact[model.ConfigurationPolicy]{Status: model.FactKnown, Value: policy},
+ }}
+}
+
+func TestProviderAuthorityBindsExactExternalRequest(t *testing.T) {
+ now := time.Now().UTC()
+ authority := AuthorityBundle{Receipts: []AuthorityReceipt{{
+ ID: "provider", Class: catalog.AuthorityProvider, Subject: "github",
+ Fingerprint: "different-preview", IssuedAt: now, ExpiresAt: now.Add(time.Minute),
+ }}}
+ transition, _ := catalog.Default().Lookup("publication.execute")
+ parameters := Parameters{{Name: "preview_fingerprint", Value: "reviewed-preview"}}
+ if err := validateProviderAuthorityBinding(authority, transition, parameters); err == nil {
+ t.Fatal("provider authority for different preview was accepted")
+ }
+ authority.Receipts[0].Fingerprint = "reviewed-preview"
+ if err := validateProviderAuthorityBinding(authority, transition, parameters); err != nil {
+ t.Fatalf("exact provider authority rejected: %v", err)
+ }
+}
+
+func TestAdmissionPolicyRejectsRepositoryOnlyHighRiskReview(t *testing.T) {
+ snapshot := policySnapshot(model.ConfigurationPolicy{
+ PlanApproval: "human", IndependentReviewForHighRisk: true, HighRiskChange: true,
+ VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"},
+ })
+ transition := catalog.Transition{ID: "gate.review.record"}
+ if err := validatePolicyAuthority(snapshot, transition, catalog.AuthoritySet{catalog.AuthorityRepository: true}); err == nil {
+ t.Fatal("repository-only authority admitted a high-risk review")
+ }
+ if err := validatePolicyAuthority(snapshot, transition, catalog.AuthoritySet{catalog.AuthorityHuman: true}); err != nil {
+ t.Fatalf("human high-risk review authority rejected: %v", err)
+ }
+}
+
+func TestAdmissionPolicyRejectsDisabledVisualEvidence(t *testing.T) {
+ snapshot := policySnapshot(model.ConfigurationPolicy{
+ PlanApproval: "human", VisualEvidence: "off",
+ ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"},
+ })
+ transition := catalog.Transition{ID: "evidence.visual.attach"}
+ if err := validatePolicyAuthority(snapshot, transition, catalog.AuthoritySet{catalog.AuthorityHuman: true}); err == nil {
+ t.Fatal("visual evidence attachment admitted while disabled")
+ }
+}
+
+func TestAdmissionRejectsRecoveryOutsideExactJournalContract(t *testing.T) {
+ snapshot := policySnapshot(model.ConfigurationPolicy{
+ PlanApproval: "human", VisualEvidence: "optional",
+ ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"},
+ })
+ snapshot.RecoveryInfo = model.Fact[model.RecoveryContext]{Status: model.FactKnown, Value: model.RecoveryContext{
+ TransactionID: "adm-interrupted", Cause: "interrupted", SourcePhase: model.PhaseActive,
+ Permitted: []string{"recovery.resume"}, BudgetRemaining: 2, Resumption: model.PhaseActive,
+ }}
+ if err := validateRecoveryPermission(snapshot, catalog.Transition{ID: "recovery.rollback", Class: catalog.EventRecovery}); err == nil {
+ t.Fatal("recovery transition outside journal contract was admitted")
+ }
+ if err := validateRecoveryPermission(snapshot, catalog.Transition{ID: "recovery.resume", Class: catalog.EventRecovery}); err != nil {
+ t.Fatalf("permitted recovery transition rejected: %v", err)
+ }
+}
diff --git a/boatstack/internal/kernel/protocol/receipt.go b/boatstack/internal/kernel/protocol/receipt.go
new file mode 100644
index 0000000..056e0aa
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/receipt.go
@@ -0,0 +1,104 @@
+package protocol
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+const ReceiptSchemaVersion = 2
+
+type Outcome string
+
+const (
+ OutcomeSucceeded Outcome = "succeeded"
+ OutcomeRecovered Outcome = "recovered"
+ OutcomeRefused Outcome = "refused"
+ OutcomeUnknown Outcome = "external-outcome-unknown"
+)
+
+type TransitionReceipt struct {
+ SchemaVersion int `json:"schema_version"`
+ ID string `json:"id"`
+ FlowID string `json:"flow_id"`
+ Sequence uint64 `json:"sequence"`
+ TransitionID catalog.TransitionID `json:"transition_id"`
+ TransitionVersion int `json:"transition_version"`
+ AdmissionID string `json:"admission_id"`
+ GoalID string `json:"goal_id"`
+ GoalKind model.GoalKind `json:"goal_kind"`
+ DeliveryID string `json:"delivery_id"`
+ SourceFingerprint string `json:"source_fingerprint"`
+ TargetFingerprint string `json:"target_fingerprint"`
+ AuthorityClasses []string `json:"authority_classes"`
+ IdempotencyKey string `json:"idempotency_key"`
+ Verifier string `json:"verifier"`
+ Outcome Outcome `json:"outcome"`
+ Recovery catalog.TransitionID `json:"recovery,omitempty"`
+ Terminal model.TerminalStatus `json:"terminal"`
+ StartedAt time.Time `json:"started_at"`
+ CompletedAt time.Time `json:"completed_at"`
+ DurationNanoseconds int64 `json:"duration_nanoseconds"`
+ FailureClass string `json:"failure_class,omitempty"`
+}
+
+func NewReceipt(flowID string, sequence uint64, admission Admission, transition catalog.Transition, target model.Snapshot, startedAt, completedAt time.Time, outcome Outcome, failureClass string) (TransitionReceipt, error) {
+ if flowID == "" || sequence == 0 || admission.ID == "" || target.Fingerprint == "" {
+ return TransitionReceipt{}, fmt.Errorf("receipt requires flow, sequence, admission, and target identity")
+ }
+ if completedAt.Before(startedAt) {
+ return TransitionReceipt{}, fmt.Errorf("receipt completion precedes start")
+ }
+ classes := make([]string, 0, len(admission.Authority.Receipts))
+ for _, authority := range admission.Authority.Receipts {
+ classes = append(classes, string(authority.Class))
+ }
+ terminal := model.TerminalUnknown
+ if target.Terminal.Status == model.FactKnown {
+ terminal = target.Terminal.Value
+ }
+ receipt := TransitionReceipt{
+ SchemaVersion: ReceiptSchemaVersion, FlowID: flowID, Sequence: sequence, TransitionID: transition.ID,
+ TransitionVersion: transition.Version, AdmissionID: admission.ID, GoalID: admission.Goal.ID, GoalKind: admission.Goal.Kind, DeliveryID: admission.Goal.DeliveryID,
+ SourceFingerprint: admission.SnapshotFingerprint, TargetFingerprint: target.Fingerprint,
+ AuthorityClasses: classes, IdempotencyKey: admission.IdempotencyKey, Verifier: transition.Verifier,
+ Outcome: outcome, Recovery: transition.Interruption.Recovery, Terminal: terminal,
+ StartedAt: startedAt.UTC(), CompletedAt: completedAt.UTC(), DurationNanoseconds: completedAt.Sub(startedAt).Nanoseconds(),
+ FailureClass: failureClass,
+ }
+ identity := receipt
+ identity.ID = ""
+ var err error
+ receipt.ID, err = contentID("trc-", identity)
+ if err != nil {
+ return TransitionReceipt{}, err
+ }
+ return receipt, nil
+}
+
+func (r TransitionReceipt) Validate() error {
+ if r.SchemaVersion != ReceiptSchemaVersion || r.ID == "" || r.FlowID == "" || r.Sequence == 0 || r.TransitionID == "" || r.TransitionVersion < 1 || r.AdmissionID == "" || r.GoalID == "" || !r.GoalKind.Valid() || r.DeliveryID == "" || r.SourceFingerprint == "" || r.TargetFingerprint == "" || r.IdempotencyKey == "" || r.Verifier == "" {
+ return fmt.Errorf("receipt has incomplete identity or evidence")
+ }
+ if r.StartedAt.IsZero() || r.CompletedAt.Before(r.StartedAt) || r.DurationNanoseconds != r.CompletedAt.Sub(r.StartedAt).Nanoseconds() {
+ return fmt.Errorf("receipt has invalid timing evidence")
+ }
+ switch r.Outcome {
+ case OutcomeSucceeded, OutcomeRecovered, OutcomeRefused, OutcomeUnknown:
+ default:
+ return fmt.Errorf("receipt has invalid outcome %q", r.Outcome)
+ }
+ identity := r
+ want := identity.ID
+ identity.ID = ""
+ got, err := contentID("trc-", identity)
+ if err != nil {
+ return err
+ }
+ if got != want {
+ return fmt.Errorf("receipt %q failed content identity verification", r.ID)
+ }
+ return nil
+}
diff --git a/boatstack/internal/kernel/protocol/values.go b/boatstack/internal/kernel/protocol/values.go
new file mode 100644
index 0000000..6c95127
--- /dev/null
+++ b/boatstack/internal/kernel/protocol/values.go
@@ -0,0 +1,40 @@
+package protocol
+
+import (
+ "fmt"
+ "strings"
+)
+
+// ValidateGitReference accepts only non-option, non-revision-expression names.
+// Callers that need ancestry operators must resolve them to an exact object ID
+// before crossing an effect boundary.
+func ValidateGitReference(value string) error {
+ if value == "" || value != strings.TrimSpace(value) || len(value) > 255 || strings.HasPrefix(value, "-") {
+ return fmt.Errorf("Git reference must be a bounded non-option value")
+ }
+ if value == "@" || strings.Contains(value, "..") || strings.Contains(value, "@{") || strings.Contains(value, "//") ||
+ strings.HasSuffix(value, ".") || strings.HasSuffix(value, "/") {
+ return fmt.Errorf("Git reference %q contains forbidden reference syntax", value)
+ }
+ for _, character := range value {
+ if character <= 0x20 || character == 0x7f || strings.ContainsRune(`~^:?*[\`, character) {
+ return fmt.Errorf("Git reference %q contains a forbidden character", value)
+ }
+ }
+ for _, component := range strings.Split(value, "/") {
+ if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
+ return fmt.Errorf("Git reference %q contains a forbidden component", value)
+ }
+ }
+ return nil
+}
+
+func ValidateGitBranch(value string) error {
+ if err := ValidateGitReference(value); err != nil {
+ return err
+ }
+ if value == "HEAD" || strings.HasPrefix(value, "refs/") {
+ return fmt.Errorf("Git branch %q must be an unqualified branch name", value)
+ }
+ return nil
+}
diff --git a/boatstack/internal/kernel/reducer/reducer.go b/boatstack/internal/kernel/reducer/reducer.go
new file mode 100644
index 0000000..7e8a0cc
--- /dev/null
+++ b/boatstack/internal/kernel/reducer/reducer.go
@@ -0,0 +1,280 @@
+package reducer
+
+import (
+ "fmt"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+func Apply(state *durable.State, admission protocol.Admission, transition catalog.Transition) error {
+ configured := state.Goal.Validate() == nil
+ switch transition.ID {
+ case "installation.initialize", "goal.configure":
+ state.Goal = admission.Goal
+ configured = true
+ default:
+ if configured && state.Goal != admission.Goal {
+ return fmt.Errorf("transition %q cannot replace configured goal; use goal.configure", transition.ID)
+ }
+ }
+ state.LastTransition = transition.ID
+ if transition.ID == "goal.configure" {
+ state.Terminal = model.TerminalNonterminal
+ }
+ switch transition.ID {
+ case "engagement.begin":
+ state.Phase, state.Engagement = model.PhaseObserved, model.EngagementCommand
+ case "engagement.renew":
+ state.Phase, state.Engagement = model.PhaseActive, model.EngagementActive
+ case "engagement.release":
+ state.Phase, state.Engagement = model.PhaseDormant, model.EngagementDormant
+ case "invocation.rebind", "repository.attach":
+ state.Phase = model.PhaseObserved
+ case "repository.detach":
+ state.Phase, state.Engagement = model.PhaseDormant, model.EngagementDormant
+ case "runtime.hydrate", "runtime.replace", "installation.update":
+ state.Runtime = model.RuntimeVerified
+ state.RuntimeFingerprint, _ = admission.Parameters.Get("runtime_sha256")
+ state.RuntimePath, _ = admission.Parameters.Get("runtime_path")
+ state.RuntimeSource, _ = admission.Parameters.Get("source_revision")
+ state.Phase = settledPhase(*state)
+ case "runtime.reconcile":
+ state.Runtime, state.Recovery, state.Transaction = model.RuntimeVerified, model.RecoveryNone, model.TransactionNone
+ 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)
+ case "configuration.initialize", "configuration.mutate":
+ state.Configuration = model.ConfigurationVerified
+ state.ConfigFingerprint, _ = admission.Parameters.Get("config_sha256")
+ state.Phase = settledPhase(*state)
+ case "configuration.reconcile":
+ state.Configuration, state.Recovery, state.Transaction = model.ConfigurationVerified, model.RecoveryNone, model.TransactionNone
+ ClearRecoveryContext(state)
+ state.Phase = settledPhase(*state)
+ case "installation.initialize":
+ state.Runtime, state.Configuration = model.RuntimeVerified, model.ConfigurationVerified
+ 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
+ case "goal.configure":
+ wasActive := state.Phase == model.PhaseActive
+ kind, _ := admission.Parameters.Get("goal_kind")
+ delivery, _ := admission.Parameters.Get("delivery_id")
+ if kind != string(admission.Goal.Kind) || delivery != admission.Goal.DeliveryID {
+ return fmt.Errorf("goal parameters do not match admitted goal")
+ }
+ state.Phase = model.PhaseObserved
+ if state.Recovery == model.RecoveryEscalated {
+ state.Phase = model.PhaseFrontier
+ } else if wasActive {
+ state.Phase = model.PhaseActive
+ }
+ case "plan.create":
+ state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanDraft, model.DeliveryPlanning, model.PhaseActive, model.TerminalNonterminal
+ case "plan.validate":
+ state.Plan, state.Phase, state.Terminal = model.PlanValid, model.PhaseActive, model.TerminalNonterminal
+ case "plan.approve":
+ state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive
+ if admission.Goal.Kind == model.GoalApprovedPlan {
+ establishTerminal(state, model.PhaseTerminal)
+ }
+ case "plan.activate":
+ state.Plan, state.Delivery, state.Phase = model.PlanLocked, model.DeliveryActive, model.PhaseActive
+ case "plan.amend":
+ state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanAmendmentRequired, model.DeliveryAmendment, model.PhaseActive, model.TerminalNonterminal
+ case "plan.approve-amendment":
+ state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive
+ case "plan.invalidate":
+ state.Plan, state.Delivery, state.Phase, state.Terminal = model.PlanInvalid, model.DeliveryInvalid, model.PhaseFrontier, model.TerminalNonterminal
+ case "plan.abandon", "publication.abandon":
+ state.Delivery = model.DeliveryDiscarded
+ if state.Workspace != model.WorkspaceAbsent {
+ state.Workspace = model.WorkspaceAbandoned
+ }
+ state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
+ ClearRecoveryContext(state)
+ establishTerminal(state, model.PhaseAbandoned)
+ case "workspace.abandon":
+ state.Delivery, state.Workspace = model.DeliveryDiscarded, model.WorkspaceAbandoned
+ state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
+ ClearRecoveryContext(state)
+ establishTerminal(state, model.PhaseAbandoned)
+ case "workspace.cut":
+ state.Workspace, state.Phase = model.WorkspaceCut, model.PhaseActive
+ state.WorkspaceBranch, _ = admission.Parameters.Get("branch")
+ state.WorkspaceBaseRef, _ = admission.Parameters.Get("base_ref")
+ state.WorkspacePath, _ = admission.Parameters.Get("destination")
+ state.WorkspaceSourcePath = admission.Invocation.InvokingPath
+ state.WorkspaceSourceID = admission.Invocation.WorktreeID
+ state.WorkspaceSourceRef = admission.Invocation.Ref
+ case "workspace.sync", "workspace.activate":
+ state.Workspace, state.Phase = model.WorkspaceActive, model.PhaseActive
+ case "workspace.publish":
+ state.Workspace, state.Phase = model.WorkspacePublished, model.PhaseActive
+ case "workspace.cleanup":
+ if state.Workspace != model.WorkspaceLanded && state.Workspace != model.WorkspaceAbandoned {
+ return fmt.Errorf("workspace cleanup requires landed or explicitly abandoned state")
+ }
+ state.Workspace = model.WorkspaceAbsent
+ state.Phase = terminalPhase(*state)
+ case "workspace.reap":
+ state.Workspace = model.WorkspaceAbsent
+ state.Phase = terminalPhase(*state)
+ case "workspace.reconcile":
+ state.Recovery, state.Transaction, state.Phase = model.RecoveryNone, model.TransactionNone, engagedPhase(*state)
+ ClearRecoveryContext(state)
+ case "gate.build.record", "gate.test.record", "gate.review.record", "gate.change.record", "gate.journey.record":
+ gate, _ := catalog.GateName(transition.ID)
+ revision, _ := admission.Parameters.Get("source_revision")
+ fingerprint, _ := admission.Parameters.Get("evidence_fingerprint")
+ upsertGate(state, durable.GateEvidence{Gate: gate, Revision: revision, Fingerprint: fingerprint})
+ state.SourceRevision, state.WorktreeFingerprint = admission.SourceRevision, admission.WorktreeFingerprint
+ state.Terminal, state.Delivery = model.TerminalNonterminal, model.DeliveryActive
+ state.Verification, state.Phase = model.VerificationCurrent, model.PhaseActive
+ if verifiedGoalSatisfied(*state, admission.Goal) {
+ state.Delivery = model.DeliveryGatesPassed
+ establishTerminal(state, model.PhaseTerminal)
+ }
+ case "evidence.visual.attach":
+ revision, _ := admission.Parameters.Get("source_revision")
+ fingerprint, _ := admission.Parameters.Get("privacy_receipt")
+ upsertGate(state, durable.GateEvidence{Gate: "visual", Revision: revision, Fingerprint: fingerprint})
+ state.SourceRevision, state.WorktreeFingerprint = admission.SourceRevision, admission.WorktreeFingerprint
+ state.Terminal = model.TerminalNonterminal
+ if admission.Goal.Kind == model.GoalVerified {
+ state.Delivery = model.DeliveryActive
+ }
+ state.Verification, state.Phase = model.VerificationCurrent, model.PhaseActive
+ if verifiedGoalSatisfied(*state, admission.Goal) {
+ state.Delivery = model.DeliveryGatesPassed
+ establishTerminal(state, model.PhaseTerminal)
+ }
+ case "evidence.approval.revoke":
+ state.Plan, state.Phase, state.Terminal = model.PlanValid, model.PhaseFrontier, model.TerminalNonterminal
+ case "delivery.slice.advance":
+ state.Delivery, state.Phase = model.DeliveryActive, model.PhaseActive
+ state.SourceRevision, state.WorktreeFingerprint = admission.SourceRevision, admission.WorktreeFingerprint
+ case "publication.preview":
+ state.Publication, state.Phase = model.PublicationCandidate, model.PhaseActive
+ case "publication.execute":
+ state.Publication, state.Workspace, state.Delivery, state.Phase = model.PublicationPublishedNotLanded, model.WorkspacePublished, model.DeliveryPublished, model.PhaseActive
+ case "publication.observe", "publication.reconcile":
+ state.Recovery, state.Transaction = model.RecoveryNone, model.TransactionNone
+ ClearRecoveryContext(state)
+ if state.Publication == model.PublicationUnavailable || state.Publication == model.PublicationConflicting {
+ state.Phase = model.PhaseUnresolved
+ } else if state.Publication == model.PublicationClosedUnmerged {
+ state.Phase = model.PhaseFrontier
+ } else if admission.Goal.Kind == model.GoalOpenPR && state.Publication == model.PublicationOpen {
+ establishTerminal(state, model.PhaseTerminal)
+ } else if admission.Goal.Kind == model.GoalMerged && state.Publication == model.PublicationMerged {
+ state.Workspace, state.Delivery = model.WorkspaceLanded, model.DeliveryTerminal
+ establishTerminal(state, model.PhaseTerminal)
+ } else {
+ state.Phase = model.PhaseActive
+ }
+ case "publication.correct":
+ state.Publication, state.Delivery = model.PublicationPublishedNotLanded, model.DeliveryPublished
+ state.Terminal, state.Phase = model.TerminalNonterminal, model.PhaseActive
+ case "recovery.resume":
+ state.Recovery, state.Transaction, state.Delivery, state.Phase = model.RecoveryNone, model.TransactionNone, model.DeliveryActive, model.PhaseActive
+ ClearRecoveryContext(state)
+ case "recovery.rollback":
+ state.Recovery, state.Transaction, state.Phase = model.RecoveryNone, model.TransactionNone, model.PhaseObserved
+ ClearRecoveryContext(state)
+ case "recovery.escalate":
+ state.Recovery, state.Phase = model.RecoveryEscalated, model.PhaseFrontier
+ state.Transaction = model.TransactionNone
+ state.TransactionID, _ = admission.Parameters.Get("transaction_id")
+ state.TransactionTransition = "recovery.escalate"
+ state.RecoveryCause = "recovery requires explicit external resolution"
+ state.RecoverySourcePhase = model.PhaseRecovery
+ state.RecoveryResumption = model.PhaseFrontier
+ state.RecoveryBudget = 0
+ default:
+ return fmt.Errorf("transition %q has no V2 state reducer", transition.ID)
+ }
+ if !transition.DeclaresTargetPhase(state.Phase) {
+ return fmt.Errorf("transition %q reducer produced undeclared target phase %s", transition.ID, state.Phase)
+ }
+ return nil
+}
+
+func ClearRecoveryContext(state *durable.State) {
+ state.TransactionID = ""
+ state.TransactionTransition = ""
+ state.RecoveryCause = ""
+ state.RecoverySourcePhase = ""
+ state.RecoveryResumption = ""
+ state.RecoveryBudget = 0
+}
+
+func engagedPhase(state durable.State) model.ProtocolPhase {
+ if state.Engagement == model.EngagementActive {
+ return model.PhaseActive
+ }
+ return model.PhaseObserved
+}
+
+func settledPhase(state durable.State) model.ProtocolPhase {
+ if state.Terminal == model.TerminalEstablished {
+ return terminalPhase(state)
+ }
+ return engagedPhase(state)
+}
+
+func terminalPhase(state durable.State) model.ProtocolPhase {
+ if state.Terminal == model.TerminalEstablished && state.Delivery == model.DeliveryDiscarded {
+ return model.PhaseAbandoned
+ }
+ if state.Terminal == model.TerminalEstablished {
+ return model.PhaseTerminal
+ }
+ return model.PhaseObserved
+}
+
+func establishTerminal(state *durable.State, phase model.ProtocolPhase) {
+ state.Terminal, state.Phase = model.TerminalEstablished, phase
+ if phase == model.PhaseTerminal {
+ state.Delivery = model.DeliveryTerminal
+ }
+}
+
+func upsertGate(state *durable.State, evidence durable.GateEvidence) {
+ for index := range state.Gates {
+ if state.Gates[index].Gate == evidence.Gate {
+ state.Gates[index] = evidence
+ return
+ }
+ }
+ state.Gates = append(state.Gates, evidence)
+}
+
+func hasGates(state durable.State, names ...string) bool {
+ found := map[string]bool{}
+ for _, gate := range state.Gates {
+ if gate.Revision == state.SourceRevision {
+ found[gate.Gate] = true
+ }
+ }
+ for _, name := range names {
+ if !found[name] {
+ return false
+ }
+ }
+ return true
+}
+
+func verifiedGoalSatisfied(state durable.State, goal model.Goal) bool {
+ if goal.Kind != model.GoalVerified || !hasGates(state, "build", "test", "review") {
+ return false
+ }
+ return state.VisualEvidencePolicy != "required" || hasGates(state, "visual")
+}
diff --git a/boatstack/internal/kernel/reducer/reducer_test.go b/boatstack/internal/kernel/reducer/reducer_test.go
new file mode 100644
index 0000000..4141105
--- /dev/null
+++ b/boatstack/internal/kernel/reducer/reducer_test.go
@@ -0,0 +1,131 @@
+package reducer
+
+import (
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+func TestRequiredVisualEvidenceParticipatesInVerifiedTerminal(t *testing.T) {
+ // control-law: repository-visual-policy-is-terminal-authority-not-decoration
+ goal := model.Goal{ID: "visual-goal", Kind: model.GoalVerified, DeliveryID: "visual-delivery"}
+ state := durable.State{
+ SchemaVersion: durable.StateSchemaVersion, RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree", Revision: 1,
+ Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryActive, Workspace: model.WorkspaceActive,
+ Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationNone,
+ Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal,
+ Goal: goal, ConfigFingerprint: "config", PlanApprovalPolicy: "human", VisualEvidencePolicy: "required",
+ ExternalEffectPolicy: "human-or-autonomy-plus-provider", EnabledHosts: []string{"cli"},
+ }
+ apply := func(id catalog.TransitionID, parameters protocol.Parameters) {
+ t.Helper()
+ transition, ok := catalog.Default().Lookup(id)
+ if !ok {
+ t.Fatalf("missing transition %s", id)
+ }
+ if err := Apply(&state, protocol.Admission{Goal: goal, Parameters: parameters, SourceRevision: "revision", WorktreeFingerprint: "worktree"}, transition); err != nil {
+ t.Fatalf("apply %s: %v", id, err)
+ }
+ }
+ for _, gate := range []string{"build", "test", "review"} {
+ apply(catalog.TransitionID("gate."+gate+".record"), protocol.Parameters{
+ {Name: "source_revision", Value: "revision"}, {Name: "evidence_fingerprint", Value: "evidence-" + gate},
+ })
+ }
+ if state.Terminal == model.TerminalEstablished {
+ t.Fatal("required visual evidence was bypassed by build/test/review gates")
+ }
+ apply("evidence.visual.attach", protocol.Parameters{
+ {Name: "manifest_path", Value: "/manifest"}, {Name: "privacy_receipt", Value: "visual-proof"}, {Name: "source_revision", Value: "revision"},
+ })
+ if state.Terminal != model.TerminalEstablished || state.Phase != model.PhaseTerminal || !hasGates(state, "visual") {
+ t.Fatalf("visual evidence did not establish verified terminal: %#v", state)
+ }
+}
+
+func TestPublicationCorrectionRequiresIndependentObservationForTerminal(t *testing.T) {
+ // control-law: external-writer-cannot-self-certify-provider-state
+ goal := model.Goal{ID: "publication-goal", Kind: model.GoalOpenPR, DeliveryID: "publication-delivery"}
+ state := durable.State{
+ SchemaVersion: durable.StateSchemaVersion, RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree", Revision: 1,
+ Phase: model.PhaseTerminal, Engagement: model.EngagementActive, Delivery: model.DeliveryTerminal, Workspace: model.WorkspacePublished,
+ Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationOpen,
+ Verification: model.VerificationCurrent, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalEstablished,
+ Goal: goal,
+ }
+ correct, _ := catalog.Default().Lookup("publication.correct")
+ admission := protocol.Admission{Goal: goal, Parameters: protocol.Parameters{{Name: "publication_id", Value: "7"}, {Name: "body_path", Value: "/body"}}}
+ if err := Apply(&state, admission, correct); err != nil {
+ t.Fatal(err)
+ }
+ if state.Terminal != model.TerminalNonterminal || state.Publication != model.PublicationPublishedNotLanded || state.Phase != model.PhaseActive {
+ t.Fatalf("external correction self-certified terminal: %#v", state)
+ }
+ state.Publication = model.PublicationOpen // supplied only by PrepareObservation through gh pr view
+ observe, _ := catalog.Default().Lookup("publication.observe")
+ if err := Apply(&state, admission, observe); err != nil {
+ t.Fatal(err)
+ }
+ if state.Terminal != model.TerminalEstablished || state.Phase != model.PhaseTerminal {
+ t.Fatalf("independent publication observation did not establish terminal: %#v", state)
+ }
+}
+
+func TestWorkspaceReapPreservesEstablishedTerminalPhase(t *testing.T) {
+ for _, fixture := range []struct {
+ goalKind model.GoalKind
+ delivery model.DeliveryState
+ phase model.ProtocolPhase
+ }{
+ {goalKind: model.GoalMerged, delivery: model.DeliveryTerminal, phase: model.PhaseTerminal},
+ {goalKind: model.GoalAbandoned, delivery: model.DeliveryDiscarded, phase: model.PhaseAbandoned},
+ } {
+ goal := model.Goal{ID: "cleanup-goal", Kind: fixture.goalKind, DeliveryID: "cleanup-delivery"}
+ state := durable.State{
+ SchemaVersion: durable.StateSchemaVersion, RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree", Revision: 1,
+ Phase: fixture.phase, Engagement: model.EngagementActive, Delivery: fixture.delivery, Workspace: model.WorkspaceAbandoned,
+ Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationMerged,
+ Verification: model.VerificationCurrent, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalEstablished, Goal: goal,
+ }
+ transition, _ := catalog.Default().Lookup("workspace.reap")
+ if err := Apply(&state, protocol.Admission{Goal: goal}, transition); err != nil {
+ t.Fatal(err)
+ }
+ if state.Workspace != model.WorkspaceAbsent || state.Phase != fixture.phase || state.Terminal != model.TerminalEstablished {
+ t.Fatalf("reap lost %s terminal: %#v", fixture.goalKind, state)
+ }
+ }
+}
+
+func TestEscalatedRecoveryCanOnlyBeReconfiguredTowardExplicitAbandonment(t *testing.T) {
+ original := model.Goal{ID: "delivery-goal", Kind: model.GoalOpenPR, DeliveryID: "delivery"}
+ abandoned := model.Goal{ID: "abandon-goal", Kind: model.GoalAbandoned, DeliveryID: "delivery"}
+ state := durable.State{
+ SchemaVersion: durable.StateSchemaVersion, RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree", Revision: 1,
+ Phase: model.PhaseFrontier, Engagement: model.EngagementActive, Delivery: model.DeliveryPublished, Workspace: model.WorkspacePublished,
+ Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationUnavailable,
+ Verification: model.VerificationCurrent, Recovery: model.RecoveryEscalated, Transaction: model.TransactionNone, Terminal: model.TerminalStale,
+ Goal: original, TransactionID: "adm-interrupted", RecoveryCause: "provider unknown", RecoverySourcePhase: model.PhaseActive,
+ RecoveryResumption: model.PhaseFrontier, RecoveryBudget: 0,
+ }
+ configure, _ := catalog.Default().Lookup("goal.configure")
+ configureAdmission := protocol.Admission{Goal: abandoned, Parameters: protocol.Parameters{
+ {Name: "goal_kind", Value: string(abandoned.Kind)}, {Name: "delivery_id", Value: abandoned.DeliveryID},
+ }}
+ if err := Apply(&state, configureAdmission, configure); err != nil {
+ t.Fatal(err)
+ }
+ if state.Phase != model.PhaseFrontier || state.Recovery != model.RecoveryEscalated {
+ t.Fatalf("goal reconfiguration bypassed escalated recovery: %#v", state)
+ }
+ abandon, _ := catalog.Default().Lookup("plan.abandon")
+ if err := Apply(&state, protocol.Admission{Goal: abandoned}, abandon); err != nil {
+ t.Fatal(err)
+ }
+ if state.Phase != model.PhaseAbandoned || state.Recovery != model.RecoveryNone || state.TransactionID != "" || state.Terminal != model.TerminalEstablished {
+ t.Fatalf("explicit abandonment did not close recovery: %#v", state)
+ }
+}
diff --git a/boatstack/internal/kernel/supervisor/classify.go b/boatstack/internal/kernel/supervisor/classify.go
new file mode 100644
index 0000000..caafc59
--- /dev/null
+++ b/boatstack/internal/kernel/supervisor/classify.go
@@ -0,0 +1,86 @@
+package supervisor
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "regexp"
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+)
+
+type commandPattern struct {
+ operation string
+ pattern *regexp.Regexp
+}
+
+// These are constitutional effect classes, not workflow heuristics. They are
+// intentionally repository-independent so every host and managed command
+// receives the same result. Raw command text is fingerprinted but never
+// persisted in a receipt or process event.
+var destructiveCommandPatterns = []commandPattern{
+ {"database.reset", regexp.MustCompile(`(?i)\b(?:supabase\s+db\s+reset|prisma\s+migrate\s+reset|rails\s+db:(?:drop|reset)|django-admin\s+flush|manage\.py\s+flush|alembic\s+downgrade\s+base|pg_restore\b[^\n]*\s--clean\b)`)},
+ {"external-resource.delete", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+delete\b`)},
+ {"external-lifecycle.weaken", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+update\b[^\n;&|]*(?:--persistent(?:=|[\s"',()]+)false\b|--no-persistent\b)`)},
+ {"external-service.expose", regexp.MustCompile(`(?i)(?:\bgcloud\s+run\s+(?:deploy|services\s+update)\b[^\n;&|]*--allow-unauthenticated\b|\bgcloud\s+(?:projects|run\s+services)\s+(?:add-iam-policy-binding|set-iam-policy)\b[^\n;&|]*(?:allusers|roles/run\.invoker)|\bkubectl\s+(?:create|expose|patch|apply)\b[^\n;&|]*(?:type[=:](?:loadbalancer|nodeport)|0\.0\.0\.0/0)|\baws\s+[^\n;&|]*(?:authorize-security-group-ingress|put-public-access-block)\b[^\n;&|]*(?:0\.0\.0\.0/0|block-public-acls\s+false))`)},
+ {"filesystem.recursive-delete", regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\s+(?:["']?(?:/|~|\$home|\.|\.\.)["']?\s*(?:;|&&|\|\||$)|[^\s;]*\*[^\s;]*)`)},
+ {"git.reset-hard", regexp.MustCompile(`(?i)\bgit\s+reset\s+[^\n;&|]*--hard\b`)},
+ {"git.clean-force", regexp.MustCompile(`(?i)\bgit\s+clean\s+-[^\s]*(?:f[^\s]*d|d[^\s]*f|x)[^\s]*`)},
+ {"git.force-push", regexp.MustCompile(`(?i)\bgit\s+push\b[^\n;&|]*(?:--force(?:-with-lease)?|-f\b)`)},
+ {"infrastructure.destroy", regexp.MustCompile(`(?i)\b(?:terraform|tofu|pulumi)\s+destroy\b|\bkubectl\s+delete\s+(?:namespace|cluster|persistentvolume|persistentvolumeclaim|pvc)\b|\bdocker\s+volume\s+(?:rm|prune)\b|\bgcloud\s+(?:projects|sql\s+instances|compute\s+(?:instances|disks))\s+delete\b|\baws\s+[^\n;&|]*(?:delete-cluster|delete-db-instance|terminate-instances|delete-volume|delete-bucket)\b`)},
+ {"recovery.destroy", regexp.MustCompile(`(?i)\b(?:delete|remove|disable)\b[^\n;]*(?:backup|snapshot|point-in-time|pitr|recovery)\b`)},
+ {"publication.merge", regexp.MustCompile(`(?i)\bgh\s+pr\s+merge\b`)},
+ {"external-tool.destructive", regexp.MustCompile(`(?i)\bmcp[_-]{2}[^\s]*(?:delete|drop|destroy|reset|execute[_-]?sql)[^\s]*\b`)},
+}
+
+var (
+ liveSQLClient = regexp.MustCompile(`(?i)(?:^|[;&|]\s*|\b(?:sudo|env)\s+)(?:psql|mysql|mariadb|mongo|mongosh|cockroach|sqlcmd|usql|clickhouse-client)\b`)
+ destructiveSQL = regexp.MustCompile(`(?i)\b(?:drop\s+(?:database|schema|table)|alter\s+table\b[^;\n]*\bdrop\s+(?:column|constraint)|truncate(?:\s+table)?|delete\s+from\b)`)
+ sqlFileRun = regexp.MustCompile(`(?i)\b(?:psql|mysql|mariadb|cockroach|sqlcmd|usql|clickhouse-client)\b[^\n;&|]*(?:\s-f\s|\s--file(?:=|\s)|\s<\s*)`)
+ managedState = regexp.MustCompile(`(?i)(?:^|[\s"'=])(?:\.git[/\\](?:worktrees[/\\][^/\\]+[/\\])?boatstack(?:[/\\]|$)|\.boatstack[/\\](?:approvals|evidence|plans|publication)(?:[/\\]|$)|\.boatstack[/\\]project\.json\b)`)
+ mutationSyntax = regexp.MustCompile(`(?i)(?:^|[;&|]\s*)(?:rm|mv|cp|touch|mkdir|tee|truncate|install|remove-item|move-item|copy-item|new-item|set-content|add-content)\b|\bsed\s+-[^\s]*i\b|(?:^|[^<])>{1,2}[^>]`)
+ dataOnlyPrefix = regexp.MustCompile(`(?i)^(?:rg|grep|cat|head|tail|less|wc|awk|sort|uniq|cut|tr|jq|column|nl|comm|rev|fold|git\s+(?:add|commit|diff|status|show|log|grep|restore\s+--staged))\b`)
+)
+
+var managedCommandPatterns = []struct {
+ pattern *regexp.Regexp
+ operation string
+ transition catalog.TransitionID
+}{
+ {regexp.MustCompile(`(?i)\bgit\s+worktree\s+remove\b`), "workspace.remove", "workspace.cleanup"},
+ {regexp.MustCompile(`(?i)\bgh\s+pr\s+create\b`), "publication.create", "publication.execute"},
+ {regexp.MustCompile(`(?i)\bgh\s+pr\s+edit\b`), "publication.edit", "publication.correct"},
+ {regexp.MustCompile(`(?i)\bgh\s+pr\s+ready\b`), "publication.ready", "publication.correct"},
+ {regexp.MustCompile(`(?i)\bgh\s+api\b[^\n;&|]*(?:/pulls\b|/pull-requests\b)[^\n;&|]*(?:\s-X\s*(?:POST|PATCH)|--request\s+(?:POST|PATCH))`), "publication.api-write", "publication.correct"},
+ {regexp.MustCompile(`(?i)\bgit\s+push\b`), "publication.push", "publication.execute"},
+}
+
+func ClassifyCommandIntent(command string) CommandIntent {
+ normalized := strings.Join(strings.Fields(command), " ")
+ digest := sha256.Sum256([]byte(command))
+ intent := CommandIntent{Class: IntentOrdinary, Operation: "repository.command", Fingerprint: hex.EncodeToString(digest[:])}
+ if dataOnlyPrefix.MatchString(normalized) && !strings.ContainsAny(normalized, ";|<>") && !strings.Contains(normalized, "&&") && !strings.Contains(normalized, "$(") && !strings.Contains(normalized, "`") {
+ return intent
+ }
+ for _, candidate := range destructiveCommandPatterns {
+ if candidate.pattern.MatchString(normalized) {
+ intent.Class, intent.Operation = IntentDestructive, candidate.operation
+ return intent
+ }
+ }
+ if (liveSQLClient.MatchString(normalized) && destructiveSQL.MatchString(normalized)) || sqlFileRun.MatchString(normalized) {
+ intent.Class, intent.Operation = IntentDestructive, "database.destruction"
+ return intent
+ }
+ if managedState.MatchString(normalized) && mutationSyntax.MatchString(normalized) {
+ intent.Class, intent.Operation = IntentDestructive, "managed-state.tamper"
+ return intent
+ }
+ for _, candidate := range managedCommandPatterns {
+ if candidate.pattern.MatchString(normalized) {
+ intent.Class, intent.Operation, intent.Transition = IntentManagedBypass, candidate.operation, candidate.transition
+ return intent
+ }
+ }
+ return intent
+}
diff --git a/boatstack/internal/kernel/supervisor/guard.go b/boatstack/internal/kernel/supervisor/guard.go
new file mode 100644
index 0000000..6ba1002
--- /dev/null
+++ b/boatstack/internal/kernel/supervisor/guard.go
@@ -0,0 +1,81 @@
+package supervisor
+
+import (
+ "fmt"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+type IntentClass string
+
+const (
+ IntentOrdinary IntentClass = "ordinary"
+ IntentManagedBypass IntentClass = "managed-bypass"
+ IntentDestructive IntentClass = "destructive"
+)
+
+type CommandIntent struct {
+ Class IntentClass `json:"class"`
+ Operation string `json:"operation"`
+ Fingerprint string `json:"fingerprint"`
+ Transition catalog.TransitionID `json:"transition,omitempty"`
+}
+
+func (i CommandIntent) Validate() error {
+ if i.Fingerprint == "" || i.Operation == "" {
+ return fmt.Errorf("command intent requires operation and fingerprint")
+ }
+ switch i.Class {
+ case IntentOrdinary, IntentDestructive:
+ if i.Transition != "" {
+ return fmt.Errorf("%s intent cannot name a managed transition", i.Class)
+ }
+ case IntentManagedBypass:
+ if i.Transition == "" {
+ return fmt.Errorf("managed-bypass intent requires a transition")
+ }
+ default:
+ return fmt.Errorf("invalid command intent class %q", i.Class)
+ }
+ return nil
+}
+
+type GuardDecision struct {
+ Allowed bool `json:"allowed"`
+ Intent CommandIntent `json:"intent"`
+ RequiredTransition catalog.TransitionID `json:"required_transition,omitempty"`
+ Reason string `json:"reason"`
+}
+
+// Guard is the only hook safety law. Hosts provide raw commands to the surface
+// classifier and consume this result; they do not reconstruct engagement or
+// lifecycle state themselves.
+func (s Supervisor) Guard(snapshot model.Snapshot, intent CommandIntent) GuardDecision {
+ decision := GuardDecision{Intent: intent}
+ if err := intent.Validate(); err != nil || snapshot.Fingerprint == "" {
+ decision.Reason = "command intent or canonical snapshot is invalid"
+ return decision
+ }
+ if intent.Class == IntentDestructive {
+ decision.Reason = "high-confidence destructive operation requires an explicit registered recovery or cleanup transition"
+ return decision
+ }
+ if intent.Class == IntentOrdinary {
+ decision.Allowed = true
+ decision.Reason = "ordinary repository operation is outside managed effect authority"
+ return decision
+ }
+ decision.RequiredTransition = intent.Transition
+ if snapshot.Engagement.Status != model.FactKnown {
+ decision.Reason = "managed-effect engagement is unresolved"
+ return decision
+ }
+ if snapshot.Engagement.Value == model.EngagementCommand || snapshot.Engagement.Value == model.EngagementActive || snapshot.Phase.Value == model.PhaseRecovery {
+ decision.Reason = "managed effect must be requested through kernel admission"
+ return decision
+ }
+ decision.Allowed = true
+ decision.Reason = "Boatstack is dormant; direct non-destructive repository operation remains inert"
+ return decision
+}
diff --git a/boatstack/internal/kernel/supervisor/supervisor.go b/boatstack/internal/kernel/supervisor/supervisor.go
new file mode 100644
index 0000000..6c0ba68
--- /dev/null
+++ b/boatstack/internal/kernel/supervisor/supervisor.go
@@ -0,0 +1,219 @@
+package supervisor
+
+import (
+ "fmt"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+type DecisionKind string
+
+const (
+ DecisionPrescribed DecisionKind = "PRESCRIBED"
+ DecisionTerminal DecisionKind = "TERMINAL"
+ DecisionFrontier DecisionKind = "FRONTIER"
+ DecisionBlocked DecisionKind = "BLOCKED"
+ DecisionRefused DecisionKind = "REFUSED"
+ DecisionUnresolved DecisionKind = "UNRESOLVED"
+)
+
+type Decision struct {
+ Kind DecisionKind `json:"kind"`
+ SnapshotFingerprint string `json:"snapshot_fingerprint"`
+ Transition *catalog.Transition `json:"transition,omitempty"`
+ Candidates []catalog.TransitionID `json:"candidates,omitempty"`
+ Reason string `json:"reason"`
+}
+
+type Supervisor struct{ registry catalog.Registry }
+
+func New(registry catalog.Registry) Supervisor { return Supervisor{registry: registry} }
+
+func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority catalog.AuthoritySet, requested catalog.TransitionID) Decision {
+ base := Decision{SnapshotFingerprint: snapshot.Fingerprint}
+ if err := goal.Validate(); err != nil || snapshot.Fingerprint == "" {
+ base.Kind, base.Reason = DecisionUnresolved, "goal or canonical snapshot is invalid"
+ return base
+ }
+ if snapshot.Terminal.Status != model.FactKnown || snapshot.Phase.Status != model.FactKnown {
+ base.Kind, base.Reason = DecisionUnresolved, "terminal or phase evidence is not known"
+ return base
+ }
+ if snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value != goal && requested != "goal.configure" {
+ base.Kind, base.Reason = DecisionRefused, "requested goal differs from configured goal; goal.configure is required"
+ return base
+ }
+ if requested != "" && snapshot.ConfigurationPolicy.Status == model.FactKnown && !hostEnabled(snapshot.ConfigurationPolicy.Value.Hosts, snapshot.Invocation.Host) {
+ base.Kind, base.Reason = DecisionRefused, fmt.Sprintf("host %q is not enabled by repository policy", snapshot.Invocation.Host)
+ return base
+ }
+ if requested == "" && snapshot.Terminal.Value == model.TerminalEstablished && terminalMatchesGoal(snapshot, goal) {
+ base.Kind, base.Reason = DecisionTerminal, "configured terminal is established by current evidence"
+ return base
+ }
+ admissible := s.registry.Admissible(snapshot, goal)
+ if snapshot.Phase.Value == model.PhaseRecovery {
+ filtered := admissible[:0]
+ for _, candidate := range admissible {
+ if candidate.Class == catalog.EventRecovery {
+ filtered = append(filtered, candidate)
+ }
+ }
+ admissible = filtered
+ }
+ if requested != "" {
+ transition, ok := s.registry.Lookup(requested)
+ if !ok || !transition.Controllable() {
+ base.Kind, base.Reason = DecisionRefused, fmt.Sprintf("transition %q is not a controllable registry event", requested)
+ return base
+ }
+ found := false
+ for _, candidate := range admissible {
+ if candidate.ID == requested {
+ transition, found = candidate, true
+ break
+ }
+ }
+ if !found {
+ base.Kind, base.Reason = DecisionRefused, fmt.Sprintf("transition %q is not admissible from current snapshot", requested)
+ return base
+ }
+ if allowed, reason := policyAllows(snapshot, transition); !allowed {
+ base.Kind, base.Reason = DecisionRefused, reason
+ return base
+ }
+ if !authoritySatisfies(snapshot, transition, authority) {
+ base.Kind, base.Reason = DecisionFrontier, fmt.Sprintf("transition %q requires unavailable authority", requested)
+ base.Candidates = []catalog.TransitionID{requested}
+ return base
+ }
+ base.Kind, base.Reason, base.Transition = DecisionPrescribed, "requested transition is admissible", &transition
+ return base
+ }
+ available := make([]catalog.Transition, 0, len(admissible))
+ for _, candidate := range admissible {
+ if allowed, _ := policyAllows(snapshot, candidate); allowed && authoritySatisfies(snapshot, candidate, authority) {
+ available = append(available, candidate)
+ }
+ }
+ if len(available) == 0 {
+ if len(admissible) > 0 {
+ base.Kind, base.Reason = DecisionFrontier, "admissible transitions require unavailable authority"
+ for _, candidate := range admissible {
+ base.Candidates = append(base.Candidates, candidate.ID)
+ }
+ return base
+ }
+ if snapshot.Phase.Value == model.PhaseRecovery || snapshot.Phase.Value == model.PhaseUnresolved {
+ base.Kind, base.Reason = DecisionBlocked, "no registered recovery transition is admissible"
+ return base
+ }
+ base.Kind, base.Reason = DecisionUnresolved, "no transition is safely selectable from current evidence"
+ return base
+ }
+ topPriority := available[0].Priority
+ var top []catalog.Transition
+ for _, candidate := range available {
+ if candidate.Priority == topPriority {
+ top = append(top, candidate)
+ }
+ }
+ if len(top) != 1 {
+ base.Kind, base.Reason = DecisionFrontier, "several equally preferred transitions remain admissible"
+ for _, candidate := range top {
+ base.Candidates = append(base.Candidates, candidate.ID)
+ }
+ return base
+ }
+ base.Kind, base.Reason, base.Transition = DecisionPrescribed, "deterministic highest-priority transition", &top[0]
+ return base
+}
+
+func hostEnabled(hosts []string, host string) bool {
+ for _, candidate := range hosts {
+ if candidate == host {
+ return true
+ }
+ }
+ return false
+}
+
+func authoritySatisfies(snapshot model.Snapshot, transition catalog.Transition, authority catalog.AuthoritySet) bool {
+ if !authority.Satisfies(transition.Authority, transition.AuthorityAll) {
+ return false
+ }
+ if transition.ID == "plan.approve" || transition.ID == "plan.approve-amendment" {
+ if snapshot.ConfigurationPolicy.Status != model.FactKnown {
+ return false
+ }
+ if snapshot.ConfigurationPolicy.Value.PlanApproval == "human" && !authority[catalog.AuthorityHuman] {
+ return false
+ }
+ }
+ if transition.ID == "gate.review.record" {
+ if snapshot.ConfigurationPolicy.Status != model.FactKnown {
+ return false
+ }
+ policy := snapshot.ConfigurationPolicy.Value
+ if policy.IndependentReviewForHighRisk && policy.HighRiskChange && !authority[catalog.AuthorityHuman] {
+ return false
+ }
+ }
+ return true
+}
+
+func policyAllows(snapshot model.Snapshot, transition catalog.Transition) (bool, string) {
+ if transition.Class == catalog.EventRecovery && snapshot.RecoveryInfo.Status == model.FactKnown {
+ permitted := false
+ for _, candidate := range snapshot.RecoveryInfo.Value.Permitted {
+ if candidate == string(transition.ID) {
+ permitted = true
+ break
+ }
+ }
+ if !permitted {
+ return false, fmt.Sprintf("recovery transition %q is not permitted for transaction %q", transition.ID, snapshot.RecoveryInfo.Value.TransactionID)
+ }
+ }
+ if transition.ID != "evidence.visual.attach" {
+ return true, ""
+ }
+ if snapshot.ConfigurationPolicy.Status != model.FactKnown {
+ return false, fmt.Sprintf("transition %q requires known configuration policy", transition.ID)
+ }
+ if snapshot.ConfigurationPolicy.Value.VisualEvidence == "off" {
+ return false, "visual evidence is disabled by repository policy"
+ }
+ return true, ""
+}
+
+func terminalMatchesGoal(snapshot model.Snapshot, goal model.Goal) bool {
+ if snapshot.Goal.Status != model.FactKnown || snapshot.Goal.Value != goal {
+ return false
+ }
+ switch goal.Kind {
+ case model.GoalApprovedPlan:
+ return snapshot.Plan.Status == model.FactKnown && snapshot.Plan.Value == model.PlanApproved
+ case model.GoalVerified:
+ return deliveryInputsCurrent(snapshot) &&
+ snapshot.Delivery.Status == model.FactKnown && snapshot.Delivery.Value == model.DeliveryTerminal
+ case model.GoalOpenPR:
+ return deliveryInputsCurrent(snapshot) && snapshot.Publication.Status == model.FactKnown && snapshot.Publication.Value == model.PublicationOpen
+ case model.GoalMerged:
+ return snapshot.Publication.Status == model.FactKnown && snapshot.Publication.Value == model.PublicationMerged &&
+ snapshot.Delivery.Status == model.FactKnown && snapshot.Delivery.Value == model.DeliveryTerminal &&
+ snapshot.Workspace.Status == model.FactKnown && (snapshot.Workspace.Value == model.WorkspaceLanded || snapshot.Workspace.Value == model.WorkspaceAbsent)
+ case model.GoalAbandoned:
+ return snapshot.Delivery.Status == model.FactKnown && snapshot.Delivery.Value == model.DeliveryDiscarded &&
+ snapshot.Workspace.Status == model.FactKnown && (snapshot.Workspace.Value == model.WorkspaceAbandoned || snapshot.Workspace.Value == model.WorkspaceAbsent)
+ default:
+ return false
+ }
+}
+
+func deliveryInputsCurrent(snapshot model.Snapshot) bool {
+ return snapshot.Verification.Status == model.FactKnown && snapshot.Verification.Value == model.VerificationCurrent &&
+ snapshot.Configuration.Status == model.FactKnown && snapshot.Configuration.Value == model.ConfigurationVerified &&
+ snapshot.Runtime.Status == model.FactKnown && snapshot.Runtime.Value == model.RuntimeVerified
+}
diff --git a/boatstack/internal/kernel/supervisor/supervisor_test.go b/boatstack/internal/kernel/supervisor/supervisor_test.go
new file mode 100644
index 0000000..bccc8ef
--- /dev/null
+++ b/boatstack/internal/kernel/supervisor/supervisor_test.go
@@ -0,0 +1,196 @@
+package supervisor
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+func snapshotFor(t *testing.T, phase model.ProtocolPhase, terminal model.TerminalStatus) model.Snapshot {
+ t.Helper()
+ 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"},
+ 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),
+ ConfigurationPolicy: model.Known(model.ConfigurationPolicy{PlanApproval: "human", VisualEvidence: "optional", ExternalEffectAuthority: "human-or-autonomy-plus-provider", Hosts: []string{"cli"}}, e),
+ Publication: model.Known(model.PublicationNone, e), Verification: model.Known(model.VerificationUnverified, e),
+ Recovery: model.Known(model.RecoveryNone, e), Transaction: model.Known(model.TransactionNone, e),
+ RecoveryInfo: model.Absent[model.RecoveryContext]("none", e), TransactionInfo: model.Absent[model.TransactionContext]("none", e),
+ Terminal: model.Known(terminal, e), Goal: model.Known(goalFor(), e), ObservedAt: time.Unix(10, 0).UTC(),
+ }
+ if phase == model.PhaseRecovery {
+ o.Recovery = model.Known(model.RecoveryReconcile, e)
+ o.Transaction = model.Known(model.TransactionExternalUncertain, e)
+ o.RecoveryInfo = model.Known(model.RecoveryContext{TransactionID: "adm-fixture", Cause: "interrupted", SourcePhase: model.PhaseActive, Permitted: []string{"recovery.resume"}, BudgetRemaining: 1, Resumption: model.PhaseActive}, e)
+ o.TransactionInfo = model.Known(model.TransactionContext{ID: "adm-fixture", TransitionID: "plan.create", Status: "recovery-required"}, e)
+ }
+ if terminal == model.TerminalEstablished {
+ o.Delivery = model.Known(model.DeliveryTerminal, e)
+ o.Verification = model.Known(model.VerificationCurrent, e)
+ }
+ result, err := model.Canonicalize(o)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return result
+}
+
+func goalFor() model.Goal {
+ return model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"}
+}
+
+func TestTerminalGoalOutranksLocalTransitions(t *testing.T) {
+ // control-law: configured-terminal-outranks-local-lifecycle
+ s := New(catalog.Default())
+ decision := s.Resolve(snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "")
+ if decision.Kind != DecisionTerminal || decision.Transition != nil {
+ t.Fatalf("decision = %#v, want terminal without transition", decision)
+ }
+}
+
+func TestExplicitPostTerminalCleanupRemainsAdmissible(t *testing.T) {
+ // control-law: terminal-proof-does-not-strand-proven-landed-resources
+ snapshot := snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished)
+ snapshot.Workspace = model.Known(model.WorkspaceLanded, snapshot.Workspace.Evidence[0])
+ snapshot.Publication = model.Known(model.PublicationMerged, snapshot.Publication.Evidence[0])
+ canonical, err := model.Canonicalize(snapshot.Observation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decision := New(catalog.Default()).Resolve(canonical, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "workspace.cleanup")
+ if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "workspace.cleanup" {
+ t.Fatalf("decision = %#v, want explicit post-terminal cleanup", decision)
+ }
+}
+
+func TestTerminalEvidenceForOldGoalDoesNotTerminateNewGoal(t *testing.T) {
+ // control-law: terminal-evidence-is-bound-to-exact-goal-not-local-phase
+ s := New(catalog.Default())
+ newGoal := model.Goal{ID: "next-goal", Kind: model.GoalOpenPR, DeliveryID: "delivery"}
+ decision := s.Resolve(snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished), newGoal, catalog.AuthoritySet{catalog.AuthorityHuman: true}, "goal.configure")
+ if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "goal.configure" {
+ t.Fatalf("decision=%#v, want exact new-goal configuration", decision)
+ }
+}
+
+func TestRequestedTransitionRequiresExactAuthority(t *testing.T) {
+ // control-law: useful-action-is-not-effect-authority
+ s := New(catalog.Default())
+ decision := s.Resolve(snapshotFor(t, model.PhaseActive, model.TerminalNonterminal), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "plan.approve")
+ if decision.Kind != DecisionFrontier {
+ t.Fatalf("decision = %s, want FRONTIER", decision.Kind)
+ }
+}
+
+func TestPlanApprovalPolicyDistinguishesHumanFromAutonomyAuthority(t *testing.T) {
+ snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal)
+ autonomy := catalog.AuthoritySet{catalog.AuthorityAutonomy: true}
+ decision := New(catalog.Default()).Resolve(snapshot, goalFor(), autonomy, "plan.approve")
+ if decision.Kind != DecisionFrontier {
+ t.Fatalf("human-only policy decision = %#v, want FRONTIER", decision)
+ }
+ snapshot.ConfigurationPolicy.Value.PlanApproval = "human-or-autonomy"
+ snapshot, err := model.Canonicalize(snapshot.Observation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decision = New(catalog.Default()).Resolve(snapshot, goalFor(), autonomy, "plan.approve")
+ if decision.Kind != DecisionPrescribed {
+ t.Fatalf("autonomy-enabled policy decision = %#v, want PRESCRIBED", decision)
+ }
+}
+
+func TestDisabledHostCannotRequestManagedTransition(t *testing.T) {
+ snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal)
+ snapshot.Invocation.Host = "codex"
+ snapshot, err := model.Canonicalize(snapshot.Observation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decision := New(catalog.Default()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "plan.approve")
+ if decision.Kind != DecisionRefused {
+ t.Fatalf("disabled host decision = %#v, want REFUSED", decision)
+ }
+}
+
+func TestHighRiskReviewPolicyRequiresHumanAuthority(t *testing.T) {
+ snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal)
+ snapshot.Plan = model.Known(model.PlanLocked, snapshot.Plan.Evidence[0])
+ snapshot.ConfigurationPolicy.Value.IndependentReviewForHighRisk = true
+ snapshot.ConfigurationPolicy.Value.HighRiskChange = true
+ snapshot, err := model.Canonicalize(snapshot.Observation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ supervisor := New(catalog.Default())
+ decision := supervisor.Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "gate.review.record")
+ if decision.Kind != DecisionFrontier {
+ t.Fatalf("repository-only high-risk review = %#v, want FRONTIER", decision)
+ }
+ decision = supervisor.Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "gate.review.record")
+ if decision.Kind != DecisionPrescribed {
+ t.Fatalf("human high-risk review = %#v, want PRESCRIBED", decision)
+ }
+}
+
+func TestVisualEvidenceOffRefusesAttachment(t *testing.T) {
+ snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal)
+ snapshot.ConfigurationPolicy.Value.VisualEvidence = "off"
+ snapshot, err := model.Canonicalize(snapshot.Observation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decision := New(catalog.Default()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "evidence.visual.attach")
+ if decision.Kind != DecisionRefused {
+ t.Fatalf("visual-off decision = %#v, want REFUSED", decision)
+ }
+}
+
+func TestRecoveryModeOnlyPrescribesRecoveryTransition(t *testing.T) {
+ // control-law: recovery-outranks-slice-position
+ s := New(catalog.Default())
+ decision := s.Resolve(snapshotFor(t, model.PhaseRecovery, model.TerminalNonterminal), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "recovery.resume")
+ if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.Class != catalog.EventRecovery {
+ t.Fatalf("decision = %#v, want a recovery prescription", decision)
+ }
+}
+
+func TestRecoveryModeRejectsARecoveryEventOutsideExactJournalContract(t *testing.T) {
+ snapshot := snapshotFor(t, model.PhaseRecovery, model.TerminalNonterminal)
+ decision := New(catalog.Default()).Resolve(snapshot, goalFor(), catalog.AuthoritySet{catalog.AuthorityHuman: true}, "recovery.rollback")
+ if decision.Kind != DecisionRefused {
+ t.Fatalf("unpermitted recovery decision = %#v, want REFUSED", decision)
+ }
+}
+
+func TestUncontrollableEventCannotBeRequested(t *testing.T) {
+ // control-law: surfaces-cannot-assert-external-facts
+ s := New(catalog.Default())
+ decision := s.Resolve(snapshotFor(t, model.PhaseActive, model.TerminalNonterminal), goalFor(), catalog.AuthoritySet{catalog.AuthorityRepository: true}, "external.pr-merged")
+ if decision.Kind != DecisionRefused {
+ t.Fatalf("decision = %s, want REFUSED", decision.Kind)
+ }
+}
+
+func TestGuardDeniesDestructionAndRoutesManagedBypassThroughAdmission(t *testing.T) {
+ s := New(catalog.Default())
+ snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal)
+ destructive := s.Guard(snapshot, CommandIntent{Class: IntentDestructive, Operation: "git.reset-hard", Fingerprint: "fingerprint"})
+ if destructive.Allowed {
+ t.Fatal("destructive command was allowed")
+ }
+ managed := s.Guard(snapshot, CommandIntent{Class: IntentManagedBypass, Operation: "publication.push", Fingerprint: "fingerprint", Transition: "publication.execute"})
+ if managed.Allowed || managed.RequiredTransition != "publication.execute" {
+ t.Fatalf("managed bypass decision = %#v", managed)
+ }
+ ordinary := s.Guard(snapshot, CommandIntent{Class: IntentOrdinary, Operation: "repository.command", Fingerprint: "fingerprint"})
+ if !ordinary.Allowed {
+ t.Fatalf("ordinary operation was blocked: %#v", ordinary)
+ }
+}
diff --git a/boatstack/internal/plant/observer.go b/boatstack/internal/plant/observer.go
new file mode 100644
index 0000000..3bf8637
--- /dev/null
+++ b/boatstack/internal/plant/observer.go
@@ -0,0 +1,748 @@
+package plant
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+type TimeSource interface{ Now() time.Time }
+
+type Observer struct {
+ resolver Resolver
+ clock TimeSource
+}
+
+func NewObserver(resolver Resolver, clock TimeSource) (Observer, error) {
+ if clock == nil {
+ return Observer{}, fmt.Errorf("plant observer requires a clock")
+ }
+ return Observer{resolver: resolver, clock: clock}, nil
+}
+
+func hashBytes(value []byte) string {
+ digest := sha256.Sum256(value)
+ return hex.EncodeToString(digest[:])
+}
+
+func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) (model.Observation, error) {
+ invocation := request.Invocation
+ layout, current, err := o.resolver.ResolveLayout(ctx, invocation)
+ if err != nil {
+ return model.Observation{}, err
+ }
+ now := o.clock.Now().UTC()
+ state, stateEvidence, err := o.readState(layout.StatePath, current, now)
+ if err != nil {
+ return model.Observation{}, err
+ }
+ if state.RepositoryID != current.RepositoryID || state.GitCommonID != current.GitCommonID || state.WorktreeID != current.WorktreeID {
+ return model.Observation{}, fmt.Errorf("durable state identity does not match invocation")
+ }
+ gitEvidence, head, worktreeFingerprint, err := o.gitEvidence(ctx, layout.RepositoryRoot, now)
+ if err != nil {
+ return model.Observation{}, err
+ }
+ configEvidence, _, configExists, err := fileEvidence(layout.ConfigPath, "configuration", now)
+ if err != nil {
+ return model.Observation{}, err
+ }
+ configuration := state.Configuration
+ configurationPolicy := model.Absent[model.ConfigurationPolicy]("no valid V2 configuration policy", configEvidence)
+ if !configExists {
+ configuration = model.ConfigurationUnsupported
+ } else if configRaw, readErr := os.ReadFile(layout.ConfigPath); readErr != nil {
+ return model.Observation{}, readErr
+ } else if config, configFingerprint, decodeErr := protocol.ProjectConfigFingerprint(configRaw); decodeErr != nil {
+ configuration = model.ConfigurationConflicting
+ configurationPolicy = model.Unknown[model.ConfigurationPolicy](model.FactConflicting, decodeErr.Error(), configEvidence)
+ } else {
+ configEvidence.Fingerprint = configFingerprint
+ policy := config.ControlPolicy()
+ highRisk, highRiskErr := o.highRiskChange(ctx, layout.RepositoryRoot, config.Project.DefaultBranch, config.Project.HighRiskPaths)
+ if highRiskErr != nil && policy.IndependentReviewForHighRisk && len(config.Project.HighRiskPaths) > 0 {
+ highRisk = true
+ }
+ policy.HighRiskChange = highRisk
+ policyEvidence := append([]model.Evidence{configEvidence}, gitEvidence...)
+ configurationPolicy = model.Fact[model.ConfigurationPolicy]{Status: model.FactKnown, Value: policy, Evidence: policyEvidence}
+ if state.ConfigFingerprint == "" || state.ConfigFingerprint != configFingerprint {
+ configuration = model.ConfigurationStale
+ } else if state.Configuration == model.ConfigurationVerified && !sameConfigurationPolicy(state.ConfigurationPolicy(), policy) {
+ configuration = model.ConfigurationConflicting
+ }
+ }
+ runtimeState := state.Runtime
+ runtimeEvidence := append([]model.Evidence(nil), stateEvidence...)
+ if state.RuntimePath == "" {
+ 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
+ }
+ }
+ verification := state.Verification
+ if state.SourceRevision != "" && head != "" && state.SourceRevision != head {
+ verification = model.VerificationStale
+ }
+ if state.WorktreeFingerprint != "" && state.WorktreeFingerprint != worktreeFingerprint {
+ verification = model.VerificationStale
+ }
+ plan, artifactVerification, artifactTerminal, planEvidence, artifactEvidence, artifactErr := observeRepositoryArtifacts(layout, state, now)
+ if artifactErr != nil {
+ return model.Observation{}, artifactErr
+ }
+ if artifactVerification != state.Verification {
+ verification = artifactVerification
+ }
+ phase := state.Phase
+ delivery := state.Delivery
+ recoveryFact := model.Fact[model.RecoveryState]{Status: model.FactKnown, Value: state.Recovery, Evidence: stateEvidence}
+ transactionFact := model.Fact[model.TransactionState]{Status: model.FactKnown, Value: state.Transaction, Evidence: stateEvidence}
+ recoveryInfoFact := model.Absent[model.RecoveryContext]("no recovery context", stateEvidence...)
+ transactionInfoFact := model.Absent[model.TransactionContext]("no active transaction", stateEvidence...)
+ terminal := artifactTerminal
+ requiresCurrentImplementation := state.Goal.Kind == model.GoalVerified || state.Goal.Kind == model.GoalOpenPR
+ currentDeliveryInvalid := verification != model.VerificationCurrent || configuration != model.ConfigurationVerified || runtimeState != model.RuntimeVerified
+ if requiresCurrentImplementation && (terminal == model.TerminalStale || (terminal == model.TerminalEstablished && currentDeliveryInvalid)) {
+ terminal, phase, delivery = model.TerminalStale, model.PhaseActive, model.DeliveryActive
+ if runtimeState == model.RuntimeAbsent {
+ phase = model.PhaseObserved
+ }
+ } else if state.Goal.Kind == model.GoalApprovedPlan && terminal == model.TerminalStale {
+ phase, delivery = model.PhaseActive, model.DeliveryPlanning
+ }
+ if state.Goal.Kind == model.GoalMerged && state.Publication == model.PublicationMerged && state.Delivery == model.DeliveryTerminal &&
+ (state.Workspace == model.WorkspaceLanded || state.Workspace == model.WorkspaceAbsent) {
+ terminal, phase = model.TerminalEstablished, model.PhaseTerminal
+ }
+ if state.Goal.Kind == model.GoalAbandoned && state.Delivery == model.DeliveryDiscarded &&
+ (state.Workspace == model.WorkspaceAbandoned || state.Workspace == model.WorkspaceAbsent) {
+ terminal, phase = model.TerminalEstablished, model.PhaseAbandoned
+ }
+ if state.Recovery != model.RecoveryNone && state.TransactionID != "" {
+ recoveryInfoFact = model.Known(model.RecoveryContext{
+ TransactionID: state.TransactionID, Cause: state.RecoveryCause, SourcePhase: state.RecoverySourcePhase,
+ Permitted: []string{"recovery.escalate"}, BudgetRemaining: state.RecoveryBudget, Resumption: state.RecoveryResumption,
+ }, stateEvidence[0])
+ }
+ if state.Transaction != model.TransactionNone && state.TransactionID != "" {
+ transactionInfoFact = model.Known(model.TransactionContext{ID: state.TransactionID, TransitionID: state.TransactionTransition, Status: string(state.Transaction)}, stateEvidence[0])
+ }
+ pending, pendingErr := pendingJournalEvidence(layout.JournalRoot, request.IgnoreAdmissionID, now)
+ if pendingErr != nil {
+ return model.Observation{}, pendingErr
+ }
+ if pending.Conflicting {
+ evidence := append(append([]model.Evidence(nil), stateEvidence...), pending.Evidence...)
+ phase = model.PhaseUnresolved
+ recoveryFact = model.Unknown[model.RecoveryState](model.FactConflicting, "several interrupted transactions require explicit selection", evidence...)
+ transactionFact = model.Unknown[model.TransactionState](model.FactConflicting, "several interrupted transactions require explicit selection", evidence...)
+ recoveryInfoFact = model.Unknown[model.RecoveryContext](model.FactConflicting, "transaction identity is ambiguous", evidence...)
+ transactionInfoFact = model.Unknown[model.TransactionContext](model.FactConflicting, "transaction identity is ambiguous", evidence...)
+ terminal = model.TerminalStale
+ } else if pending.Found {
+ evidence := append(append([]model.Evidence(nil), stateEvidence...), pending.Evidence...)
+ phase = model.PhaseRecovery
+ recoveryFact = model.Fact[model.RecoveryState]{Status: model.FactKnown, Value: model.RecoveryReconcile, Evidence: evidence}
+ transactionFact = model.Fact[model.TransactionState]{Status: model.FactKnown, Value: pending.TransactionState, Evidence: evidence}
+ recoveryInfoFact = model.Fact[model.RecoveryContext]{Status: model.FactKnown, Value: pending.Recovery, Evidence: pending.Evidence}
+ transactionInfoFact = model.Fact[model.TransactionContext]{Status: model.FactKnown, Value: pending.Transaction, Evidence: pending.Evidence}
+ terminal = model.TerminalStale
+ }
+ deliveryEvidence := append(append([]model.Evidence(nil), stateEvidence...), gitEvidence...)
+ verificationEvidence := append(append([]model.Evidence(nil), deliveryEvidence...), artifactEvidence...)
+ planEvidence = append(append([]model.Evidence(nil), stateEvidence...), planEvidence...)
+ terminalEvidence := append(append([]model.Evidence(nil), stateEvidence...), artifactEvidence...)
+ configurationEvidence := stateEvidence
+ if configEvidence.Source != "" {
+ configurationEvidence = append(append([]model.Evidence(nil), stateEvidence...), configEvidence)
+ }
+ goalFact := model.Absent[model.Goal]("no configured V2 goal", stateEvidence...)
+ if state.Goal.Validate() == nil {
+ goalFact = model.Fact[model.Goal]{Status: model.FactKnown, Value: state.Goal, Evidence: stateEvidence}
+ }
+ return model.Observation{
+ SchemaVersion: model.SnapshotSchemaVersion, Invocation: current,
+ Phase: model.Fact[model.ProtocolPhase]{Status: model.FactKnown, Value: phase, Evidence: stateEvidence},
+ Engagement: model.Fact[model.EngagementState]{Status: model.FactKnown, Value: state.Engagement, Evidence: stateEvidence},
+ Delivery: model.Fact[model.DeliveryState]{Status: model.FactKnown, Value: delivery, Evidence: deliveryEvidence},
+ Workspace: model.Fact[model.WorkspaceState]{Status: model.FactKnown, Value: state.Workspace, Evidence: deliveryEvidence},
+ Plan: model.Fact[model.PlanState]{Status: model.FactKnown, Value: plan, Evidence: planEvidence},
+ Configuration: model.Fact[model.ConfigurationState]{Status: model.FactKnown, Value: configuration, Evidence: configurationEvidence},
+ ConfigurationPolicy: configurationPolicy,
+ Runtime: model.Fact[model.RuntimeState]{Status: model.FactKnown, Value: runtimeState, Evidence: runtimeEvidence},
+ Publication: model.Fact[model.PublicationState]{Status: model.FactKnown, Value: state.Publication, Evidence: stateEvidence},
+ Verification: model.Fact[model.VerificationState]{Status: model.FactKnown, Value: verification, Evidence: verificationEvidence},
+ Recovery: recoveryFact,
+ Transaction: transactionFact,
+ RecoveryInfo: recoveryInfoFact,
+ TransactionInfo: transactionInfoFact,
+ Terminal: model.Fact[model.TerminalStatus]{Status: model.FactKnown, Value: terminal, Evidence: terminalEvidence},
+ Goal: goalFact,
+ ObservedAt: now,
+ }, nil
+}
+
+func (o Observer) highRiskChange(ctx context.Context, repository, defaultBranch string, patterns []string) (bool, error) {
+ if len(patterns) == 0 {
+ return false, nil
+ }
+ changed := map[string]bool{}
+ base, err := o.resolver.git(ctx, repository, "rev-parse", "--verify", "--end-of-options", "refs/heads/"+defaultBranch+"^{commit}")
+ if err != nil {
+ return false, fmt.Errorf("resolve configured default branch: %w", err)
+ }
+ committed, err := o.resolver.git(ctx, repository, "diff", "--name-only", "--relative", "-z", base+"...HEAD")
+ if err != nil {
+ return false, err
+ }
+ for _, name := range strings.Split(committed, "\x00") {
+ if name != "" {
+ changed[filepath.ToSlash(name)] = true
+ }
+ }
+ status, err := o.resolver.git(ctx, repository, "status", "--porcelain=v1", "-z", "--untracked-files=all")
+ if err != nil {
+ return false, err
+ }
+ records := strings.Split(status, "\x00")
+ for index := 0; index < len(records); index++ {
+ record := records[index]
+ if len(record) < 4 {
+ continue
+ }
+ code, name := record[:2], record[3:]
+ changed[filepath.ToSlash(name)] = true
+ if (code[0] == 'R' || code[0] == 'C' || code[1] == 'R' || code[1] == 'C') && index+1 < len(records) {
+ index++
+ if prior := records[index]; prior != "" {
+ changed[filepath.ToSlash(prior)] = true
+ }
+ }
+ }
+ for name := range changed {
+ for _, pattern := range patterns {
+ matched, matchErr := doublestarMatch(filepath.ToSlash(pattern), name)
+ if matchErr != nil {
+ return false, matchErr
+ }
+ if matched {
+ return true, nil
+ }
+ }
+ }
+ return false, nil
+}
+
+func doublestarMatch(pattern, name string) (bool, error) {
+ if pattern == "" || strings.HasPrefix(pattern, "/") || strings.Contains(pattern, "..") {
+ return false, fmt.Errorf("invalid high-risk path pattern %q", pattern)
+ }
+ runes := []rune(filepath.ToSlash(pattern))
+ var expression strings.Builder
+ expression.WriteString("^(?:")
+ for index := 0; index < len(runes); index++ {
+ switch runes[index] {
+ case '*':
+ if index+1 < len(runes) && runes[index+1] == '*' {
+ index++
+ if index+1 < len(runes) && runes[index+1] == '/' {
+ index++
+ expression.WriteString(`(?:.*/)?`)
+ } else {
+ expression.WriteString(`.*`)
+ }
+ } else {
+ expression.WriteString(`[^/]*`)
+ }
+ case '?':
+ expression.WriteString(`[^/]`)
+ default:
+ expression.WriteString(regexp.QuoteMeta(string(runes[index])))
+ }
+ }
+ expression.WriteString(")$")
+ compiled, err := regexp.Compile(expression.String())
+ if err != nil {
+ return false, err
+ }
+ return compiled.MatchString(filepath.ToSlash(name)), nil
+}
+
+func (o Observer) readState(path string, invocation model.InvocationContext, now time.Time) (durable.State, []model.Evidence, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ fingerprint := hashBytes([]byte("absent:" + path + ":" + invocation.RepositoryID + ":" + invocation.WorktreeID))
+ evidence := []model.Evidence{{Source: path, Fingerprint: fingerprint, ObservedAt: now}}
+ return durable.Default(invocation, now), evidence, nil
+ }
+ return durable.State{}, nil, fmt.Errorf("read durable state: %w", err)
+ }
+ state, err := durable.DecodeState(raw)
+ if err != nil {
+ return durable.State{}, nil, fmt.Errorf("decode durable state: %w", err)
+ }
+ return state, []model.Evidence{{Source: path, Fingerprint: hashBytes(raw), ObservedAt: now}}, nil
+}
+
+func (o Observer) gitEvidence(ctx context.Context, repository string, now time.Time) ([]model.Evidence, string, string, error) {
+ head, err := o.resolver.git(ctx, repository, "rev-parse", "--verify", "HEAD^{commit}")
+ if err != nil {
+ return nil, "", "", fmt.Errorf("observe exact Git HEAD: %w", err)
+ }
+ if head == "" {
+ return nil, "", "", fmt.Errorf("observe exact Git HEAD: empty object identity")
+ }
+ status, err := o.resolver.git(ctx, repository, "status", "--porcelain=v1", "-z", "--untracked-files=all")
+ if err != nil {
+ return nil, "", "", fmt.Errorf("observe Git worktree status: %w", err)
+ }
+ status = canonicalProductStatus(status)
+ fingerprint := hashBytes([]byte(head + "\x00" + status))
+ return []model.Evidence{{Source: "git:" + repository, Fingerprint: fingerprint, Revision: head, ObservedAt: now}}, head, fingerprint, nil
+}
+
+func canonicalProductStatus(status string) string {
+ records := strings.Split(status, "\x00")
+ kept := make([]string, 0, len(records))
+ for index := 0; index < len(records); index++ {
+ record := records[index]
+ if len(record) < 4 {
+ continue
+ }
+ code, name := record[:2], filepath.ToSlash(record[3:])
+ prior := ""
+ if (code[0] == 'R' || code[0] == 'C' || code[1] == 'R' || code[1] == 'C') && index+1 < len(records) {
+ index++
+ prior = filepath.ToSlash(records[index])
+ }
+ if generatedBoatstackPath(name) && (prior == "" || generatedBoatstackPath(prior)) {
+ continue
+ }
+ kept = append(kept, code+" "+name)
+ if prior != "" {
+ kept = append(kept, "prior "+prior)
+ }
+ }
+ sort.Strings(kept)
+ return strings.Join(kept, "\x00")
+}
+
+func generatedBoatstackPath(name string) bool {
+ name = strings.TrimPrefix(filepath.ToSlash(name), "./")
+ for _, prefix := range []string{".boatstack/approvals/", ".boatstack/evidence/", ".boatstack/plans/", ".boatstack/publication/"} {
+ if strings.HasPrefix(name, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func fileEvidence(path, source string, now time.Time) (model.Evidence, string, bool, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ fingerprint := hashBytes([]byte("absent:" + path))
+ return model.Evidence{Source: source + ":" + path, Fingerprint: fingerprint, ObservedAt: now}, fingerprint, false, nil
+ }
+ return model.Evidence{}, "", false, err
+ }
+ fingerprint := hashBytes(raw)
+ return model.Evidence{Source: source + ":" + path, Fingerprint: fingerprint, ObservedAt: now}, fingerprint, true, nil
+}
+
+func sameConfigurationPolicy(one, two model.ConfigurationPolicy) bool {
+ one, two = one.Canonical(), two.Canonical()
+ return one.PlanApproval == two.PlanApproval &&
+ one.IndependentReviewForHighRisk == two.IndependentReviewForHighRisk &&
+ one.VisualEvidence == two.VisualEvidence &&
+ one.ExternalEffectAuthority == two.ExternalEffectAuthority &&
+ slices.Equal(one.Hosts, two.Hosts)
+}
+
+type observedApproval struct {
+ SchemaVersion int `json:"schema_version"`
+ DeliveryID string `json:"delivery_id"`
+ PlanFingerprint string `json:"plan_fingerprint"`
+ Actor string `json:"actor"`
+ AdmissionID string `json:"admission_id"`
+ ApprovedAt time.Time `json:"approved_at"`
+}
+
+type observedGate struct {
+ SchemaVersion int `json:"schema_version"`
+ DeliveryID string `json:"delivery_id"`
+ TransitionID string `json:"transition_id"`
+ Revision string `json:"revision"`
+ Fingerprint string `json:"fingerprint"`
+ AdmissionID string `json:"admission_id"`
+ RecordedAt time.Time `json:"recorded_at"`
+}
+
+type observedGateEvidence struct {
+ SchemaVersion int `json:"schema_version"`
+ Gate string `json:"gate"`
+ SourceRevision string `json:"source_revision"`
+ Outcome string `json:"outcome"`
+ Producer string `json:"producer"`
+ CompletedAt time.Time `json:"completed_at"`
+}
+
+func decodeStrictJSON[T any](raw []byte, value *T) error {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(value); err != nil {
+ return err
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ return fmt.Errorf("artifact contains trailing JSON")
+ }
+ return nil
+}
+
+func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.State, now time.Time) (model.PlanState, model.VerificationState, model.TerminalStatus, []model.Evidence, []model.Evidence, error) {
+ plan, verification, terminal := state.Plan, state.Verification, state.Terminal
+ var planEvidence, verificationEvidence []model.Evidence
+ if state.Goal.Validate() != nil {
+ return plan, verification, terminal, planEvidence, verificationEvidence, nil
+ }
+ deliveryID := state.Goal.DeliveryID
+ if state.Plan != model.PlanAbsent {
+ path := filepath.Join(layout.RepositoryRoot, ".boatstack", "plans", deliveryID+".source")
+ evidence, fingerprint, exists, err := fileEvidence(path, "plan", now)
+ if err != nil {
+ return plan, verification, terminal, nil, nil, err
+ }
+ planEvidence = append(planEvidence, evidence)
+ if !exists || state.PlanFingerprint == "" || fingerprint != state.PlanFingerprint {
+ plan, terminal = model.PlanStale, model.TerminalStale
+ }
+ }
+ if state.Plan == model.PlanApproved || state.Plan == model.PlanLocked {
+ path := filepath.Join(layout.RepositoryRoot, ".boatstack", "approvals", deliveryID+".json")
+ evidence, _, exists, err := fileEvidence(path, "approval", now)
+ if err != nil {
+ return plan, verification, terminal, nil, nil, err
+ }
+ planEvidence = append(planEvidence, evidence)
+ valid := exists
+ if exists {
+ raw, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return plan, verification, terminal, nil, nil, readErr
+ }
+ var approval observedApproval
+ valid = decodeStrictJSON(raw, &approval) == nil && approval.SchemaVersion == 1 &&
+ approval.DeliveryID == deliveryID && approval.PlanFingerprint == state.PlanFingerprint &&
+ approval.Actor != "" && approval.AdmissionID != "" && !approval.ApprovedAt.IsZero()
+ }
+ if !valid {
+ plan, terminal = model.PlanStale, model.TerminalStale
+ }
+ }
+
+ gateNames := map[string]string{
+ "build": "gate.build.record", "test": "gate.test.record", "review": "gate.review.record",
+ "change": "gate.change.record", "journey": "gate.journey.record",
+ }
+ hasVisual := false
+ for _, gate := range state.Gates {
+ if gate.Gate == "visual" {
+ hasVisual = true
+ path := filepath.Join(layout.EvidenceRoot, deliveryID, "visual-manifest.json")
+ evidence, fingerprint, exists, err := fileEvidence(path, "visual-evidence", now)
+ if err != nil {
+ return plan, verification, terminal, nil, nil, err
+ }
+ verificationEvidence = append(verificationEvidence, evidence)
+ if !exists || fingerprint != gate.Fingerprint || gate.Revision == "" {
+ verification, terminal = model.VerificationStale, model.TerminalStale
+ }
+ continue
+ }
+ transitionID, known := gateNames[gate.Gate]
+ path := filepath.Join(layout.EvidenceRoot, deliveryID, gate.Gate+".json")
+ evidence, _, exists, err := fileEvidence(path, "gate-evidence", now)
+ if err != nil {
+ return plan, verification, terminal, nil, nil, err
+ }
+ verificationEvidence = append(verificationEvidence, evidence)
+ valid := known && exists
+ if valid {
+ raw, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return plan, verification, terminal, nil, nil, readErr
+ }
+ var artifact observedGate
+ valid = decodeStrictJSON(raw, &artifact) == nil && artifact.SchemaVersion == 1 &&
+ artifact.DeliveryID == deliveryID && artifact.TransitionID == transitionID &&
+ artifact.Revision == gate.Revision && artifact.Fingerprint == gate.Fingerprint &&
+ artifact.AdmissionID != "" && !artifact.RecordedAt.IsZero()
+ }
+ payloadPath := filepath.Join(layout.EvidenceRoot, deliveryID, gate.Gate+".evidence.json")
+ payloadEvidence, payloadFingerprint, payloadExists, payloadErr := fileEvidence(payloadPath, "gate-payload", now)
+ if payloadErr != nil {
+ return plan, verification, terminal, nil, nil, payloadErr
+ }
+ verificationEvidence = append(verificationEvidence, payloadEvidence)
+ if payloadExists {
+ payloadRaw, readErr := os.ReadFile(payloadPath)
+ if readErr != nil {
+ return plan, verification, terminal, nil, nil, readErr
+ }
+ var payload observedGateEvidence
+ valid = valid && payloadFingerprint == gate.Fingerprint && decodeStrictJSON(payloadRaw, &payload) == nil &&
+ payload.SchemaVersion == 1 && payload.Gate == gate.Gate && payload.SourceRevision == gate.Revision &&
+ payload.Outcome == "passed" && payload.Producer != "" && !payload.CompletedAt.IsZero()
+ } else {
+ valid = false
+ }
+ if !valid {
+ verification, terminal = model.VerificationStale, model.TerminalStale
+ }
+ }
+ if terminal == model.TerminalEstablished && state.Goal.Kind == model.GoalVerified && state.VisualEvidencePolicy == "required" && !hasVisual {
+ verification, terminal = model.VerificationUnresolved, model.TerminalStale
+ }
+ return plan, verification, terminal, planEvidence, verificationEvidence, nil
+}
+
+type pendingJournalHeader struct {
+ SchemaVersion int `json:"schema_version"`
+ TransitionID string `json:"transition_id"`
+ TransitionClass string `json:"transition_class"`
+ Status string `json:"status"`
+ Reason string `json:"reason"`
+ Admission struct {
+ ID string `json:"id"`
+ SourcePhase model.ProtocolPhase `json:"source_phase"`
+ Invocation model.InvocationContext `json:"invocation"`
+ Parameters protocol.Parameters `json:"parameters"`
+ } `json:"admission"`
+ Mutations []struct {
+ Path string `json:"path"`
+ Prior []byte `json:"prior"`
+ Target []byte `json:"target"`
+ } `json:"mutations"`
+}
+
+type pendingJournalSet struct {
+ Found bool
+ Conflicting bool
+ Evidence []model.Evidence
+ Recovery model.RecoveryContext
+ Transaction model.TransactionContext
+ TransactionState model.TransactionState
+}
+
+type pendingJournalRecord struct {
+ header pendingJournalHeader
+ set pendingJournalSet
+}
+
+func pendingJournalEvidence(root, ignoreAdmissionID string, now time.Time) (pendingJournalSet, error) {
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return pendingJournalSet{}, nil
+ }
+ return pendingJournalSet{}, err
+ }
+ recoveryAttempts := map[string]int{}
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".aborted" {
+ continue
+ }
+ raw, readErr := os.ReadFile(filepath.Join(root, entry.Name()))
+ if readErr != nil {
+ return pendingJournalSet{}, readErr
+ }
+ var header pendingJournalHeader
+ if json.Unmarshal(raw, &header) != nil || header.SchemaVersion != 2 || header.TransitionClass != string(catalog.EventRecovery) {
+ continue
+ }
+ if transactionID, ok := header.Admission.Parameters.Get("transaction_id"); ok {
+ recoveryAttempts[transactionID]++
+ }
+ }
+ groups := map[string][]pendingJournalRecord{}
+ for _, entry := range entries {
+ if !entry.IsDir() && filepath.Ext(entry.Name()) == ".pending" {
+ path := filepath.Join(root, entry.Name())
+ raw, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return pendingJournalSet{}, readErr
+ }
+ evidence := model.Evidence{Source: path, Fingerprint: hashBytes(raw), ObservedAt: now}
+ var header pendingJournalHeader
+ if err := json.Unmarshal(raw, &header); err != nil {
+ return pendingJournalSet{}, err
+ }
+ class := catalog.EventClass(header.TransitionClass)
+ if header.SchemaVersion != 2 || header.Admission.ID == "" || entry.Name() != header.Admission.ID+".pending" || header.TransitionID == "" || header.Status == "" || !class.Valid() || !class.Controllable() {
+ return pendingJournalSet{}, fmt.Errorf("invalid pending transaction journal %s", path)
+ }
+ if header.Admission.ID == ignoreAdmissionID {
+ continue
+ }
+ resourceDigests := make([]string, 0, len(header.Mutations))
+ for _, mutation := range header.Mutations {
+ resourceDigests = append(resourceDigests, hashBytes([]byte(mutation.Path+"\x00"+hashBytes(mutation.Prior)+"\x00"+hashBytes(mutation.Target))))
+ }
+ sort.Strings(resourceDigests)
+ external := header.TransitionClass == "owned-external"
+ transactionState := model.TransactionStaged
+ switch header.Status {
+ case "verifying":
+ transactionState = model.TransactionVerifying
+ case "recovery-required":
+ if external {
+ transactionState = model.TransactionExternalUncertain
+ } else {
+ transactionState = model.TransactionLocalApplied
+ }
+ }
+ budget := 3 - recoveryAttempts[header.Admission.ID]
+ if budget < 0 {
+ budget = 0
+ }
+ permitted := recoveryContract(header.TransitionID, external, budget)
+ cause := header.Reason
+ if cause == "" {
+ cause = "process ended before transition receipt"
+ }
+ set := pendingJournalSet{
+ Found: true, Evidence: []model.Evidence{evidence}, TransactionState: transactionState,
+ Recovery: model.RecoveryContext{TransactionID: header.Admission.ID, Cause: cause, SourcePhase: header.Admission.SourcePhase, Permitted: permitted, BudgetRemaining: budget, Resumption: header.Admission.SourcePhase},
+ Transaction: model.TransactionContext{ID: header.Admission.ID, TransitionID: header.TransitionID, Status: header.Status, ResourceDigests: resourceDigests, ExternalPossible: external},
+ }
+ rootID := header.Admission.ID
+ if class == catalog.EventRecovery {
+ parent, ok := header.Admission.Parameters.Get("transaction_id")
+ if !ok {
+ return pendingJournalSet{}, fmt.Errorf("recovery journal %s has no interrupted transaction identity", path)
+ }
+ rootID = parent
+ }
+ groups[rootID] = append(groups[rootID], pendingJournalRecord{header: header, set: set})
+ }
+ }
+ if len(groups) == 0 {
+ return pendingJournalSet{}, nil
+ }
+ if len(groups) == 1 {
+ for rootID, records := range groups {
+ var root *pendingJournalRecord
+ var attempts []pendingJournalRecord
+ for index := range records {
+ if catalog.EventClass(records[index].header.TransitionClass) == catalog.EventRecovery {
+ attempts = append(attempts, records[index])
+ } else if root != nil {
+ return conflictingPending(records), nil
+ } else {
+ root = &records[index]
+ }
+ }
+ if len(attempts) == 0 && root != nil {
+ return root.set, nil
+ }
+ // An interrupted recovery attempt may have partially changed the
+ // original resources. Preserve the group and permit only escalation;
+ // a fresh recovery attempt closes every older journal in the group.
+ base := attempts[0].set
+ if root != nil {
+ base = root.set
+ }
+ base.Evidence = nil
+ base.Transaction.ResourceDigests = nil
+ for _, record := range records {
+ base.Evidence = append(base.Evidence, record.set.Evidence...)
+ base.Transaction.ResourceDigests = append(base.Transaction.ResourceDigests, record.set.Transaction.ResourceDigests...)
+ }
+ sort.Strings(base.Transaction.ResourceDigests)
+ budget := 3 - recoveryAttempts[rootID] - len(attempts)
+ if budget < 0 {
+ budget = 0
+ }
+ base.Found, base.Conflicting = true, false
+ base.Recovery.TransactionID = rootID
+ base.Recovery.Cause = "a recovery attempt was interrupted; the transaction group requires escalation"
+ base.Recovery.Permitted = []string{"recovery.escalate"}
+ base.Recovery.BudgetRemaining = budget
+ base.Transaction.ID = rootID
+ base.Transaction.Status = "nested-recovery-interrupted"
+ base.TransactionState = model.TransactionLocalApplied
+ if root != nil && root.set.Transaction.ExternalPossible {
+ base.TransactionState = model.TransactionExternalUncertain
+ }
+ return base, nil
+ }
+ }
+ result := pendingJournalSet{Conflicting: true}
+ for _, records := range groups {
+ for _, record := range records {
+ result.Evidence = append(result.Evidence, record.set.Evidence...)
+ }
+ }
+ return result, nil
+}
+
+func conflictingPending(records []pendingJournalRecord) pendingJournalSet {
+ result := pendingJournalSet{Conflicting: true}
+ for _, record := range records {
+ result.Evidence = append(result.Evidence, record.set.Evidence...)
+ }
+ return result
+}
+
+func recoveryContract(transitionID string, external bool, budget int) []string {
+ if budget == 0 {
+ return []string{"recovery.escalate"}
+ }
+ if external {
+ return []string{"publication.reconcile", "recovery.escalate"}
+ }
+ switch transitionID {
+ case "runtime.hydrate", "runtime.replace", "installation.update", "installation.initialize":
+ return []string{"runtime.reconcile", "recovery.rollback", "recovery.escalate"}
+ case "configuration.initialize", "configuration.mutate":
+ return []string{"configuration.reconcile", "recovery.rollback", "recovery.escalate"}
+ case "workspace.cut":
+ return []string{"workspace.reconcile", "recovery.escalate"}
+ case "workspace.cleanup", "workspace.reap":
+ return []string{"recovery.escalate"}
+ default:
+ return []string{"recovery.resume", "recovery.rollback", "recovery.escalate"}
+ }
+}
diff --git a/boatstack/internal/plant/observer_test.go b/boatstack/internal/plant/observer_test.go
new file mode 100644
index 0000000..a1b7230
--- /dev/null
+++ b/boatstack/internal/plant/observer_test.go
@@ -0,0 +1,292 @@
+package plant
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+)
+
+type observerClock struct{ now time.Time }
+
+func (c observerClock) Now() time.Time { return c.now }
+
+func runGit(t *testing.T, directory string, arguments ...string) {
+ t.Helper()
+ command := exec.Command("git", append([]string{"-C", directory}, arguments...)...)
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", arguments, err, output)
+ }
+}
+
+func TestObserverBindsVerifiedRuntimeToExecutingBinary(t *testing.T) {
+ // control-law: stale-runtime-selection-cannot-authorize-managed-effects
+ repository := t.TempDir()
+ runGit(t, repository, "init", "-q")
+ runGit(t, repository, "config", "user.email", "boatstack@example.invalid")
+ runGit(t, repository, "config", "user.name", "Boatstack Test")
+ if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repository, "add", "README.md")
+ runGit(t, repository, "commit", "-q", "-m", "fixture")
+
+ resolver, err := NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "runtime-binding")
+ if err != nil {
+ t.Fatal(err)
+ }
+ layout, _, err := resolver.ResolveLayout(context.Background(), invocation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ state := durable.Default(invocation, time.Unix(100, 0).UTC())
+ state.Runtime = model.RuntimeVerified
+ state.RuntimePath = invocation.RuntimePath
+ state.RuntimeFingerprint = invocation.RuntimeFingerprint
+ state.RuntimeSource = "fixture-revision"
+ raw, err := durable.EncodeState(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(layout.StatePath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ observer, err := NewObserver(resolver, observerClock{now: time.Unix(200, 0).UTC()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ observed, err := observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if observed.Runtime.Value != model.RuntimeVerified {
+ t.Fatalf("matching executing runtime observed as %s", observed.Runtime.Value)
+ }
+
+ state.RuntimePath = filepath.Join(t.TempDir(), "stale-runtime")
+ if err := os.WriteFile(state.RuntimePath, mustRead(t, invocation.RuntimePath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ raw, err = durable.EncodeState(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(layout.StatePath, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ 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)
+ }
+}
+
+func TestDoubleStarMatchesRootAndNestedPaths(t *testing.T) {
+ for _, test := range []struct {
+ pattern string
+ name string
+ want bool
+ }{
+ {pattern: "**/*.go", name: "main.go", want: true},
+ {pattern: "**/*.go", name: "internal/kernel/main.go", want: true},
+ {pattern: "migrations/**", name: "migrations/001.sql", want: true},
+ {pattern: "migrations/**", name: "docs/migrations/001.sql", want: false},
+ } {
+ got, err := doublestarMatch(test.pattern, test.name)
+ if err != nil {
+ t.Fatalf("match %q against %q: %v", test.pattern, test.name, err)
+ }
+ if got != test.want {
+ t.Fatalf("match %q against %q = %v, want %v", test.pattern, test.name, got, test.want)
+ }
+ }
+}
+
+func TestObserverDerivesHighRiskChangeFromCommittedAndWorkingTreePaths(t *testing.T) {
+ repository := t.TempDir()
+ runGit(t, repository, "init", "-q")
+ runGit(t, repository, "config", "user.email", "boatstack@example.invalid")
+ runGit(t, repository, "config", "user.name", "Boatstack Test")
+ if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repository, "add", "README.md")
+ runGit(t, repository, "commit", "-q", "-m", "fixture")
+ runGit(t, repository, "branch", "-M", "main")
+ runGit(t, repository, "checkout", "-q", "-b", "feature")
+ if err := os.MkdirAll(filepath.Join(repository, "migrations"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repository, "migrations", "001.sql"), []byte("select 1;\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, repository, "add", "migrations/001.sql")
+ runGit(t, repository, "commit", "-q", "-m", "migration")
+
+ resolver, err := NewResolver(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ observer, err := NewObserver(resolver, observerClock{now: time.Unix(200, 0).UTC()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ highRisk, err := observer.highRiskChange(context.Background(), repository, "main", []string{"migrations/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !highRisk {
+ t.Fatal("committed high-risk path was not derived")
+ }
+
+ if err := os.MkdirAll(filepath.Join(repository, "billing"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(repository, "billing", "rate plan.json"), []byte("{}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ highRisk, err = observer.highRiskChange(context.Background(), repository, "main", []string{"billing/**"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !highRisk {
+ t.Fatal("untracked high-risk path containing spaces was not derived")
+ }
+}
+
+func TestProductFingerprintExcludesGeneratedProofButIncludesConfiguration(t *testing.T) {
+ status := strings.Join([]string{
+ "?? .boatstack/evidence/delivery/build.json",
+ " M .boatstack/project.json",
+ " M src/main.go",
+ "",
+ }, "\x00")
+ canonical := canonicalProductStatus(status)
+ if strings.Contains(canonical, ".boatstack/evidence/") {
+ t.Fatalf("generated evidence remained in product fingerprint: %q", canonical)
+ }
+ for _, required := range []string{".boatstack/project.json", "src/main.go"} {
+ if !strings.Contains(canonical, required) {
+ t.Fatalf("product fingerprint omitted %s: %q", required, canonical)
+ }
+ }
+}
+
+func TestRecoveryAttemptsExhaustToEscalationOnly(t *testing.T) {
+ // control-law: recovery-retry-budget-is-derived-and-finite-across-restarts
+ root := t.TempDir()
+ originalID := "adm-interrupted"
+ pending := map[string]any{
+ "schema_version": 2,
+ "transition_id": "plan.create",
+ "transition_class": "owned-local",
+ "status": "recovery-required",
+ "reason": "simulated interruption",
+ "admission": map[string]any{
+ "id": originalID,
+ "source_phase": "ACTIVE",
+ "invocation": map[string]any{"correlation_id": "prior-process"},
+ },
+ }
+ writeJSON := func(name string, value any) {
+ t.Helper()
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, name), raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ writeJSON(originalID+".pending", pending)
+ for attempt := 1; attempt <= 3; attempt++ {
+ aborted := map[string]any{
+ "schema_version": 2,
+ "transition_id": "recovery.rollback",
+ "transition_class": "recovery",
+ "status": "aborted",
+ "admission": map[string]any{
+ "id": "adm-recovery-attempt",
+ "parameters": []map[string]string{{"name": "transaction_id", "value": originalID}},
+ },
+ }
+ writeJSON("attempt-"+string(rune('0'+attempt))+".aborted", aborted)
+ observed, err := pendingJournalEvidence(root, "new-process", time.Unix(500, 0).UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantBudget := 3 - attempt
+ if observed.Recovery.BudgetRemaining != wantBudget {
+ t.Fatalf("attempt %d budget=%d, want %d", attempt, observed.Recovery.BudgetRemaining, wantBudget)
+ }
+ if attempt == 3 {
+ if len(observed.Recovery.Permitted) != 1 || observed.Recovery.Permitted[0] != "recovery.escalate" {
+ t.Fatalf("exhausted recovery permitted=%v, want escalation only", observed.Recovery.Permitted)
+ }
+ }
+ }
+}
+
+func TestInterruptedRecoveryAttemptCollapsesToEscalatableTransactionGroup(t *testing.T) {
+ // control-law: recovery-of-recovery-does-not-create-an-unselectable-conflict
+ root := t.TempDir()
+ write := func(name string, value any) {
+ t.Helper()
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, name), raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ originalID := "adm-original"
+ write(originalID+".pending", map[string]any{
+ "schema_version": 2, "transition_id": "plan.create", "transition_class": "owned-local", "status": "recovery-required",
+ "admission": map[string]any{"id": originalID, "source_phase": "ACTIVE", "invocation": map[string]any{"correlation_id": "old-process"}},
+ })
+ write("adm-nested.pending", map[string]any{
+ "schema_version": 2, "transition_id": "recovery.rollback", "transition_class": "recovery", "status": "verifying",
+ "admission": map[string]any{
+ "id": "adm-nested", "source_phase": "RECOVERY", "invocation": map[string]any{"correlation_id": "old-process"},
+ "parameters": []map[string]string{{"name": "transaction_id", "value": originalID}},
+ },
+ })
+ observed, err := pendingJournalEvidence(root, "restart", time.Unix(600, 0).UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if observed.Conflicting || !observed.Found || observed.Recovery.TransactionID != originalID {
+ t.Fatalf("nested recovery was not grouped: %#v", observed)
+ }
+ if observed.Recovery.BudgetRemaining != 2 || len(observed.Recovery.Permitted) != 1 || observed.Recovery.Permitted[0] != "recovery.escalate" {
+ t.Fatalf("nested recovery contract=%#v, want budget 2 escalation-only", observed.Recovery)
+ }
+}
+
+func mustRead(t *testing.T, path string) []byte {
+ t.Helper()
+ value, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return value
+}
diff --git a/boatstack/internal/plant/resolver.go b/boatstack/internal/plant/resolver.go
new file mode 100644
index 0000000..b446664
--- /dev/null
+++ b/boatstack/internal/plant/resolver.go
@@ -0,0 +1,291 @@
+package plant
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/durable"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports"
+)
+
+const stateRootEnvironment = "BOATSTACK_STATE_ROOT"
+
+type CommandRunner interface {
+ Output(context.Context, string, ...string) ([]byte, error)
+}
+
+type execRunner struct{}
+
+func (execRunner) Output(ctx context.Context, name string, arguments ...string) ([]byte, error) {
+ return exec.CommandContext(ctx, name, arguments...).Output()
+}
+
+type Resolver struct {
+ externalRoot string
+ runtimePath string
+ runtimeFingerprint string
+ runner CommandRunner
+}
+
+func NewResolver(externalBase string) (Resolver, error) {
+ root, err := externalStateRoot(externalBase)
+ if err != nil {
+ return Resolver{}, err
+ }
+ runtimePath, err := os.Executable()
+ if err != nil {
+ return Resolver{}, fmt.Errorf("resolve executing runtime: %w", err)
+ }
+ runtimePath, err = filepath.Abs(runtimePath)
+ if err != nil {
+ return Resolver{}, fmt.Errorf("resolve executing runtime path: %w", err)
+ }
+ if resolved, resolveErr := filepath.EvalSymlinks(runtimePath); resolveErr == nil {
+ runtimePath = resolved
+ }
+ runtimeRaw, err := os.ReadFile(runtimePath)
+ 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
+}
+
+func NewResolverWithRunner(externalBase string, runner CommandRunner) (Resolver, error) {
+ resolver, err := NewResolver(externalBase)
+ if err != nil {
+ return Resolver{}, err
+ }
+ if runner == nil {
+ return Resolver{}, fmt.Errorf("plant resolver requires a command runner")
+ }
+ resolver.runner = runner
+ return resolver, nil
+}
+
+func externalStateRoot(explicit string) (string, error) {
+ if explicit == "" {
+ explicit = strings.TrimSpace(os.Getenv(stateRootEnvironment))
+ }
+ if explicit != "" {
+ absolute, err := filepath.Abs(explicit)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(absolute, "boatstack", "v2"), nil
+ }
+ if runtime.GOOS == "windows" {
+ if base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); base != "" {
+ return filepath.Join(base, "boatstack", "v2"), nil
+ }
+ }
+ if runtime.GOOS == "darwin" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, "Library", "Application Support", "boatstack", "v2"), nil
+ }
+ if base := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); base != "" {
+ return filepath.Join(base, "boatstack", "v2"), nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".local", "state", "boatstack", "v2"), nil
+}
+
+func canonicalExisting(path string) (string, error) {
+ absolute, err := filepath.Abs(path)
+ if err != nil {
+ return "", err
+ }
+ resolved, err := filepath.EvalSymlinks(absolute)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Clean(resolved), nil
+}
+
+func digest(value string, width int) string {
+ sum := sha256.Sum256([]byte(value))
+ encoded := hex.EncodeToString(sum[:])
+ if width > 0 && width < len(encoded) {
+ return encoded[:width]
+ }
+ return encoded
+}
+
+func normalizeOrigin(value string) string {
+ value = strings.TrimSpace(strings.TrimSuffix(value, ".git"))
+ for _, prefix := range []string{"https://", "http://", "ssh://", "git+ssh://"} {
+ value = strings.TrimPrefix(value, prefix)
+ }
+ value = strings.TrimPrefix(value, "git@")
+ value = strings.ReplaceAll(value, ":", "/")
+ return strings.ToLower(strings.Trim(value, "/"))
+}
+
+func (r Resolver) git(ctx context.Context, path string, arguments ...string) (string, error) {
+ output, err := r.runner.Output(ctx, "git", append([]string{"-C", path}, arguments...)...)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(output)), nil
+}
+
+func (r Resolver) ResolveInvocation(ctx context.Context, path, host, correlation string) (model.InvocationContext, error) {
+ if strings.TrimSpace(path) == "" || strings.TrimSpace(host) == "" || strings.TrimSpace(correlation) == "" {
+ return model.InvocationContext{}, fmt.Errorf("repository path, host, and correlation are required")
+ }
+ invokingPath, err := canonicalExisting(path)
+ if err != nil {
+ return model.InvocationContext{}, fmt.Errorf("resolve invoking path: %w", err)
+ }
+ rootValue, err := r.git(ctx, invokingPath, "rev-parse", "--show-toplevel")
+ if err != nil {
+ return model.InvocationContext{}, fmt.Errorf("resolve repository: %w", err)
+ }
+ repositoryRoot, err := canonicalExisting(rootValue)
+ if err != nil {
+ return model.InvocationContext{}, err
+ }
+ commonValue, err := r.git(ctx, repositoryRoot, "rev-parse", "--path-format=absolute", "--git-common-dir")
+ if err != nil {
+ return model.InvocationContext{}, fmt.Errorf("resolve git common identity: %w", err)
+ }
+ gitCommon, err := canonicalExisting(commonValue)
+ if err != nil {
+ return model.InvocationContext{}, err
+ }
+ gitDirValue, err := r.git(ctx, repositoryRoot, "rev-parse", "--path-format=absolute", "--git-dir")
+ if err != nil {
+ return model.InvocationContext{}, fmt.Errorf("resolve worktree identity: %w", err)
+ }
+ if _, err := canonicalExisting(gitDirValue); err != nil {
+ return model.InvocationContext{}, err
+ }
+ origin, _ := r.git(ctx, repositoryRoot, "remote", "get-url", "origin")
+ initial, _ := r.git(ctx, repositoryRoot, "rev-list", "--max-parents=0", "HEAD")
+ seed := normalizeOrigin(origin)
+ if initial != "" {
+ seed += "@" + strings.Split(initial, "\n")[0]
+ }
+ if seed == "" {
+ seed = "git-common:" + gitCommon
+ }
+ repositoryID := digest(seed, 16)
+ gitCommonID := digest(gitCommon, 16)
+ worktreeID, err := model.DeriveWorktreeID(gitCommonID, repositoryRoot)
+ if err != nil {
+ return model.InvocationContext{}, err
+ }
+ ref, err := r.git(ctx, repositoryRoot, "symbolic-ref", "-q", "HEAD")
+ if err != nil || ref == "" {
+ head, headErr := r.git(ctx, repositoryRoot, "rev-parse", "HEAD")
+ if headErr != nil {
+ return model.InvocationContext{}, fmt.Errorf("resolve git ref: %w", headErr)
+ }
+ ref = "detached:" + head
+ }
+
+ topology := model.TopologyEmbedded
+ controllerID := digest("embedded:"+repositoryID+":"+gitCommonID, 20)
+ bindingPath := filepath.Join(r.externalRoot, "repositories", repositoryID, gitCommonID, "binding.json")
+ if raw, readErr := os.ReadFile(bindingPath); readErr == nil {
+ binding, decodeErr := durable.DecodeBinding(raw)
+ if decodeErr != nil {
+ return model.InvocationContext{}, fmt.Errorf("decode external binding: %w", decodeErr)
+ }
+ if binding.RepositoryID != repositoryID || binding.GitCommonID != gitCommonID {
+ return model.InvocationContext{}, fmt.Errorf("external binding identity conflicts with invoking repository")
+ }
+ topology, controllerID = binding.Topology, binding.ControllerID
+ } else if !os.IsNotExist(readErr) {
+ return model.InvocationContext{}, fmt.Errorf("read external binding: %w", readErr)
+ }
+ invocation := model.InvocationContext{
+ RepositoryID: repositoryID, GitCommonID: gitCommonID, WorktreeID: worktreeID, Ref: ref,
+ ControllerID: controllerID, InvokingPath: invokingPath, RuntimePath: r.runtimePath, RuntimeFingerprint: r.runtimeFingerprint,
+ Topology: topology, Host: host, Correlation: correlation,
+ }
+ if err := invocation.Validate(true); err != nil {
+ return model.InvocationContext{}, err
+ }
+ return invocation, nil
+}
+
+func (r Resolver) ResolveLayout(ctx context.Context, invocation model.InvocationContext) (ports.ControllerLayout, model.InvocationContext, error) {
+ current, err := r.ResolveInvocation(ctx, invocation.InvokingPath, invocation.Host, invocation.Correlation)
+ if err != nil {
+ return ports.ControllerLayout{}, model.InvocationContext{}, err
+ }
+ if current.RepositoryID != invocation.RepositoryID || current.GitCommonID != invocation.GitCommonID || current.WorktreeID != invocation.WorktreeID {
+ return ports.ControllerLayout{}, current, fmt.Errorf("invocation identity no longer resolves to the same repository and worktree")
+ }
+ repositoryRootValue, err := r.git(ctx, current.InvokingPath, "rev-parse", "--show-toplevel")
+ if err != nil {
+ return ports.ControllerLayout{}, current, err
+ }
+ repositoryRoot, err := canonicalExisting(repositoryRootValue)
+ if err != nil {
+ return ports.ControllerLayout{}, current, err
+ }
+ commonValue, err := r.git(ctx, repositoryRoot, "rev-parse", "--path-format=absolute", "--git-common-dir")
+ if err != nil {
+ return ports.ControllerLayout{}, current, err
+ }
+ commonRoot, err := canonicalExisting(commonValue)
+ if err != nil {
+ return ports.ControllerLayout{}, current, err
+ }
+ gitDirValue, err := r.git(ctx, repositoryRoot, "rev-parse", "--path-format=absolute", "--git-dir")
+ if err != nil {
+ return ports.ControllerLayout{}, current, err
+ }
+ if _, err := canonicalExisting(gitDirValue); err != nil {
+ return ports.ControllerLayout{}, current, err
+ }
+ bindingPath := filepath.Join(r.externalRoot, "repositories", current.RepositoryID, current.GitCommonID, "binding.json")
+ embeddedSharedRoot := filepath.Join(commonRoot, "boatstack", "v2")
+ embeddedStateRoot := filepath.Join(embeddedSharedRoot, "worktrees", current.WorktreeID)
+ externalSharedRoot := filepath.Join(r.externalRoot, "repositories", current.RepositoryID, current.GitCommonID)
+ externalStateRoot := filepath.Join(externalSharedRoot, "worktrees", current.WorktreeID)
+ stateRoot := embeddedStateRoot
+ sharedRoot := embeddedSharedRoot
+ configAuthority := "repository"
+ if current.Topology == model.TopologyDetached || current.Topology == model.TopologyHybrid {
+ sharedRoot = externalSharedRoot
+ stateRoot = externalStateRoot
+ raw, readErr := os.ReadFile(bindingPath)
+ if readErr != nil {
+ return ports.ControllerLayout{}, current, readErr
+ }
+ binding, decodeErr := durable.DecodeBinding(raw)
+ if decodeErr != nil {
+ return ports.ControllerLayout{}, current, decodeErr
+ }
+ configAuthority = binding.ConfigAuthority
+ }
+ configPath := filepath.Join(repositoryRoot, ".boatstack", "project.json")
+ if configAuthority == "external" {
+ configPath = filepath.Join(externalSharedRoot, "project.json")
+ }
+ layout := ports.ControllerLayout{
+ RepositoryRoot: repositoryRoot, GitCommonRoot: commonRoot, StateRoot: stateRoot, SharedRoot: sharedRoot, FlowRoot: externalSharedRoot,
+ EmbeddedStateRoot: embeddedStateRoot, ExternalStateRoot: externalStateRoot,
+ StatePath: filepath.Join(stateRoot, "state.json"), BindingPath: bindingPath,
+ JournalRoot: filepath.Join(externalSharedRoot, "journals"), ReceiptPath: filepath.Join(externalSharedRoot, "receipts.jsonl"),
+ EventPath: filepath.Join(externalSharedRoot, "events.jsonl"), LockRoot: filepath.Join(externalSharedRoot, "locks"),
+ ConfigPath: configPath, ConfigAuthority: configAuthority, EvidenceRoot: filepath.Join(repositoryRoot, ".boatstack", "evidence"),
+ }
+ return layout, current, nil
+}
diff --git a/boatstack/internal/surfaces/catalog_render.go b/boatstack/internal/surfaces/catalog_render.go
new file mode 100644
index 0000000..dde761a
--- /dev/null
+++ b/boatstack/internal/surfaces/catalog_render.go
@@ -0,0 +1,124 @@
+package surfaces
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+// RenderCatalogMarkdown projects the executable registry into a reviewable
+// architecture artifact. It contains no independently maintained transitions.
+func RenderCatalogMarkdown(transitions []catalog.Transition) string {
+ ordered := append([]catalog.Transition(nil), transitions...)
+ sort.Slice(ordered, func(i, j int) bool { return ordered[i].ID < ordered[j].ID })
+ counts := map[catalog.EventClass]int{}
+ for _, transition := range ordered {
+ counts[transition.Class]++
+ }
+ var output strings.Builder
+ output.WriteString("\n")
+ output.WriteString("# Boatstack V2 executable transition catalog\n\n")
+ fmt.Fprintf(&output, "Registry size: **%d** transitions. Event classes: authority %d; owned-local %d; owned-external %d; recovery %d; observed-external %d.\n\n",
+ len(ordered), counts[catalog.EventAuthority], counts[catalog.EventOwnedLocal], counts[catalog.EventOwnedExternal], counts[catalog.EventRecovery], counts[catalog.EventObservedExternal])
+ output.WriteString("Controlling facets: `")
+ facets := model.ControllingFacets()
+ for index, facet := range facets {
+ if index > 0 {
+ output.WriteString("`, `")
+ }
+ output.WriteString(string(facet))
+ }
+ output.WriteString("`.\n\n")
+ output.WriteString("| Transition | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Recovery |\n")
+ output.WriteString("|---|---|---|---|---|---|---|---|\n")
+ for _, transition := range ordered {
+ authority := joinAuthorities(transition.Authority)
+ if len(transition.AuthorityAll) > 0 {
+ authority += " AND " + joinAuthorities(transition.AuthorityAll)
+ }
+ parameters := make([]string, 0, len(transition.Parameters))
+ for _, parameter := range transition.Parameters {
+ name := parameter.Name
+ if parameter.Required {
+ name += "*"
+ }
+ parameters = append(parameters, name)
+ }
+ recovery := string(transition.Interruption.Recovery)
+ if recovery == "" {
+ recovery = "-"
+ }
+ fmt.Fprintf(&output, "| `%s` | %s | %s | %s | %s | %s | %s | `%s` |\n",
+ transition.ID, transition.Class, joinPhases(transition.SourcePhases), joinPhases(transition.TargetPhases),
+ authority, markdownList(parameters), markdownList(transition.OwnedResources), recovery)
+ }
+ output.WriteString("\n`*` 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`.\n")
+ return output.String()
+}
+
+// RenderCatalogMermaid generates one inventory node per runtime transition,
+// grouped by event class. Phase sets are labels on the exact transition node,
+// avoiding a second hand-maintained graph.
+func RenderCatalogMermaid(transitions []catalog.Transition) string {
+ ordered := append([]catalog.Transition(nil), transitions...)
+ sort.Slice(ordered, func(i, j int) bool {
+ if ordered[i].Class != ordered[j].Class {
+ return ordered[i].Class < ordered[j].Class
+ }
+ return ordered[i].ID < ordered[j].ID
+ })
+ classes := []catalog.EventClass{catalog.EventAuthority, catalog.EventOwnedLocal, catalog.EventOwnedExternal, catalog.EventRecovery, catalog.EventObservedExternal}
+ var output strings.Builder
+ output.WriteString("%% Generated from catalog.Default by surfaces.RenderCatalogMermaid. Do not edit.\n")
+ output.WriteString("flowchart TB\n")
+ index := 0
+ for _, class := range classes {
+ fmt.Fprintf(&output, " subgraph %s[\"%s\"]\n", strings.ReplaceAll(string(class), "-", "_"), class)
+ for _, transition := range ordered {
+ if transition.Class != class {
+ continue
+ }
+ label := fmt.Sprintf("%s
%s → %s", transition.ID, strings.Join(phaseStrings(transition.SourcePhases), " | "), strings.Join(phaseStrings(transition.TargetPhases), " | "))
+ fmt.Fprintf(&output, " t%02d[\"%s\"]\n", index, escapeMermaid(label))
+ index++
+ }
+ output.WriteString(" end\n")
+ }
+ return output.String()
+}
+
+func joinAuthorities(values []catalog.AuthorityClass) string {
+ parts := make([]string, len(values))
+ for index, value := range values {
+ parts[index] = string(value)
+ }
+ return strings.Join(parts, "/")
+}
+
+func phaseStrings(values []model.ProtocolPhase) []string {
+ parts := make([]string, len(values))
+ for index, value := range values {
+ parts[index] = string(value)
+ }
+ return parts
+}
+
+func joinPhases(values []model.ProtocolPhase) string {
+ return strings.Join(phaseStrings(values), " / ")
+}
+
+func markdownList(values []string) string {
+ if len(values) == 0 {
+ return "-"
+ }
+ return "`" + strings.Join(values, "`, `") + "`"
+}
+
+func escapeMermaid(value string) string {
+ value = strings.ReplaceAll(value, "&", "&")
+ value = strings.ReplaceAll(value, "\"", """)
+ return value
+}
diff --git a/boatstack/internal/surfaces/guard.go b/boatstack/internal/surfaces/guard.go
new file mode 100644
index 0000000..55df3ce
--- /dev/null
+++ b/boatstack/internal/surfaces/guard.go
@@ -0,0 +1,9 @@
+package surfaces
+
+import "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor"
+
+// ClassifyCommandIntent is the host-facing projection of the kernel's one
+// consumer-neutral command classifier.
+func ClassifyCommandIntent(command string) supervisor.CommandIntent {
+ return supervisor.ClassifyCommandIntent(command)
+}
diff --git a/boatstack/internal/surfaces/locus_render.go b/boatstack/internal/surfaces/locus_render.go
new file mode 100644
index 0000000..d78f6a8
--- /dev/null
+++ b/boatstack/internal/surfaces/locus_render.go
@@ -0,0 +1,144 @@
+package surfaces
+
+import (
+ "encoding/json"
+ "sort"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+)
+
+type locusEvidence struct {
+ Path string `json:"path"`
+ Note string `json:"note"`
+}
+
+type locusState struct {
+ ID string `json:"id"`
+ Marked bool `json:"marked,omitempty"`
+}
+
+type locusEvent struct {
+ ID string `json:"id"`
+ Controllable bool `json:"controllable"`
+ Observable bool `json:"observable"`
+ Basis string `json:"basis"`
+}
+
+type locusTransition struct {
+ From string `json:"from"`
+ Event string `json:"event"`
+ To string `json:"to"`
+ Guard string `json:"guard,omitempty"`
+ Evidence []int `json:"evidence"`
+ Basis string `json:"basis"`
+}
+
+type locusSpec struct {
+ Description string `json:"description"`
+ ForbiddenStates []string `json:"forbidden_states"`
+ ForbiddenTransitions []struct{} `json:"forbidden_transitions"`
+}
+
+type locusMutation struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ GuardID string `json:"guard_id"`
+}
+
+type locusModel struct {
+ SchemaVersion int `json:"schema_version"`
+ ID string `json:"id"`
+ Subject string `json:"subject"`
+ Evidence []locusEvidence `json:"evidence"`
+ States []locusState `json:"states"`
+ Events []locusEvent `json:"events"`
+ Transitions []locusTransition `json:"transitions"`
+ Spec locusSpec `json:"spec"`
+ Targets struct {
+ Selector string `json:"selector"`
+ } `json:"targets"`
+ Mutation *locusMutation `json:"mutation,omitempty"`
+ Unknowns []string `json:"unknowns"`
+}
+
+func RenderCatalogLocusSafety(transitions []catalog.Transition) (string, error) {
+ return renderCatalogLocus(transitions, true)
+}
+
+func RenderCatalogLocusLiveness(transitions []catalog.Transition) (string, error) {
+ return renderCatalogLocus(transitions, false)
+}
+
+func renderCatalogLocus(transitions []catalog.Transition, safety bool) (string, error) {
+ ordered := append([]catalog.Transition(nil), transitions...)
+ sort.Slice(ordered, func(i, j int) bool { return ordered[i].ID < ordered[j].ID })
+ usedPhases := map[model.ProtocolPhase]bool{}
+ for _, transition := range ordered {
+ for _, phase := range transition.SourcePhases {
+ usedPhases[phase] = true
+ }
+ for _, phase := range transition.TargetPhases {
+ usedPhases[phase] = true
+ }
+ }
+ result := locusModel{
+ SchemaVersion: 1,
+ ID: "boatstack-v2-executable-catalog-liveness-v1",
+ Subject: "Finite stable-phase abstraction generated from the executable Boatstack V2 registry. It contains one event for every runtime catalog entry and expands each declared source and target phase set. The 17-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.",
+ Evidence: []locusEvidence{
+ {Path: "boatstack/internal/kernel/catalog/default.go", Note: "Executable registry, exact transition count, event classes, phase predicates, authority, and materialization."},
+ {Path: "docs/architecture/boatstack-v2-transition-catalog.md", Note: "Generated readable projection from the same runtime registry."},
+ {Path: "boatstack/internal/kernel/protocol/admission.go", Note: "Exact admission, authority, parameter, source-revision, provider-request, expiry, and stale-snapshot checks."},
+ {Path: "boatstack/internal/kernel/engine/engine.go", Note: "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery."},
+ {Path: "boatstack/internal/kernel/reducer/reducer.go", Note: "Single executable reducer for every controllable semantic transition."},
+ {Path: "boatstack/internal/kernel/catalog/completeness_test.go", Note: "Runtime facet/event classification, writer-boundary inventory, and reducer-completeness refusing tests."},
+ {Path: "boatstack/internal/kernel/engine/engine_test.go", Note: "Exact-admission, stale-snapshot, postcondition, interruption, idempotency, and unknown-outcome tests."},
+ {Path: "boatstack/internal/effects/prepared.go", Note: "Staged effect ordering, atomic resource application, rollback, and external settlement boundary."},
+ {Path: "boatstack/internal/kernel/catalog/historical_test.go", Note: "Historical incidents resolved through the executable runtime supervisor."},
+ },
+ Spec: locusSpec{
+ Description: "Every reachable stable catalog phase retains a path to terminal, explicit frontier, or safe abandonment.",
+ ForbiddenStates: []string{},
+ ForbiddenTransitions: []struct{}{},
+ },
+ Unknowns: []string{
+ "The stable-phase graph is a conservative expansion of declared source and target phase sets; facet combinations and deterministic reducer branches remain executable-test obligations.",
+ "External provider state and operating-system crash behavior are represented by declared outcomes and restart tests, not exhaustively observed by Locus.",
+ },
+ }
+ result.Targets.Selector = "marked"
+ for _, phase := range model.ProtocolPhases() {
+ if !usedPhases[phase] {
+ continue
+ }
+ result.States = append(result.States, locusState{ID: string(phase), Marked: phase.IsCompletionTarget()})
+ }
+ for _, transition := range ordered {
+ result.Events = append(result.Events, locusEvent{
+ ID: string(transition.ID), Controllable: transition.Controllable(), Observable: true, Basis: "observed",
+ })
+ for _, source := range transition.SourcePhases {
+ for _, target := range transition.TargetPhases {
+ result.Transitions = append(result.Transitions, locusTransition{
+ From: string(source), Event: string(transition.ID), To: string(target), Evidence: []int{0, 1, 4}, Basis: "inferred",
+ })
+ }
+ }
+ }
+ if safety {
+ result.ID = "boatstack-v2-executable-catalog-safety-v1"
+ result.States = append(result.States, locusState{ID: "UNADMITTED_EFFECT"})
+ result.Transitions = append(result.Transitions, locusTransition{
+ From: "DORMANT", Event: "publication.execute", To: "UNADMITTED_EFFECT", Guard: "exact-admission", Evidence: []int{2, 3, 6}, Basis: "inferred",
+ })
+ result.Spec.Description = "Managed publication is unreachable from DORMANT without the exact-admission guard."
+ result.Spec.ForbiddenStates = []string{"UNADMITTED_EFFECT"}
+ result.Mutation = &locusMutation{ID: "remove-exact-admission", Kind: "remove-guard", GuardID: "exact-admission"}
+ }
+ raw, err := json.MarshalIndent(result, "", " ")
+ if err != nil {
+ return "", err
+ }
+ return string(append(raw, '\n')), nil
+}
diff --git a/boatstack/internal/surfaces/protocol.go b/boatstack/internal/surfaces/protocol.go
new file mode 100644
index 0000000..41106e7
--- /dev/null
+++ b/boatstack/internal/surfaces/protocol.go
@@ -0,0 +1,117 @@
+package surfaces
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "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"
+)
+
+const SchemaVersion = 2
+
+type Operation string
+
+const (
+ OperationResolve Operation = "resolve"
+ OperationApply Operation = "apply"
+ OperationRecover Operation = "recover"
+ OperationDoctor Operation = "doctor"
+ OperationCatalog Operation = "catalog"
+ OperationEvents Operation = "events"
+ OperationGuard Operation = "guard"
+)
+
+func (o Operation) Valid() bool {
+ switch o {
+ case OperationResolve, OperationApply, OperationRecover, OperationDoctor, OperationCatalog, OperationEvents, OperationGuard:
+ return true
+ default:
+ return false
+ }
+}
+
+type Request struct {
+ SchemaVersion int `json:"schema_version"`
+ Operation Operation `json:"operation"`
+ Repository string `json:"repository"`
+ Host string `json:"host"`
+ CorrelationID string `json:"correlation_id"`
+ FlowID string `json:"flow_id,omitempty"`
+ Goal model.Goal `json:"goal,omitempty"`
+ TransitionID catalog.TransitionID `json:"transition_id,omitempty"`
+ Authority protocol.AuthorityBundle `json:"authority,omitempty"`
+ RepositoryAuthority bool `json:"repository_authority,omitempty"`
+ Parameters protocol.Parameters `json:"parameters,omitempty"`
+ IdempotencyKey string `json:"idempotency_key,omitempty"`
+ Command string `json:"command,omitempty"`
+}
+
+func (r Request) Validate(now time.Time) error {
+ if r.SchemaVersion != SchemaVersion || !r.Operation.Valid() {
+ return fmt.Errorf("surface request has invalid schema or operation")
+ }
+ if r.Operation != OperationCatalog && (r.Repository == "" || r.Host == "" || r.CorrelationID == "") {
+ return fmt.Errorf("surface request requires repository, host, and correlation identity")
+ }
+ if r.Operation != OperationCatalog {
+ knownHost := false
+ for _, host := range CanonicalHostNames() {
+ if r.Host == host {
+ knownHost = true
+ break
+ }
+ }
+ if !knownHost {
+ return fmt.Errorf("surface request has unsupported host %q", r.Host)
+ }
+ }
+ if r.Operation == OperationApply || r.Operation == OperationRecover {
+ if r.FlowID == "" || r.TransitionID == "" {
+ return fmt.Errorf("apply/recover request requires flow and transition identity")
+ }
+ }
+ if r.Operation == OperationRecover {
+ switch r.TransitionID {
+ case "recovery.resume", "recovery.rollback", "recovery.escalate", "runtime.reconcile", "configuration.reconcile", "workspace.reconcile", "publication.reconcile":
+ default:
+ return fmt.Errorf("recover operation requires a registered recovery transition")
+ }
+ }
+ if r.Operation == OperationGuard && (strings.TrimSpace(r.Command) == "" || len(r.Command) > 1<<20) {
+ return fmt.Errorf("guard operation requires a bounded command")
+ }
+ if r.IdempotencyKey != "" && !strings.HasPrefix(r.IdempotencyKey, "idem-") {
+ return fmt.Errorf("surface idempotency key has invalid identity")
+ }
+ if err := r.Authority.Validate(now); err != nil {
+ return err
+ }
+ return nil
+}
+
+type DoctorReport struct {
+ Healthy bool `json:"healthy"`
+ TransitionCount int `json:"transition_count"`
+ Snapshot string `json:"snapshot,omitempty"`
+ Detail string `json:"detail"`
+}
+
+type Response struct {
+ SchemaVersion int `json:"schema_version"`
+ Operation Operation `json:"operation"`
+ Goal model.Goal `json:"goal,omitempty"`
+ Snapshot *model.Snapshot `json:"snapshot,omitempty"`
+ Decision *supervisor.Decision `json:"decision,omitempty"`
+ Admission *protocol.Admission `json:"admission,omitempty"`
+ Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"`
+ Replayed bool `json:"replayed,omitempty"`
+ Catalog []catalog.Transition `json:"catalog,omitempty"`
+ Events []map[string]any `json:"events,omitempty"`
+ Doctor *DoctorReport `json:"doctor,omitempty"`
+ Guard *supervisor.GuardDecision `json:"guard,omitempty"`
+ Error string `json:"error,omitempty"`
+}
diff --git a/boatstack/internal/surfaces/render.go b/boatstack/internal/surfaces/render.go
new file mode 100644
index 0000000..134b4e3
--- /dev/null
+++ b/boatstack/internal/surfaces/render.go
@@ -0,0 +1,96 @@
+package surfaces
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+)
+
+type Shell string
+
+const (
+ ShellPOSIX Shell = "posix"
+ ShellPowerShell Shell = "powershell"
+ ShellGitBash Shell = "git-bash"
+)
+
+// CommandAST is the sole semantic prescription consumed by every shell and
+// host renderer.
+type CommandAST struct {
+ Executable string
+ Arguments []string
+}
+
+func PrescriptionCommand(transition catalog.Transition, repository string, goal model.Goal, flowID string, parameters protocol.Parameters) CommandAST {
+ arguments := []string{"apply", "--repo", repository, "--transition", string(transition.ID), "--goal-kind", string(goal.Kind), "--delivery", goal.DeliveryID, "--goal-id", goal.ID, "--flow", flowID}
+ canonical := parameters.Canonical()
+ for _, parameter := range canonical {
+ arguments = append(arguments, "--param", parameter.Name+"="+parameter.Value)
+ }
+ return CommandAST{Executable: "boatstack", Arguments: arguments}
+}
+
+func RenderCommand(command CommandAST, shell Shell) (string, error) {
+ if command.Executable == "" {
+ return "", fmt.Errorf("command executable is required")
+ }
+ quote := quotePOSIX
+ if shell == ShellPowerShell {
+ quote = quotePowerShell
+ } else if shell != ShellPOSIX && shell != ShellGitBash {
+ return "", fmt.Errorf("unsupported shell %q", shell)
+ }
+ parts := []string{quote(command.Executable)}
+ for _, argument := range command.Arguments {
+ parts = append(parts, quote(argument))
+ }
+ return strings.Join(parts, " "), nil
+}
+
+type HostPrescription struct {
+ Host string `json:"host"`
+ TransitionID string `json:"transition_id"`
+ Command CommandAST `json:"command"`
+ AuthorityPrompt string `json:"authority_prompt,omitempty"`
+ ExpectedPostcondition string `json:"expected_postcondition"`
+}
+
+// ProjectHostPrescription changes host capability metadata only. Every host
+// consumes the same semantic command, authority prompt, and postcondition.
+func ProjectHostPrescription(host string, transition catalog.Transition, repository string, goal model.Goal, flowID string, parameters protocol.Parameters) (HostPrescription, error) {
+ known := false
+ for _, candidate := range CanonicalHostNames() {
+ if host == candidate {
+ known = true
+ break
+ }
+ }
+ if !known {
+ return HostPrescription{}, fmt.Errorf("unsupported host %q", host)
+ }
+ return HostPrescription{
+ Host: host, TransitionID: string(transition.ID), Command: PrescriptionCommand(transition, repository, goal, flowID, parameters),
+ AuthorityPrompt: transition.Prescription.AuthorityPrompt, ExpectedPostcondition: transition.Prescription.ExpectedPostcondition,
+ }, nil
+}
+
+func quotePOSIX(value string) string {
+ if value != "" && !strings.ContainsAny(value, " \t\r\n'\"\\$`;&|<>()[]{}*!?") {
+ return value
+ }
+ return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
+}
+
+func quotePowerShell(value string) string {
+ if value != "" && !strings.ContainsAny(value, " \t\r\n'\"`$;&|<>()[]{}") {
+ return value
+ }
+ return "'" + strings.ReplaceAll(value, "'", "''") + "'"
+}
+
+func CanonicalHostNames() []string {
+ return protocol.CanonicalHosts()
+}
diff --git a/boatstack/internal/surfaces/render_test.go b/boatstack/internal/surfaces/render_test.go
new file mode 100644
index 0000000..ad06b52
--- /dev/null
+++ b/boatstack/internal/surfaces/render_test.go
@@ -0,0 +1,237 @@
+package surfaces
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+
+ "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"
+)
+
+func TestShellRenderersConsumeOneCommandAST(t *testing.T) {
+ // control-law: shell-rendering-never-changes-transition-semantics
+ transition, ok := catalog.Default().Lookup("plan.create")
+ if !ok {
+ t.Fatal("missing plan.create")
+ }
+ goal := model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"}
+ parameters := protocol.Parameters{{Name: "source_path", Value: "/tmp/O'Brien plan.md"}, {Name: "delivery_id", Value: "delivery"}}
+ command := PrescriptionCommand(transition, "/repo with space", goal, "flow", parameters)
+ before := append([]string(nil), command.Arguments...)
+ posix, err := RenderCommand(command, ShellPOSIX)
+ if err != nil {
+ t.Fatal(err)
+ }
+ powerShell, err := RenderCommand(command, ShellPowerShell)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if posix == powerShell || posix == "" || powerShell == "" {
+ t.Fatalf("renderings should be non-empty shell projections: %q / %q", posix, powerShell)
+ }
+ if !reflect.DeepEqual(command.Arguments, before) {
+ t.Fatal("renderer mutated semantic command AST")
+ }
+ gitBash, err := RenderCommand(command, ShellGitBash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gitBash != posix {
+ t.Fatalf("Git Bash projection diverged from POSIX semantics: %q != %q", gitBash, posix)
+ }
+}
+
+func TestCatalogArtifactsAreGeneratedFromEveryRuntimeTransition(t *testing.T) {
+ registry := catalog.Default()
+ markdown := RenderCatalogMarkdown(registry.All())
+ mermaid := RenderCatalogMermaid(registry.All())
+ for _, transition := range registry.All() {
+ rowPrefix := "\n| `" + string(transition.ID) + "` | " + string(transition.Class) + " |"
+ if strings.Count(markdown, rowPrefix) != 1 {
+ t.Errorf("markdown does not contain transition %s exactly once", transition.ID)
+ }
+ if strings.Count(mermaid, string(transition.ID)+"
") != 1 {
+ t.Errorf("Mermaid does not contain transition %s exactly once", transition.ID)
+ }
+ }
+ if markdown != RenderCatalogMarkdown(registry.All()) || mermaid != RenderCatalogMermaid(registry.All()) {
+ t.Fatal("catalog artifact rendering is not deterministic")
+ }
+}
+
+func TestLocusModelsAreGeneratedFromEveryRuntimeTransition(t *testing.T) {
+ // control-law: formal-model-alphabet-is-the-runtime-catalog
+ registry := catalog.Default()
+ for _, render := range []func([]catalog.Transition) (string, error){RenderCatalogLocusSafety, RenderCatalogLocusLiveness} {
+ one, err := render(registry.All())
+ if err != nil {
+ t.Fatal(err)
+ }
+ two, err := render(registry.All())
+ if err != nil || one != two {
+ t.Fatal("Locus catalog model is not deterministic")
+ }
+ var decoded struct {
+ Events []struct {
+ ID string `json:"id"`
+ } `json:"events"`
+ }
+ if err := json.Unmarshal([]byte(one), &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if len(decoded.Events) != registry.Len() {
+ t.Fatalf("Locus event count=%d, want %d", len(decoded.Events), registry.Len())
+ }
+ seen := map[string]bool{}
+ for _, event := range decoded.Events {
+ seen[event.ID] = true
+ }
+ for _, transition := range registry.All() {
+ if !seen[string(transition.ID)] {
+ t.Errorf("Locus model omitted transition %s", transition.ID)
+ }
+ }
+ }
+}
+
+func TestCheckedArchitectureArtifactsMatchExecutableCatalog(t *testing.T) {
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("cannot locate checked architecture artifacts")
+ }
+ repositoryRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
+ checks := map[string]string{
+ "boatstack-v2-transition-catalog.md": RenderCatalogMarkdown(catalog.Default().All()),
+ "boatstack-v2-transition-catalog.mmd": RenderCatalogMermaid(catalog.Default().All()),
+ }
+ safety, err := RenderCatalogLocusSafety(catalog.Default().All())
+ if err != nil {
+ t.Fatal(err)
+ }
+ liveness, err := RenderCatalogLocusLiveness(catalog.Default().All())
+ if err != nil {
+ t.Fatal(err)
+ }
+ checks["boatstack-v2-locus-safety.json"] = safety
+ checks["boatstack-v2-locus-liveness.json"] = liveness
+ for name, expected := range checks {
+ path := filepath.Join(repositoryRoot, "docs", "architecture", name)
+ actual, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(actual) != expected {
+ t.Errorf("%s drifted; regenerate it with boatstack catalog", name)
+ }
+ }
+}
+
+func TestEveryHostConsumesOneSemanticPrescription(t *testing.T) {
+ goal := model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"}
+ for _, transition := range catalog.Default().All() {
+ if !transition.Controllable() {
+ continue
+ }
+ parameters := make(protocol.Parameters, 0, len(transition.Parameters))
+ for _, parameter := range transition.Parameters {
+ parameters = append(parameters, protocol.Parameter{Name: parameter.Name, Value: "fixture-" + parameter.Name})
+ }
+ var canonical HostPrescription
+ for index, host := range CanonicalHostNames() {
+ projection, err := ProjectHostPrescription(host, transition, "/repo", goal, "flow", parameters)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if index == 0 {
+ canonical = projection
+ continue
+ }
+ projection.Host = canonical.Host
+ if !reflect.DeepEqual(projection, canonical) {
+ t.Fatalf("transition %s host %s changed control semantics: %#v != %#v", transition.ID, host, projection, canonical)
+ }
+ }
+ }
+}
+
+func TestCanonicalHostsAreDataNotControllers(t *testing.T) {
+ want := []string{"claude", "cli", "codex", "cursor", "gemini", "mcp"}
+ if got := CanonicalHostNames(); !reflect.DeepEqual(got, want) {
+ t.Fatalf("hosts = %v, want %v", got, want)
+ }
+}
+
+func TestGuardClassifierIsShellNeutralAndPrivacyBounded(t *testing.T) {
+ commands := []string{"git reset --hard HEAD~1", "GIT RESET --HARD HEAD~1", "git reset --hard HEAD~1"}
+ for _, command := range commands {
+ intent := ClassifyCommandIntent(command)
+ if intent.Class != supervisor.IntentDestructive || intent.Operation != "git.reset-hard" || intent.Fingerprint == "" {
+ t.Fatalf("intent for %q = %#v", command, intent)
+ }
+ }
+ managed := ClassifyCommandIntent("gh pr create --base main --head feature")
+ if managed.Class != supervisor.IntentManagedBypass || managed.Transition != "publication.execute" {
+ t.Fatalf("managed intent = %#v", managed)
+ }
+ ordinary := ClassifyCommandIntent("go test ./...")
+ if ordinary.Class != supervisor.IntentOrdinary || ordinary.Transition != "" {
+ t.Fatalf("ordinary intent = %#v", ordinary)
+ }
+}
+
+func TestGuardPreservesConstitutionalDestructionFloor(t *testing.T) {
+ destructive := []string{
+ `psql -c "DROP SCHEMA public CASCADE"`,
+ `psql -c "TRUNCATE TABLE accounts"`,
+ `psql -c "DELETE FROM accounts"`,
+ `psql -f migrate.sql`,
+ `supabase db reset`,
+ `prisma migrate reset --force`,
+ `terraform destroy -auto-approve`,
+ `rm -rf /`,
+ `rm -rf build/*`,
+ `Remove-Item -Recurse -Force $HOME`,
+ `git reset --hard HEAD~1`,
+ `git clean -fdx`,
+ `git push --force-with-lease origin main`,
+ `kubectl delete namespace production`,
+ `docker volume rm data-volume`,
+ `gcloud sql instances delete primary`,
+ `aws ec2 terminate-instances --instance-ids i-1`,
+ `gh pr merge 123 --squash`,
+ `printf '{}' > .git/boatstack/v2/worktrees/x/state.json`,
+ `Remove-Item -Recurse -Force .boatstack/evidence`,
+ }
+ for _, command := range destructive {
+ intent := ClassifyCommandIntent(command)
+ if intent.Class != supervisor.IntentDestructive || intent.Operation == "" || intent.Fingerprint == "" {
+ t.Errorf("constitutional destruction was not denied: %q -> %#v", command, intent)
+ }
+ }
+}
+
+func TestGuardKeepsRoutineDataOperationsOrdinary(t *testing.T) {
+ routine := []string{
+ `git add migrate.sql`,
+ `git commit -m "document DROP TABLE recovery"`,
+ `git diff --stat migrate.sql`,
+ `cat migrate.sql`,
+ `psql -c "SELECT count(*) FROM accounts"`,
+ `rm -rf build`,
+ `go test ./...`,
+ `rg -n "git reset --hard" docs`,
+ }
+ for _, command := range routine {
+ intent := ClassifyCommandIntent(command)
+ if intent.Class != supervisor.IntentOrdinary {
+ t.Errorf("routine data operation was not ordinary: %q -> %#v", command, intent)
+ }
+ }
+}
diff --git a/boatstack/journey.go b/boatstack/journey.go
deleted file mode 100644
index cb72237..0000000
--- a/boatstack/journey.go
+++ /dev/null
@@ -1,209 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-)
-
-const journeyManifestSchemaVersion = 1
-
-type JourneyResult struct {
- OracleID string `json:"oracle_id"`
- Status string `json:"status"`
- Evidence []string `json:"evidence"`
-}
-
-type JourneyResults struct {
- SchemaVersion int `json:"schema_version"`
- Feature string `json:"feature"`
- ManifestSHA256 string `json:"manifest_sha256"`
- HeadCommit string `json:"head_commit"`
- DiffSHA256 string `json:"diff_sha256"`
- Results []JourneyResult `json:"results"`
- RecordedAt string `json:"recorded_at"`
-}
-
-type JourneyResultsOptions struct {
- Repo string
- Feature string
- BaseBranch string
- InputPath string
-}
-
-func CompileJourneyManifest(plan map[string]any) ([]byte, error) {
- version, _ := plan["schema_version"].(float64)
- if err := validateJourneyEvidence(plan, version); err != nil {
- return nil, err
- }
- decision, _ := plan["journey_evidence"].(map[string]any)
- relevance := "not_relevant"
- reason := "legacy plan does not require journey evidence"
- oracles := []any{}
- if decision != nil {
- relevance = strings.ToLower(stringValue(decision["relevance"]))
- reason = stringValue(decision["reason"])
- if values, ok := decision["oracles"].([]any); ok {
- oracles = values
- }
- }
- body := map[string]any{
- "schema_version": journeyManifestSchemaVersion,
- "feature_id": stringValue(plan["feature_id"]),
- "relevance": relevance,
- "reason": reason,
- "oracles": oracles,
- }
- canonical, err := MarshalJSON(body)
- if err != nil {
- return nil, err
- }
- body["manifest_sha256"] = SHA256Bytes(canonical)
- return MarshalJSON(body)
-}
-
-func journeyManifestPath(repo, feature string) string {
- directory := WorkspaceFor(repo).FeatureDir(feature)
- return featureArtifactPath(directory, filepath.Join("compiled", "journey-oracles.json"), "journey-oracles.json")
-}
-
-func journeyResultsPath(repo, feature string) string {
- statePath, err := deliveryStatePath(repo, feature)
- if err != nil {
- return filepath.Join(repo, ".boatstack-invalid-state", feature, "journey-results.json")
- }
- return filepath.Join(filepath.Dir(statePath), "journey-results.json")
-}
-
-func loadJourneyManifest(path string) (map[string]any, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- var manifest map[string]any
- if err := DecodeJSON("journey oracle manifest", path, value, &manifest); err != nil {
- return nil, err
- }
- fingerprint := stringValue(manifest["manifest_sha256"])
- if fingerprint == "" {
- return nil, fmt.Errorf("journey oracle manifest fingerprint is missing")
- }
- delete(manifest, "manifest_sha256")
- canonical, err := MarshalJSON(manifest)
- if err != nil {
- return nil, err
- }
- if SHA256Bytes(canonical) != fingerprint {
- return nil, fmt.Errorf("journey oracle manifest fingerprint does not match its contents")
- }
- manifest["manifest_sha256"] = fingerprint
- return manifest, nil
-}
-
-func RecordJourneyResults(options JourneyResultsOptions) (JourneyResults, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return JourneyResults{}, err
- }
- manifestPath := journeyManifestPath(repo, options.Feature)
- manifest, err := loadJourneyManifest(manifestPath)
- if err != nil {
- return JourneyResults{}, fmt.Errorf("journey oracle manifest is missing: %w", err)
- }
- if stringValue(manifest["relevance"]) != "relevant" {
- return JourneyResults{}, fmt.Errorf("journey results may only be recorded for a relevant journey manifest")
- }
- input, err := os.ReadFile(options.InputPath)
- if err != nil {
- return JourneyResults{}, err
- }
- var submitted struct {
- Results []JourneyResult `json:"results"`
- }
- if err := DecodeJSON("journey results", options.InputPath, input, &submitted); err != nil {
- return JourneyResults{}, err
- }
- required := map[string]bool{}
- oracles, _ := objectSlice(manifest["oracles"])
- for _, oracle := range oracles {
- required[stringValue(oracle["id"])] = true
- }
- seen := map[string]bool{}
- for _, result := range submitted.Results {
- if !required[result.OracleID] || seen[result.OracleID] {
- return JourneyResults{}, fmt.Errorf("journey result has unknown or duplicate oracle %q", result.OracleID)
- }
- if result.Status != "PASS" && result.Status != "FAIL" {
- return JourneyResults{}, fmt.Errorf("journey oracle %s status must be PASS or FAIL", result.OracleID)
- }
- if len(result.Evidence) == 0 {
- return JourneyResults{}, fmt.Errorf("journey oracle %s requires evidence", result.OracleID)
- }
- for _, evidence := range result.Evidence {
- if strings.TrimSpace(evidence) == "" {
- return JourneyResults{}, fmt.Errorf("journey oracle %s evidence must be non-empty", result.OracleID)
- }
- }
- seen[result.OracleID] = true
- }
- for id := range required {
- if !seen[id] {
- return JourneyResults{}, fmt.Errorf("journey result is missing required oracle %s", id)
- }
- }
- base := strings.TrimSpace(options.BaseBranch)
- if base == "" {
- base = defaultPRBase(repo)
- }
- base, err = canonicalPRBase(repo, base)
- if err != nil {
- return JourneyResults{}, err
- }
- _, headCommit, diffSHA, _, err := currentDiffIdentity(repo, base, "")
- if err != nil {
- return JourneyResults{}, err
- }
- results := JourneyResults{
- SchemaVersion: 1, Feature: options.Feature,
- ManifestSHA256: stringValue(manifest["manifest_sha256"]),
- HeadCommit: headCommit, DiffSHA256: diffSHA, Results: submitted.Results,
- RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
- }
- sort.Slice(results.Results, func(i, j int) bool { return results.Results[i].OracleID < results.Results[j].OracleID })
- value, _ := MarshalJSON(results)
- if err := atomicWriteMode(journeyResultsPath(repo, options.Feature), value, 0o644); err != nil {
- return JourneyResults{}, err
- }
- return results, nil
-}
-
-func checkCurrentJourneyResults(repo, feature, base, headCommit, diffSHA string) error {
- manifestPath := journeyManifestPath(repo, feature)
- manifest, err := loadJourneyManifest(manifestPath)
- if err != nil {
- return fmt.Errorf("required journey manifest is missing: %w", err)
- }
- if stringValue(manifest["relevance"]) != "relevant" {
- return nil
- }
- value, err := os.ReadFile(journeyResultsPath(repo, feature))
- if err != nil {
- return fmt.Errorf("required journey results are missing")
- }
- var results JourneyResults
- if err := DecodeJSON("journey results", journeyResultsPath(repo, feature), value, &results); err != nil {
- return err
- }
- if results.ManifestSHA256 != stringValue(manifest["manifest_sha256"]) || results.HeadCommit != headCommit || results.DiffSHA256 != diffSHA {
- return fmt.Errorf("required journey results are stale for the current manifest, head, or diff")
- }
- for _, result := range results.Results {
- if result.Status != "PASS" {
- return fmt.Errorf("required journey oracle %s did not pass", result.OracleID)
- }
- }
- return nil
-}
diff --git a/boatstack/journey_conformance_test.go b/boatstack/journey_conformance_test.go
deleted file mode 100644
index 9eaa84f..0000000
--- a/boatstack/journey_conformance_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// control-law: journey-evidence-is-explicit-complete-and-fingerprint-bound
-func TestJourneyEvidenceDecisionAndManifest(t *testing.T) {
- plan := validPlan()
- plan["schema_version"] = float64(3)
- plan["architecture_facts"] = []any{}
- plan["architecture_unknowns"] = []any{}
- if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "journey_evidence") {
- t.Fatalf("schema-v3 plan without a journey decision must fail: %v", err)
- }
-
- plan["journey_evidence"] = map[string]any{"relevance": "not_relevant", "reason": ""}
- if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "requires a reason") {
- t.Fatalf("not_relevant without a reason must fail: %v", err)
- }
-
- plan["journey_evidence"] = map[string]any{
- "relevance": "relevant",
- "oracles": []any{map[string]any{
- "id": "J-1", "type": "cli", "criteria": []any{"AC-1"},
- "entry_point": "boatstack-helper", "steps": []any{"run the command"},
- "expected": []any{"exit zero"}, "run": "go test ./...",
- "oracle": "exit status", "independence": "contract-derived",
- }},
- }
- if err := ValidatePlan(plan, nil); err != nil {
- t.Fatalf("complete journey decision rejected: %v", err)
- }
- first, err := CompileJourneyManifest(plan)
- if err != nil {
- t.Fatal(err)
- }
- second, err := CompileJourneyManifest(plan)
- if err != nil {
- t.Fatal(err)
- }
- if string(first) != string(second) {
- t.Fatal("journey manifest must compile deterministically")
- }
- var manifest map[string]any
- if err := json.Unmarshal(first, &manifest); err != nil {
- t.Fatal(err)
- }
- if stringValue(manifest["manifest_sha256"]) == "" {
- t.Fatal("compiled journey manifest must carry its fingerprint")
- }
- plan["journey_evidence"].(map[string]any)["oracles"].([]any)[0].(map[string]any)["run"] = "definitely-missing-boatstack-capability --check"
- if err := checkJourneyCapabilities(t.TempDir(), plan); err == nil || !strings.Contains(err.Error(), "missing command") {
- t.Fatalf("missing journey capability must block readiness: %v", err)
- }
-}
-
-// control-law: journey-results-must-match-manifest-head-diff-and-pass
-func TestJourneyResultsRejectFailureAndStaleness(t *testing.T) {
- repo := prTestRepo(t)
- feature := "journey-feature"
- dir := filepath.Join(repo, ".product-loop", "features", feature, "compiled")
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- manifest := map[string]any{
- "schema_version": 1, "feature_id": feature, "relevance": "relevant",
- "reason": "", "oracles": []any{map[string]any{"id": "J-1"}},
- }
- canonical, _ := MarshalJSON(manifest)
- manifest["manifest_sha256"] = SHA256Bytes(canonical)
- manifestValue, _ := MarshalJSON(manifest)
- if err := os.WriteFile(filepath.Join(dir, "journey-oracles.json"), manifestValue, 0o644); err != nil {
- t.Fatal(err)
- }
- input := filepath.Join(t.TempDir(), "journey-input.json")
- if err := os.WriteFile(input, []byte(`{"results":[{"oracle_id":"J-1","status":"FAIL","evidence":["trace"]}]}`), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "record journey fixture")
- _, currentHead, currentDiff, _, err := currentDiffIdentity(repo, defaultPRBase(repo), "")
- if err != nil {
- t.Fatal(err)
- }
- if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), currentHead, currentDiff); err == nil || !strings.Contains(err.Error(), "missing") {
- t.Fatalf("missing journey results must block gates: %v", err)
- }
- recorded, err := RecordJourneyResults(JourneyResultsOptions{Repo: repo, Feature: feature, InputPath: input})
- if err != nil {
- t.Fatal(err)
- }
- if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), recorded.HeadCommit, recorded.DiffSHA256); err == nil || !strings.Contains(err.Error(), "did not pass") {
- t.Fatalf("failed journey must block gates: %v", err)
- }
- if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), recorded.HeadCommit, "different"); err == nil || !strings.Contains(err.Error(), "stale") {
- t.Fatalf("stale journey must block gates: %v", err)
- }
- if err := os.WriteFile(input, []byte(`{"results":[{"oracle_id":"J-1","status":"PASS","evidence":["current trace"]}]}`), 0o644); err != nil {
- t.Fatal(err)
- }
- passed, err := RecordJourneyResults(JourneyResultsOptions{Repo: repo, Feature: feature, InputPath: input})
- if err != nil {
- t.Fatal(err)
- }
- if err := checkCurrentJourneyResults(repo, feature, defaultPRBase(repo), passed.HeadCommit, passed.DiffSHA256); err != nil {
- t.Fatalf("current passing journey results must permit progression: %v", err)
- }
-}
diff --git a/boatstack/launcher.go b/boatstack/launcher.go
deleted file mode 100644
index 76ba3d7..0000000
--- a/boatstack/launcher.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package boatstack
-
-import "fmt"
-
-// launcherShellScript is the tracked command boundary for POSIX worktrees. It
-// resolves only the release identity baked into this generated file, verifies
-// that shared runtime, asks it to activate the ignored worktree-local runtime,
-// and then dispatches through the local runtime. It never searches sibling
-// worktrees or selects a newer release.
-func launcherShellScript() []byte {
- recovery := runtimeHydrateCommandBash(Version)
- return []byte(fmt.Sprintf(`#!/usr/bin/env bash
-# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json.
-set -u
-
-bs_fail() {
- printf 'Boatstack runtime activation failed: %%s\n' "$1" >&2
- printf 'Recovery: %%s\n' '%s' >&2
- exit 2
-}
-
-SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P || true)"
-[[ -n "$SCRIPT_DIR" ]] || bs_fail "cannot resolve the launcher directory"
-ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
-[[ -n "$ROOT" ]] || bs_fail "cannot resolve the repository"
-COMMON="$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
-[[ -n "$COMMON" ]] || bs_fail "cannot resolve the Git common directory"
-
-case "$(uname -s)" in
- Darwin) OS_NAME="darwin"; EXTENSION="" ;;
- Linux) OS_NAME="linux"; EXTENSION="" ;;
- MINGW*|MSYS*|CYGWIN*) OS_NAME="windows"; EXTENSION=".exe" ;;
- *) bs_fail "unsupported operating system" ;;
-esac
-case "$(uname -m)" in
- x86_64|amd64) ARCH="amd64" ;;
- arm64|aarch64) ARCH="arm64" ;;
- *) bs_fail "unsupported architecture" ;;
-esac
-
-VERSION='%s'
-SOURCE_COMMIT='%s'
-PLATFORM="${OS_NAME}-${ARCH}"
-SLOT="$COMMON/boatstack/runtimes/$VERSION/$SOURCE_COMMIT/$PLATFORM"
-HELPER="$SLOT/boatstack-helper$EXTENSION"
-MANIFEST="$SLOT/runtime.lock.json"
-HYDRATE_LOCK="$COMMON/boatstack/hydrate-$VERSION.lock"
-
-bs_slot_ready() {
- [[ -x "$HELPER" && ! -L "$HELPER" && -f "$MANIFEST" && ! -L "$MANIFEST" ]]
-}
-
-if ! bs_slot_ready && [[ "${BOATSTACK_AUTO_HYDRATE:-1}" != "0" ]]; then
- mkdir -p "$COMMON/boatstack" 2>/dev/null || true
- if mkdir "$HYDRATE_LOCK" 2>/dev/null; then
- if ! bs_slot_ready; then
- (
- cd "$ROOT" || exit 1
- export BOATSTACK_MODE=hydrate BOATSTACK_VERSION="$VERSION" BOATSTACK_REPO="$ROOT"
- HYDRATE_COMMAND="${BOATSTACK_HYDRATE_COMMAND:-}"
- [[ -n "$HYDRATE_COMMAND" ]] || HYDRATE_COMMAND='%s'
- if command -v timeout >/dev/null 2>&1; then
- timeout 8 /bin/bash -c "$HYDRATE_COMMAND"
- else
- /bin/bash -c "$HYDRATE_COMMAND"
- fi
- ) >&2 || true
- fi
- rmdir "$HYDRATE_LOCK" 2>/dev/null || true
- else
- for _ in $(seq 1 12); do
- [[ -d "$HYDRATE_LOCK" ]] || break
- sleep 1
- done
- fi
-fi
-
-# Path visibility is not publication. A concurrent hydrator copies the helper
-# and manifest before releasing this lock, so even a ready-looking slot must
-# wait for that release before its manifest is parsed.
-for _ in $(seq 1 12); do
- [[ -d "$HYDRATE_LOCK" ]] || break
- sleep 1
-done
-[[ ! -d "$HYDRATE_LOCK" ]] || bs_fail "shared runtime hydration did not complete"
-
-bs_slot_ready || bs_fail "the exact pinned shared runtime is missing or unsafe"
-manifest_value() {
- sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST" | head -n 1
-}
-[[ "$(manifest_value boatstack_version)" == "$VERSION" ]] || bs_fail "shared runtime version does not match the launcher pin"
-[[ "$(manifest_value source_commit)" == "$SOURCE_COMMIT" ]] || bs_fail "shared runtime source does not match the launcher pin"
-[[ "$(manifest_value platform)" == "$PLATFORM" ]] || bs_fail "shared runtime platform does not match this host"
-EXPECTED="$(manifest_value binary_sha256)"
-if command -v sha256sum >/dev/null 2>&1; then
- ACTUAL="$(sha256sum "$HELPER" | awk '{print $1}')"
-elif command -v shasum >/dev/null 2>&1; then
- ACTUAL="$(shasum -a 256 "$HELPER" | awk '{print $1}')"
-else
- bs_fail "no SHA-256 verifier is available"
-fi
-[[ "$EXPECTED" =~ ^[0-9a-f]{64}$ && "$ACTUAL" == "$EXPECTED" ]] || bs_fail "shared runtime checksum is invalid"
-
-"$HELPER" activate-worktree-runtime --repo "$ROOT" || bs_fail "the pinned runtime could not activate this worktree"
-LOCAL="$ROOT/.product-loop/bin/boatstack-helper$EXTENSION"
-[[ -x "$LOCAL" && ! -L "$LOCAL" ]] || bs_fail "worktree runtime activation did not produce a safe helper"
-exec "$LOCAL" "$@"
-`, recovery, Version, SourceCommit, recovery))
-}
-
-// launcherPowerShellScript is the PowerShell form of launcherShellScript and
-// enforces the same exact identity, activation, and recovery contract.
-func launcherPowerShellScript() []byte {
- recovery := runtimeHydrateCommandPowerShell(Version)
- return []byte(fmt.Sprintf(`# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json.
-$ErrorActionPreference = "Stop"
-function Fail-Activation([string]$Message) {
- [Console]::Error.WriteLine("Boatstack runtime activation failed: $Message")
- [Console]::Error.WriteLine('Recovery: %s')
- exit 2
-}
-
-if (-not $PSScriptRoot) { Fail-Activation "cannot resolve the launcher directory" }
-try { $root = (& git -C $PSScriptRoot rev-parse --show-toplevel 2>$null) } catch { Fail-Activation "cannot resolve the repository" }
-if (-not $root) { Fail-Activation "cannot resolve the repository" }
-try { $common = (& git -C $PSScriptRoot rev-parse --path-format=absolute --git-common-dir 2>$null) } catch { Fail-Activation "cannot resolve the Git common directory" }
-if (-not $common) { Fail-Activation "cannot resolve the Git common directory" }
-$arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) {
- "x64" { "amd64" }
- "arm64" { "arm64" }
- default { Fail-Activation "unsupported architecture" }
-}
-$version = '%s'
-$sourceCommit = '%s'
-$platform = "windows-$arch"
-$slot = Join-Path $common "boatstack/runtimes/$version/$sourceCommit/$platform"
-$helper = Join-Path $slot "boatstack-helper.exe"
-$manifestPath = Join-Path $slot "runtime.lock.json"
-$boatstackRoot = Join-Path $common "boatstack"
-$lockPath = Join-Path $boatstackRoot "hydrate-$version.lock"
-function Slot-Ready {
- (Test-Path -LiteralPath $helper -PathType Leaf) -and
- (Test-Path -LiteralPath $manifestPath -PathType Leaf) -and
- (-not ((Get-Item -LiteralPath $helper -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) -and
- (-not ((Get-Item -LiteralPath $manifestPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint))
-}
-
-if ((-not (Slot-Ready)) -and ($env:BOATSTACK_AUTO_HYDRATE -ne "0")) {
- New-Item -ItemType Directory -Force -Path $boatstackRoot | Out-Null
- $ownsLock = $false
- try { New-Item -ItemType Directory -ErrorAction Stop -Path $lockPath | Out-Null; $ownsLock = $true } catch {}
- if ($ownsLock) {
- try {
- if (-not (Slot-Ready)) {
- $env:BOATSTACK_MODE = "hydrate"
- $env:BOATSTACK_VERSION = $version
- $env:BOATSTACK_REPO = $root
- $hydrateCommand = $env:BOATSTACK_HYDRATE_COMMAND
- if (-not $hydrateCommand) { $hydrateCommand = '%s' }
- & powershell -NoProfile -Command $hydrateCommand 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) }
- }
- } catch {} finally { Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue }
- } else {
- for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $lockPath); $i++) { Start-Sleep -Seconds 1 }
- }
-}
-
-# File existence can become visible before the writer has finished publishing
-# the manifest. Never parse shared runtime state until the writer releases the
-# version-scoped hydration lock.
-for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $lockPath); $i++) { Start-Sleep -Seconds 1 }
-if (Test-Path -LiteralPath $lockPath) { Fail-Activation "shared runtime hydration did not complete" }
-
-if (-not (Slot-Ready)) { Fail-Activation "the exact pinned shared runtime is missing or unsafe" }
-try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json } catch { Fail-Activation "shared runtime manifest is malformed" }
-if ($manifest.boatstack_version -ne $version) { Fail-Activation "shared runtime version does not match the launcher pin" }
-if ($manifest.source_commit -ne $sourceCommit) { Fail-Activation "shared runtime source does not match the launcher pin" }
-if ($manifest.platform -ne $platform) { Fail-Activation "shared runtime platform does not match this host" }
-$actual = (Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash.ToLowerInvariant()
-if (($manifest.binary_sha256 -notmatch '^[0-9a-f]{64}$') -or ($actual -ne $manifest.binary_sha256)) { Fail-Activation "shared runtime checksum is invalid" }
-
-& $helper activate-worktree-runtime --repo $root
-if ($LASTEXITCODE -ne 0) { Fail-Activation "the pinned runtime could not activate this worktree" }
-$local = Join-Path $root ".product-loop/bin/boatstack-helper.exe"
-if (-not (Test-Path -LiteralPath $local -PathType Leaf)) { Fail-Activation "worktree runtime activation did not produce a safe helper" }
-if ($MyInvocation.ExpectingInput) {
- $input | & $local @args
-} else {
- & $local @args
-}
-exit $LASTEXITCODE
-`, recovery, Version, SourceCommit, recovery))
-}
diff --git a/boatstack/launcher_test.go b/boatstack/launcher_test.go
deleted file mode 100644
index 0c932b3..0000000
--- a/boatstack/launcher_test.go
+++ /dev/null
@@ -1,401 +0,0 @@
-package boatstack
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-func buildLauncherTestHelper(t *testing.T) string {
- t.Helper()
- binary := filepath.Join(t.TempDir(), helperName())
- command := exec.Command("go", "build", "-o", binary, "./cmd/boatstack-helper")
- command.Dir = "."
- if output, err := command.CombinedOutput(); err != nil {
- t.Fatalf("build helper: %v\n%s", err, output)
- }
- return binary
-}
-
-func launcherTestRepository(t *testing.T) (string, string) {
- t.Helper()
- repo := planningRepo(t)
- // Linked worktrees must preserve generated bytes even when a Windows-style
- // checkout policy would otherwise rewrite every LF to CRLF.
- runGit(t, repo, "config", "core.autocrlf", "true")
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module launcher-fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, BinaryPath: buildLauncherTestHelper(t), IntegrationChoice: "core", Yes: true}); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "initialize Boatstack")
- linked := filepath.Join(t.TempDir(), "linked")
- runGit(t, repo, "worktree", "add", "--detach", linked, "HEAD")
- return repo, linked
-}
-
-func runLauncher(t *testing.T, repo string, arguments ...string) (string, error) {
- t.Helper()
- command := launcherCommand(repo, arguments...)
- command.Dir = repo
- value, err := command.CombinedOutput()
- return string(value), err
-}
-
-func launcherCommand(repo string, arguments ...string) *exec.Cmd {
- if runtime.GOOS == "windows" {
- values := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", filepath.Join(repo, ".product-loop", "boatstack.ps1")}
- return exec.Command("powershell", append(values, arguments...)...)
- }
- return exec.Command(filepath.Join(repo, ".product-loop", "boatstack"), arguments...)
-}
-
-// control-law: tracked-launcher-selects-only-the-pinned-runtime
-func TestTrackedLauncherActivatesFreshLinkedWorktreeWithoutHookTrust(t *testing.T) {
- primary, linked := launcherTestRepository(t)
- local := filepath.Join(linked, ".product-loop", "bin")
- if _, err := os.Stat(local); !os.IsNotExist(err) {
- t.Fatalf("fresh linked worktree unexpectedly inherited local runtime: %v", err)
- }
-
- // A stale sibling-local helper is a counterexample only if the launcher scans
- // sibling worktrees. The exact shared-runtime path must make it irrelevant.
- primaryHelper := filepath.Join(primary, ".product-loop", "bin", helperName())
- if err := os.WriteFile(primaryHelper, []byte("#!/bin/sh\necho STALE-SIBLING\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- output, err := runLauncher(t, linked, "version")
- if err != nil {
- t.Fatalf("fresh-worktree version failed: %v\n%s", err, output)
- }
- if strings.Contains(output, "STALE-SIBLING") || !strings.Contains(output, Version) {
- t.Fatalf("launcher selected the wrong runtime: %q", output)
- }
- for _, arguments := range [][]string{
- {"doctor", "--repo", "."},
- {"run-preflight", "--repo", ".", "--health-only", "--json"},
- } {
- if output, err := runLauncher(t, linked, arguments...); err != nil {
- t.Fatalf("launcher %v failed: %v\n%s", arguments, err, output)
- }
- }
- if err := verifyLocalRuntime(linked); err != nil {
- t.Fatalf("launcher did not leave an exact local runtime: %v", err)
- }
-
- // Remove the exact shared slot and prove concurrent first use serializes one
- // pinned hydration, then independently activates the worktree-local runtime.
- binary, manifest, err := sharedRuntimePaths(linked, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- backup := t.TempDir()
- backupBinary := filepath.Join(backup, helperName())
- backupManifest := filepath.Join(backup, "runtime.lock.json")
- for source, target := range map[string]string{binary: backupBinary, manifest: backupManifest} {
- value, readErr := os.ReadFile(source)
- if readErr != nil {
- t.Fatal(readErr)
- }
- mode := os.FileMode(0o644)
- if source == binary {
- mode = 0o755
- }
- if writeErr := os.WriteFile(target, value, mode); writeErr != nil {
- t.Fatal(writeErr)
- }
- }
- if err := os.RemoveAll(filepath.Dir(binary)); err != nil {
- t.Fatal(err)
- }
- if err := os.RemoveAll(local); err != nil {
- t.Fatal(err)
- }
- hydrate := "mkdir -p " + quotedLiteral(t, filepath.Dir(binary)) +
- " && cp " + quotedLiteral(t, backupBinary) + " " + quotedLiteral(t, binary) +
- " && cp " + quotedLiteral(t, backupManifest) + " " + quotedLiteral(t, manifest)
- if runtime.GOOS == "windows" {
- hydrate = "$null = New-Item -ItemType Directory -Force -Path " + quotedLiteral(t, filepath.Dir(binary)) +
- "; Copy-Item -Force " + quotedLiteral(t, backupBinary) + " " + quotedLiteral(t, binary) +
- "; Copy-Item -Force " + quotedLiteral(t, backupManifest) + " " + quotedLiteral(t, manifest)
- }
- const workers = 4
- errors := make(chan string, workers)
- var group sync.WaitGroup
- for index := 0; index < workers; index++ {
- group.Add(1)
- go func() {
- defer group.Done()
- command := launcherCommand(linked, "version")
- command.Dir = linked
- command.Env = append(os.Environ(), "BOATSTACK_HYDRATE_COMMAND="+hydrate)
- value, runErr := command.CombinedOutput()
- if runErr != nil || !strings.Contains(string(value), Version) {
- errors <- runErrString(runErr, value)
- }
- }()
- }
- group.Wait()
- close(errors)
- for failure := range errors {
- t.Fatalf("concurrent pinned hydration failed: %s", failure)
- }
-}
-
-func TestTrackedLauncherWaitsForHydrationPublicationBeforeManifestValidation(t *testing.T) {
- _, linked := launcherTestRepository(t)
- binary, manifest, err := sharedRuntimePaths(linked, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- helperBytes, err := os.ReadFile(binary)
- if err != nil {
- t.Fatal(err)
- }
- manifestBytes, err := os.ReadFile(manifest)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.RemoveAll(filepath.Dir(binary)); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(binary, helperBytes, 0o755); err != nil {
- t.Fatal(err)
- }
- // A parseable but incomplete manifest makes premature validation
- // deterministic: an unlocked reader reports a version mismatch immediately.
- if err := os.WriteFile(manifest, []byte("{}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockPath := hydrateLockPath(t, binary)
- if err := os.MkdirAll(lockPath, 0o755); err != nil {
- t.Fatal(err)
- }
- defer os.RemoveAll(lockPath)
-
- type launcherResult struct {
- output string
- err error
- }
- result := make(chan launcherResult, 1)
- go func() {
- output, runErr := runLauncher(t, linked, "version")
- result <- launcherResult{output: output, err: runErr}
- }()
- select {
- case early := <-result:
- t.Fatalf("launcher validated before hydration publication: err=%v output=%s", early.err, early.output)
- case <-time.After(300 * time.Millisecond):
- }
-
- if err := os.WriteFile(manifest, manifestBytes, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.Remove(lockPath); err != nil {
- t.Fatal(err)
- }
- select {
- case completed := <-result:
- if completed.err != nil || !strings.Contains(completed.output, Version) {
- t.Fatalf("launcher did not accept the published runtime: err=%v output=%s", completed.err, completed.output)
- }
- case <-time.After(15 * time.Second):
- t.Fatal("launcher did not resume after hydration publication")
- }
-}
-
-// control-law: tracked-launcher-selects-only-the-pinned-runtime
-// Relation conformance for the detached failure mode: tracked launcher -> exact
-// hydrate operation -> Git-common bootstrap -> detached shared activation ->
-// verified local command dispatch.
-func TestTrackedLauncherHydratesDetachedRepositoryThroughGitCommonBootstrap(t *testing.T) {
- t.Setenv(stateRootEnv, t.TempDir())
- invalidateWorkspaceCache()
- primary, _ := launcherTestRepository(t)
- helper := buildLauncherTestHelper(t)
- result, err := AttachDetached(AttachOptions{Repo: primary, BinaryPath: helper})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach failed: %v %+v", err, result)
- }
- sharedBinary, _, err := sharedRuntimePaths(primary, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- bootstrapBinary, _, err := bootstrapRuntimePaths(primary, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{filepath.Dir(sharedBinary), filepath.Dir(bootstrapBinary), filepath.Join(primary, ".product-loop", "bin")} {
- if err := os.RemoveAll(path); err != nil {
- t.Fatal(err)
- }
- }
- hydrate := quotedLiteral(t, helper) + " hydrate-runtime --repo " + quotedLiteral(t, primary)
- if runtime.GOOS == "windows" {
- hydrate = "& " + hydrate
- }
- command := launcherCommand(primary, "version")
- command.Dir = primary
- command.Env = append(os.Environ(), "BOATSTACK_HYDRATE_COMMAND="+hydrate)
- value, runErr := command.CombinedOutput()
- if runErr != nil || !strings.Contains(string(value), Version) {
- t.Fatalf("detached launcher hydration failed: %v\n%s", runErr, value)
- }
- for name, path := range map[string]string{"Git-common bootstrap": bootstrapBinary, "detached shared runtime": sharedBinary} {
- if _, err := os.Stat(path); err != nil {
- t.Fatalf("%s missing after launcher hydration: %v", name, err)
- }
- }
- if err := verifyLocalRuntime(primary); err != nil {
- t.Fatalf("launcher did not dispatch through a verified local runtime: %v", err)
- }
-}
-
-func runErrString(err error, output []byte) string {
- if err == nil {
- return string(output)
- }
- return err.Error() + ": " + string(output)
-}
-
-// control-law: tracked-launcher-selects-only-the-pinned-runtime
-func TestTrackedLauncherRejectsTamperedSharedRuntimeBeforeDispatch(t *testing.T) {
- _, linked := launcherTestRepository(t)
- binary, manifest, err := sharedRuntimePaths(linked, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- originalBinary, err := os.ReadFile(binary)
- if err != nil {
- t.Fatal(err)
- }
- originalManifest, err := os.ReadFile(manifest)
- if err != nil {
- t.Fatal(err)
- }
- malformedError := "shared runtime version does not match the launcher pin"
- if runtime.GOOS == "windows" {
- malformedError = "shared runtime manifest is malformed"
- }
- tests := []struct {
- name string
- mutate func()
- error string
- }{
- {
- name: "binary checksum",
- mutate: func() {
- if err := os.WriteFile(binary, []byte("tampered"), 0o755); err != nil {
- t.Fatal(err)
- }
- },
- error: "shared runtime checksum is invalid",
- },
- {
- name: "malformed manifest",
- mutate: func() {
- if err := os.WriteFile(manifest, []byte("{not-json"), 0o644); err != nil {
- t.Fatal(err)
- }
- },
- error: malformedError,
- },
- {
- name: "version pin mismatch",
- mutate: func() {
- value := strings.Replace(string(originalManifest), `"boatstack_version": "`+Version+`"`, `"boatstack_version": "foreign"`, 1)
- if value == string(originalManifest) {
- value = strings.Replace(string(originalManifest), `"boatstack_version":"`+Version+`"`, `"boatstack_version":"foreign"`, 1)
- }
- if value == string(originalManifest) {
- value = strings.Replace(string(originalManifest), Version, "foreign", 1)
- }
- if err := os.WriteFile(manifest, []byte(value), 0o644); err != nil {
- t.Fatal(err)
- }
- },
- error: "shared runtime version does not match the launcher pin",
- },
- {
- name: "source pin mismatch",
- mutate: func() {
- value := strings.Replace(string(originalManifest), SourceCommit, "foreign-source", 1)
- if err := os.WriteFile(manifest, []byte(value), 0o644); err != nil {
- t.Fatal(err)
- }
- },
- error: "shared runtime source does not match the launcher pin",
- },
- }
- if runtime.GOOS != "windows" {
- tests = append(tests, struct {
- name string
- mutate func()
- error string
- }{
- name: "symlinked binary",
- mutate: func() {
- if err := os.Remove(binary); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(filepath.Join(t.TempDir(), "foreign"), binary); err != nil {
- t.Fatal(err)
- }
- },
- error: "the exact pinned shared runtime is missing or unsafe",
- })
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- _ = os.Remove(binary)
- if err := os.WriteFile(binary, originalBinary, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(manifest, originalManifest, 0o644); err != nil {
- t.Fatal(err)
- }
- test.mutate()
- command := launcherCommand(linked, "version")
- command.Dir = linked
- command.Env = append(os.Environ(), "BOATSTACK_AUTO_HYDRATE=0")
- value, runErr := command.CombinedOutput()
- output := string(value)
- if runErr == nil || !strings.Contains(output, "Boatstack runtime activation failed: "+test.error) {
- t.Fatalf("invalid runtime did not fail closed: %v\n%s", runErr, output)
- }
- if !strings.Contains(output, "Recovery:") || !strings.Contains(output, Version) {
- t.Fatalf("activation failure omitted exact pinned recovery: %s", output)
- }
- if _, statErr := os.Stat(filepath.Join(linked, ".product-loop", "bin", helperName())); !os.IsNotExist(statErr) {
- t.Fatalf("failed activation partially installed a local runtime: %v", statErr)
- }
- })
- }
-}
-
-// control-law: tracked-launcher-selects-only-the-pinned-runtime
-func TestLaunchersShareExactIdentityActivationAndRecoveryContract(t *testing.T) {
- for name, value := range map[string]string{
- "POSIX": string(launcherShellScript()),
- "PowerShell": string(launcherPowerShellScript()),
- } {
- for _, required := range []string{Version, SourceCommit, "activate-worktree-runtime", "binary_sha256", "Boatstack runtime activation failed", "BOATSTACK_MODE"} {
- if !strings.Contains(value, required) {
- t.Fatalf("%s launcher omits %q", name, required)
- }
- }
- if strings.Contains(strings.ToLower(value), "latest") || strings.Contains(value, ".product-loop/worktrees") {
- t.Fatalf("%s launcher contains a non-deterministic selection path", name)
- }
- }
-}
diff --git a/boatstack/lifecycle.go b/boatstack/lifecycle.go
deleted file mode 100644
index c427c48..0000000
--- a/boatstack/lifecycle.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// LifecycleSnapshot is the canonical control projection for one managed
-// delivery. It deliberately includes every durable dimension that can remove
-// or grant an actuator. Callers may render or enforce this answer; they must not
-// reconstruct authority from DeliverySlice.Status alone.
-// control-law: lifecycle-authority-includes-every-controlling-dimension
-type LifecycleSnapshot struct {
- SchemaVersion int `json:"schema_version"`
- Feature string `json:"feature"`
- State deliverycontrol.StateID `json:"state"`
- Mode string `json:"mode"`
- ResumeStage string `json:"resume_stage,omitempty"`
- ActiveSlice string `json:"active_slice,omitempty"`
- SliceStatus string `json:"slice_status,omitempty"`
- ActiveIndex int `json:"active_index"`
- TotalSlices int `json:"total_slices"`
- PlanLockSHA256 string `json:"plan_lock_sha256"`
- LockedPlanSHA256 string `json:"locked_plan_sha256,omitempty"`
- PlanSHA256 string `json:"plan_sha256,omitempty"`
- ObservationID string `json:"observation_id,omitempty"`
- Repository string `json:"repository"`
- RepositoryID string `json:"repository_id,omitempty"`
- WorktreeID string `json:"worktree_id,omitempty"`
- Branch string `json:"branch,omitempty"`
- ConfigurationSHA256 string `json:"configuration_sha256,omitempty"`
- HumanApproval bool `json:"human_approval"`
- ApprovalCurrent bool `json:"approval_current"`
- Fingerprint string `json:"fingerprint"`
-}
-
-func lockPlanSHA256(path string) (string, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- return "", err
- }
- var lock map[string]any
- if err := DecodeJSON("inspect plan lock", path, value, &lock); err != nil {
- return "", err
- }
- sha := strings.TrimSpace(stringValue(lock["plan_sha256"]))
- if sha == "" {
- return "", fmt.Errorf("plan lock does not bind plan_sha256")
- }
- return sha, nil
-}
-
-func lifecycleStateForSlice(status string) (deliverycontrol.StateID, error) {
- switch status {
- case StatusPending:
- return deliverycontrol.StatePending, nil
- case StatusBuild:
- return deliverycontrol.StateBuild, nil
- case StatusTestPassed:
- return deliverycontrol.StateTestPassed, nil
- case StatusReviewPassed:
- return deliverycontrol.StateReviewPassed, nil
- case StatusPublished:
- return deliverycontrol.StatePublished, nil
- default:
- return deliverycontrol.StateUnresolved, fmt.Errorf("unsupported delivery slice status %q", status)
- }
-}
-
-func lifecycleFingerprint(snapshot LifecycleSnapshot) (string, error) {
- snapshot.Fingerprint = ""
- value, err := MarshalJSON(snapshot)
- if err != nil {
- return "", err
- }
- return SHA256Bytes(value), nil
-}
-
-// ResolveLifecycleSnapshot reads one verified delivery and returns the exact
-// composite state consumed by next-status, flow, bootstrap, and safety
-// admission. It is read-only.
-func ResolveLifecycleSnapshot(repoPath, feature string) (LifecycleSnapshot, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return LifecycleSnapshot{}, err
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return LifecycleSnapshot{}, err
- }
- state, err := CurrentDeliveryState(repo, feature)
- if err != nil {
- return LifecycleSnapshot{}, err
- }
- slice, err := activeDeliverySlice(state)
- if err != nil {
- return LifecycleSnapshot{}, err
- }
- branch, _ := gitCommand(repo, "branch", "--show-current")
- config, _, configErr := LoadConfig(workspace.ProjectConfigPath())
- if configErr != nil {
- return LifecycleSnapshot{}, fmt.Errorf("managed lifecycle requires a valid Boatstack configuration: %w", configErr)
- }
- configSHA, _ := SHA256File(workspace.ProjectConfigPath())
- planPath := filepath.Join(workspace.FeatureDir(feature), "plan.md")
- lockPath := filepath.Join(workspace.FeatureDir(feature), "plan.lock.json")
- planSHA, _ := SHA256File(planPath)
- lockedPlanSHA, err := lockPlanSHA256(lockPath)
- if err != nil {
- return LifecycleSnapshot{}, err
- }
-
- snapshot := LifecycleSnapshot{
- SchemaVersion: 1, Feature: feature, Mode: strings.TrimSpace(state.Mode),
- ResumeStage: strings.TrimSpace(state.ResumeStage), ActiveSlice: slice.ID,
- SliceStatus: slice.Status, ActiveIndex: state.ActiveIndex, TotalSlices: len(state.Slices),
- PlanLockSHA256: state.PlanLockHash, LockedPlanSHA256: lockedPlanSHA, PlanSHA256: planSHA,
- ObservationID: strings.TrimSpace(state.ActiveObservationID), Repository: repo,
- RepositoryID: workspace.RepoID, WorktreeID: workspace.WorktreeID,
- Branch: strings.TrimSpace(branch), ConfigurationSHA256: configSHA,
- HumanApproval: config.Workflow.HumanPlanApproval,
- }
-
- switch snapshot.Mode {
- case "AMENDMENT_REQUIRED", "PLAN_INVALID":
- if snapshot.Mode == "PLAN_INVALID" {
- snapshot.State = deliverycontrol.StatePlanInvalid
- } else {
- snapshot.State = deliverycontrol.StateAmendmentRequired
- }
- if snapshot.PlanSHA256 != "" && snapshot.PlanSHA256 != snapshot.LockedPlanSHA256 {
- snapshot.State = deliverycontrol.StateAmendmentDrafted
- if check, checkErr := CheckPlan(planPath); checkErr == nil {
- if !snapshot.HumanApproval {
- snapshot.ApprovalCurrent = true
- snapshot.State = deliverycontrol.StateAmendmentApproved
- } else if receipt, approvalErr := CheckApprovalReceipt(filepath.Join(filepath.Dir(planPath), "approval.md"), check); approvalErr == nil {
- preApprovalSHA, fingerprintErr := lifecycleFingerprint(snapshot)
- if fingerprintErr == nil && receipt.LifecycleSHA256 == preApprovalSHA &&
- receipt.PlanLockSHA256 == snapshot.PlanLockSHA256 && receipt.ObservationID == snapshot.ObservationID {
- snapshot.ApprovalCurrent = true
- snapshot.State = deliverycontrol.StateAmendmentApproved
- }
- }
- }
- }
- default:
- snapshot.State, err = lifecycleStateForSlice(slice.Status)
- if err != nil {
- return LifecycleSnapshot{}, err
- }
- }
- snapshot.Fingerprint, err = lifecycleFingerprint(snapshot)
- if err != nil {
- return LifecycleSnapshot{}, err
- }
- return snapshot, nil
-}
-
-func amendmentLifecycleState(state deliverycontrol.StateID) bool {
- return state == deliverycontrol.StateAmendmentRequired ||
- state == deliverycontrol.StateAmendmentDrafted ||
- state == deliverycontrol.StateAmendmentApproved ||
- state == deliverycontrol.StatePlanInvalid
-}
diff --git a/boatstack/lifecycle_authority_conformance_test.go b/boatstack/lifecycle_authority_conformance_test.go
deleted file mode 100644
index b0f2816..0000000
--- a/boatstack/lifecycle_authority_conformance_test.go
+++ /dev/null
@@ -1,272 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-func amendmentPlanDocument(t *testing.T, feature, sourcePlan string) []byte {
- t.Helper()
- plan := twoSlicePlan()
- plan["feature_id"] = feature
- plan["source_plan_path"] = sourcePlan
- plan["spec_path"] = "feature-spec.md"
- plan["acceptance_criteria"].([]any)[0].(map[string]any)["text"] = "amended observable result"
- value, err := MarshalJSON(plan)
- if err != nil {
- t.Fatal(err)
- }
- return []byte("# Amended structured plan\n\n" + planMarkerStart + "\n```json\n" + strings.TrimSpace(string(value)) + "\n```\n" + planMarkerEnd + "\n")
-}
-
-func allowLifecyclePlanningHealth(t *testing.T) {
- t.Helper()
- previousBootstrap := bootstrapInstallationHealth
- previousPlanning := planningInstallationHealth
- bootstrapInstallationHealth = func(string) error { return nil }
- planningInstallationHealth = func(string) error { return nil }
- t.Cleanup(func() {
- bootstrapInstallationHealth = previousBootstrap
- planningInstallationHealth = previousPlanning
- })
-}
-
-func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) {
- allowLifecyclePlanningHealth(t)
- repo, feature := activateTwoSliceDelivery(t)
- observation, _, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "acceptance criteria changed",
- SourceStage: "build", Expected: "original result", Actual: "amended result",
- Classification: "requirement_amendment",
- })
- if err != nil {
- t.Fatal(err)
- }
-
- required, err := ResolveLifecycleSnapshot(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if required.State != deliverycontrol.StateAmendmentRequired || required.ObservationID != observation.ID {
- t.Fatalf("requirement amendment projected as %+v", required)
- }
- next, err := nextForDelivery(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if next.ObservedStage != string(deliverycontrol.StateAmendmentRequired) || next.NextOperation != "amend-plan" {
- t.Fatalf("requirement amendment has no owned planning transition: %+v", next)
- }
-
- if err := os.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
- t.Fatal(err)
- }
- sourcePlan := "docs/amendment-source.md"
- if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Accepted amendment\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- document := amendmentPlanDocument(t, feature, sourcePlan)
- prescription, err := ResolvePlanningBootstrap(BootstrapOptions{
- Repo: repo, Feature: feature, SourcePlan: sourcePlan, Artifact: "plan.md",
- Shell: BootstrapShellPOSIX, Document: document,
- })
- if err != nil {
- t.Fatal(err)
- }
- if prescription.Disposition != "AMEND_ACTIVE" || prescription.LifecycleSHA256 != required.Fingerprint || prescription.PreviousPlanLock != required.PlanLockSHA256 {
- t.Fatalf("amendment bootstrap was not bound to current lifecycle authority: %+v", prescription)
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, prescription.PlanningEnvelope)}); denied {
- t.Fatalf("%s denied the canonical amendment planning envelope: %s", host, output)
- }
- }
- if _, err := WritePlanningArtifact(PlanningWriteOptions{
- Repo: repo, Feature: feature, Artifact: "plan.md", Content: document,
- SourcePlan: sourcePlan, SourcePlanSHA256: prescription.SourcePlanSHA256,
- ExpectedLifecycleSHA256: prescription.LifecycleSHA256,
- ExpectedPlanLockSHA256: prescription.PreviousPlanLock,
- ExpectedObservation: prescription.ObservationID,
- }); err != nil {
- t.Fatal(err)
- }
-
- drafted, err := ResolveLifecycleSnapshot(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if drafted.State != deliverycontrol.StateAmendmentDrafted {
- t.Fatalf("amended plan did not enter approval state: %+v", drafted)
- }
- planPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.md")
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- baseline, err := PlanningBaselineForPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := RecordApproval(ApprovalRecordOptions{
- Repo: repo,
- PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-08-10T12:00:00Z",
- Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256,
- ExpectedLifecycleSHA256: drafted.Fingerprint, ExpectedPlanLockSHA256: drafted.PlanLockSHA256,
- ExpectedObservation: drafted.ObservationID,
- }); err != nil {
- t.Fatal(err)
- }
-
- approved, err := ResolveLifecycleSnapshot(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if approved.State != deliverycontrol.StateAmendmentApproved || !approved.ApprovalCurrent {
- t.Fatalf("approved amendment was not projected as activatable: %+v", approved)
- }
- directory := filepath.Dir(planPath)
- stateWithChangedObservation, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- stateWithChangedObservation.ActiveObservationID = "CHG-999"
- if err := saveDeliveryState(repo, stateWithChangedObservation); err != nil {
- t.Fatal(err)
- }
- if err := ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"),
- OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
- SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
- }); err == nil || !strings.Contains(err.Error(), "not currently approved") {
- t.Fatalf("activation accepted approval for a different amendment observation: %v", err)
- }
- stateWithChangedObservation.ActiveObservationID = observation.ID
- if err := saveDeliveryState(repo, stateWithChangedObservation); err != nil {
- t.Fatal(err)
- }
- if err := ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"),
- OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"),
- SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
- }); err != nil {
- t.Fatal(err)
- }
-
- active, err := ResolveLifecycleSnapshot(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if active.State != deliverycontrol.StateBuild || active.Mode != "NORMAL" || active.ObservationID != "" {
- t.Fatalf("activation did not restore ordinary delivery: %+v", active)
- }
- state, err := CurrentDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if len(state.PreviousPlanLocks) != 1 || state.PreviousPlanLocks[0] != required.PlanLockSHA256 {
- t.Fatalf("reactivation lost prior plan authority: %+v", state.PreviousPlanLocks)
- }
-}
-
-func TestAmendmentPlanningPrescriptionRejectsLifecycleDrift(t *testing.T) {
- allowLifecyclePlanningHealth(t)
- repo, feature := activateTwoSliceDelivery(t)
- if _, _, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "requirements changed", SourceStage: "build",
- Classification: "requirement_amendment",
- }); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
- t.Fatal(err)
- }
- sourcePlan := "docs/amendment-source.md"
- if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Accepted amendment\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- document := amendmentPlanDocument(t, feature, sourcePlan)
- prescription, err := ResolvePlanningBootstrap(BootstrapOptions{
- Repo: repo, Feature: feature, SourcePlan: sourcePlan, Artifact: "plan.md",
- Shell: BootstrapShellPOSIX, Document: document,
- })
- if err != nil {
- t.Fatal(err)
- }
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- state.ActiveObservationID = "CHG-999"
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- _, err = WritePlanningArtifact(PlanningWriteOptions{
- Repo: repo, Feature: feature, Artifact: "plan.md", Content: document,
- SourcePlan: sourcePlan, SourcePlanSHA256: prescription.SourcePlanSHA256,
- ExpectedLifecycleSHA256: prescription.LifecycleSHA256,
- ExpectedPlanLockSHA256: prescription.PreviousPlanLock,
- ExpectedObservation: prescription.ObservationID,
- })
- if err == nil || !strings.Contains(err.Error(), "lifecycle changed") {
- t.Fatalf("stale lifecycle prescription was not rejected: %v", err)
- }
-}
-
-func TestInvalidActivePlanUsesTheSameOwnedRewritePath(t *testing.T) {
- allowLifecyclePlanningHealth(t)
- repo, feature := activateTwoSliceDelivery(t)
- observation, _, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "active plan is structurally invalid",
- SourceStage: "build", Classification: "plan_invalid",
- })
- if err != nil {
- t.Fatal(err)
- }
- invalid, err := ResolveLifecycleSnapshot(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if invalid.State != deliverycontrol.StatePlanInvalid || invalid.ObservationID != observation.ID {
- t.Fatalf("invalid active plan did not enter the owned rewrite state: %+v", invalid)
- }
- if err := os.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
- t.Fatal(err)
- }
- sourcePlan := "docs/plan-repair-source.md"
- if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Corrected plan intent\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- document := amendmentPlanDocument(t, feature, sourcePlan)
- prescription, err := ResolvePlanningBootstrap(BootstrapOptions{
- Repo: repo, Feature: feature, SourcePlan: sourcePlan, Artifact: "plan.md",
- Shell: BootstrapShellPOSIX, Document: document,
- })
- if err != nil {
- t.Fatal(err)
- }
- if prescription.Disposition != "AMEND_ACTIVE" || prescription.LifecycleState != string(deliverycontrol.StatePlanInvalid) {
- t.Fatalf("invalid active plan did not receive a lifecycle-bound rewrite prescription: %+v", prescription)
- }
- if _, err := WritePlanningArtifact(PlanningWriteOptions{
- Repo: repo, Feature: feature, Artifact: "plan.md", Content: document,
- SourcePlan: sourcePlan, SourcePlanSHA256: prescription.SourcePlanSHA256,
- ExpectedLifecycleSHA256: prescription.LifecycleSHA256,
- ExpectedPlanLockSHA256: prescription.PreviousPlanLock,
- ExpectedObservation: prescription.ObservationID,
- }); err != nil {
- t.Fatal(err)
- }
- drafted, err := ResolveLifecycleSnapshot(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if drafted.State != deliverycontrol.StateAmendmentDrafted {
- t.Fatalf("corrected active plan did not enter the common approval path: %+v", drafted)
- }
-}
diff --git a/boatstack/lifecycle_event_registry_test.go b/boatstack/lifecycle_event_registry_test.go
deleted file mode 100644
index 12a2308..0000000
--- a/boatstack/lifecycle_event_registry_test.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package boatstack
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "io/fs"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "testing"
-)
-
-// lifecycleFieldClasses is the reviewed projection of every durable delivery
-// dimension. Adding a field without deciding whether it controls authority is
-// a CI failure, preventing a slice-only model from silently becoming stale.
-var lifecycleFieldClasses = map[string]string{
- "SchemaVersion": "identity",
- "Feature": "identity",
- "PlanLockHash": "authority",
- "PreviousPlanLocks": "history",
- "ActiveIndex": "control",
- "Slices": "control",
- "Mode": "control",
- "ResumeStage": "control",
- "ActiveObservationID": "authority",
- "RepairCounters": "control",
- "RepairAttempt": "derived",
- "SupersededReceipts": "evidence",
- "ParentDelivery": "lineage",
- "Goal": "control",
-}
-
-// lifecycleEventClasses inventories the code sites that read, resolve, render,
-// admit, or mutate lifecycle authority. The reviewed digest below changes when
-// a new entry path bypasses the canonical composite resolver.
-var lifecycleEventClasses = map[string]string{
- "LoadDeliveryState": "reader",
- "CurrentDeliveryState": "verified-reader",
- "saveDeliveryState": "writer",
- "ResolveLifecycleSnapshot": "resolver",
- "ResolveNext": "status",
- "nextForDelivery": "status",
- "NextControl": "renderer",
- "nextControlFromStatus": "renderer",
- "ResolvePlanningBootstrap": "renderer",
- "controlledPhaseTransition": "admission",
- "HookDecision": "host-admission",
- "WritePlanningArtifact": "writer",
- "ActivatePlan": "writer",
- "RecordApproval": "writer",
- "RecordChangeObservation": "writer",
- "RecordDeliveryGate": "writer",
-}
-
-func TestEveryDeliveryStateFieldHasReviewedLifecycleSemantics(t *testing.T) {
- set := token.NewFileSet()
- parsed, err := parser.ParseFile(set, "delivery.go", nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- fields := map[string]bool{}
- ast.Inspect(parsed, func(node ast.Node) bool {
- typeSpec, ok := node.(*ast.TypeSpec)
- if !ok || typeSpec.Name.Name != "DeliveryState" {
- return true
- }
- structure, ok := typeSpec.Type.(*ast.StructType)
- if !ok {
- t.Fatal("DeliveryState is not a struct")
- }
- for _, field := range structure.Fields.List {
- for _, name := range field.Names {
- fields[name.Name] = true
- }
- }
- return false
- })
- for field := range fields {
- if lifecycleFieldClasses[field] == "" {
- t.Errorf("DeliveryState.%s has no reviewed lifecycle classification", field)
- }
- }
- for field := range lifecycleFieldClasses {
- if !fields[field] {
- t.Errorf("lifecycle field registry contains removed DeliveryState.%s", field)
- }
- }
-}
-
-func TestLifecycleEventRegistryIsComplete(t *testing.T) {
- entries := []string{}
- set := token.NewFileSet()
- files := []string{}
- if err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if !entry.IsDir() && strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
- files = append(files, path)
- }
- return nil
- }); err != nil {
- t.Fatal(err)
- }
- for _, path := range files {
- if strings.HasSuffix(path, "_test.go") {
- continue
- }
- parsed, err := parser.ParseFile(set, path, nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- for _, declaration := range parsed.Decls {
- function, ok := declaration.(*ast.FuncDecl)
- if !ok || function.Body == nil {
- continue
- }
- counts := map[string]int{}
- ast.Inspect(function.Body, func(node ast.Node) bool {
- call, ok := node.(*ast.CallExpr)
- if !ok {
- return true
- }
- name := calledName(call)
- class, tracked := lifecycleEventClasses[name]
- if !tracked {
- return true
- }
- counts[name]++
- entries = append(entries, filepath.ToSlash(path)+":"+function.Name.Name+":"+name+":"+class+":"+strconv.Itoa(counts[name]))
- return true
- })
- }
- }
- sort.Strings(entries)
- digest := SHA256Bytes([]byte(strings.Join(entries, "\n")))
- const expected = "167ba36844018e8eefe5120416c1354714dbc888588a96369cc2c83457ce433b"
- if digest != expected {
- t.Fatalf("lifecycle event registry changed: got %s; classify the new or removed site and update the reviewed digest\n%s", digest, strings.Join(entries, "\n"))
- }
-}
diff --git a/boatstack/migrate.go b/boatstack/migrate.go
deleted file mode 100644
index ddc5b22..0000000
--- a/boatstack/migrate.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-)
-
-const CurrentConfigSchemaVersion = 1
-
-var currentConfigSchemaVersionOverride = CurrentConfigSchemaVersion
-
-type configMigration struct {
- from int
- to int
- apply func(map[string]any) (map[string]any, error)
-}
-
-var configMigrations []configMigration
-
-func currentSchemaVersion() int {
- return currentConfigSchemaVersionOverride
-}
-
-// MigrateConfigBytes migrates raw JSON configuration bytes to the latest CurrentConfigSchemaVersion.
-// It returns the upgraded JSON bytes, the original schema version, the final schema version,
-// a boolean indicating whether the content actually changed, and any error encountered.
-func MigrateConfigBytes(raw []byte) (upgraded []byte, from, to int, changed bool, err error) {
- if len(strings.TrimSpace(string(raw))) == 0 {
- return raw, 0, 0, false, nil
- }
-
- var partial map[string]any
- if err := json.Unmarshal(raw, &partial); err != nil {
- return nil, 0, 0, false, fmt.Errorf("failed to parse config JSON: %w", err)
- }
-
- targetVer := currentSchemaVersion()
-
- var fromVer int
- if v, exists := partial["schema_version"]; exists {
- switch val := v.(type) {
- case float64:
- fromVer = int(val)
- case int:
- fromVer = val
- default:
- return nil, 0, 0, false, fmt.Errorf("schema_version must be an integer")
- }
- } else {
- // Default to 1 if schema_version is missing
- fromVer = 1
- }
-
- if fromVer > targetVer {
- return nil, fromVer, 0, false, fmt.Errorf("config was written by a newer Boatstack; update Boatstack")
- }
-
- if fromVer == targetVer {
- return raw, fromVer, fromVer, false, nil
- }
-
- currentVer := fromVer
- data := partial
-
- for currentVer < targetVer {
- var found *configMigration
- for i := range configMigrations {
- if configMigrations[i].from == currentVer {
- found = &configMigrations[i]
- break
- }
- }
-
- if found == nil {
- return nil, fromVer, 0, false, fmt.Errorf("no migration found from version %d to %d", currentVer, currentVer+1)
- }
-
- var err error
- data, err = found.apply(data)
- if err != nil {
- return nil, fromVer, 0, false, fmt.Errorf("failed to apply migration from %d to %d: %w", found.from, found.to, err)
- }
-
- if found.to <= currentVer {
- return nil, fromVer, 0, false, fmt.Errorf("invalid migration path from %d to %d", found.from, found.to)
- }
-
- currentVer = found.to
- }
-
- data["schema_version"] = targetVer
-
- upgraded, err = json.MarshalIndent(data, "", " ")
- if err != nil {
- return nil, fromVer, 0, false, fmt.Errorf("failed to marshal migrated config: %w", err)
- }
- upgraded = append(upgraded, '\n')
-
- return upgraded, fromVer, targetVer, true, nil
-}
diff --git a/boatstack/migrate_effect_grade.go b/boatstack/migrate_effect_grade.go
deleted file mode 100644
index fbf6c8a..0000000
--- a/boatstack/migrate_effect_grade.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "os"
- "os/exec"
- "strings"
-)
-
-// MigrationEffectStatus is the verdict of grading a migration by its observed effect.
-type MigrationEffectStatus string
-
-const (
- // MigrationEffectSkipped means the project declared no migration commands, so no
- // effect was executed — a repository without a database is unaffected.
- MigrationEffectSkipped MigrationEffectStatus = "SKIPPED"
- // MigrationEffectPass means the migration applied and verified against the
- // disposable database.
- MigrationEffectPass MigrationEffectStatus = "PASS"
- // MigrationEffectFail means applying or verifying the migration failed — real
- // breakage the static guard cannot see, because a migration is inert as data.
- MigrationEffectFail MigrationEffectStatus = "FAIL"
-)
-
-// MigrationEffectResult is the outcome of GradeMigrationEffect.
-type MigrationEffectResult struct {
- Status MigrationEffectStatus
- Reason string
-}
-
-// GradeMigrationEffect grades a project's migrations by their OBSERVED EFFECT rather
-// than by their SQL text. Sandboxed-Effect law: when an effect's safety cannot be
-// certified statically, execute it in a disposable environment and read the oracle;
-// never approximate it by reading the source. The guard keeps treating a committed
-// migration as a data artifact (it is applied later by the controlled pipeline); this
-// harness IS that controlled executor for grading purposes.
-//
-// It runs the project's configured apply_command, then verify_command, via `sh -c`
-// with the caller-provided environment (which carries the disposable database
-// coordinate, BOATSTACK_MIGRATE_DB). PASS iff both succeed; FAIL if either fails;
-// SKIPPED when no apply_command is configured. The caller owns the disposable
-// database and its guaranteed teardown (a fresh-per-run service container in CI, or a
-// temp file removed by the test) — this function only executes and grades.
-func GradeMigrationEffect(repo string, extraEnv []string) (MigrationEffectResult, error) {
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- return MigrationEffectResult{}, err
- }
- apply := strings.TrimSpace(config.Project.Migration.ApplyCommand)
- verify := strings.TrimSpace(config.Project.Migration.VerifyCommand)
- if apply == "" {
- return MigrationEffectResult{Status: MigrationEffectSkipped, Reason: "no migration apply_command is configured; effect grading skipped"}, nil
- }
- if out, runErr := runMigrationShell(repo, apply, extraEnv); runErr != nil {
- return MigrationEffectResult{Status: MigrationEffectFail, Reason: "apply failed: " + firstOutputLine(out)}, nil
- }
- if verify != "" {
- if out, runErr := runMigrationShell(repo, verify, extraEnv); runErr != nil {
- return MigrationEffectResult{Status: MigrationEffectFail, Reason: "verify failed: " + firstOutputLine(out)}, nil
- }
- }
- return MigrationEffectResult{Status: MigrationEffectPass, Reason: "migration applied and verified against the disposable database"}, nil
-}
-
-// runMigrationShell keeps stdout (authority-bearing) and stderr (diagnostic)
-// separate. Grading needs only the exit status and a diagnostic line, so it reports
-// stderr, falling back to stdout when a tool writes its error there.
-func runMigrationShell(dir, command string, extraEnv []string) (string, error) {
- cmd := exec.Command("sh", "-c", command)
- cmd.Dir = dir
- cmd.Env = append(os.Environ(), extraEnv...)
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
- err := cmd.Run()
- diagnostic := strings.TrimSpace(stderr.String())
- if diagnostic == "" {
- diagnostic = strings.TrimSpace(stdout.String())
- }
- return diagnostic, err
-}
-
-func firstOutputLine(s string) string {
- s = strings.TrimSpace(s)
- if index := strings.IndexByte(s, '\n'); index >= 0 {
- s = s[:index]
- }
- if s == "" {
- return "(no output)"
- }
- return s
-}
diff --git a/boatstack/migrate_effect_grade_test.go b/boatstack/migrate_effect_grade_test.go
deleted file mode 100644
index f9b53b9..0000000
--- a/boatstack/migrate_effect_grade_test.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package boatstack
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "testing"
-)
-
-// migrateGradeRepo builds a repo whose project config declares the given migration
-// apply/verify commands. A disposable SQLite database is created under the test's
-// temp dir (removed automatically when the test ends — the guaranteed-teardown
-// invariant), seeded with one row, and returned as the BOATSTACK_MIGRATE_DB env the
-// commands read.
-func migrateGradeRepo(t *testing.T, apply, verify string) (repo string, env []string) {
- t.Helper()
- repo = t.TempDir()
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop", "features"), 0o755); err != nil {
- t.Fatal(err)
- }
- config := testConfig()
- config.Project.Migration = MigrationConfig{ApplyCommand: apply, VerifyCommand: verify}
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- db := filepath.Join(t.TempDir(), "disposable.sqlite")
- if out, seedErr := exec.Command("sqlite3", db, "CREATE TABLE accounts(id INTEGER); INSERT INTO accounts VALUES (1);").CombinedOutput(); seedErr != nil {
- t.Fatalf("seed disposable db: %v: %s", seedErr, out)
- }
- return repo, []string{"BOATSTACK_MIGRATE_DB=" + db}
-}
-
-// Sandboxed-Effect law: a migration's safety is graded by EXECUTING it against a
-// fresh, disposable database and reading the oracle — never approximated from its
-// SQL text. The guard treats the same migration as inert data; this harness is the
-// controlled executor. A repo that declares no migration commands is unaffected.
-func TestMigrationEffectGradingSandbox(t *testing.T) {
- if _, err := exec.LookPath("sqlite3"); err != nil {
- t.Skip("sqlite3 not available")
- }
- if _, err := exec.LookPath("sh"); err != nil {
- t.Skip("sh not available")
- }
-
- const apply = `sqlite3 "$BOATSTACK_MIGRATE_DB" < migrate.sql`
- // Verify the invariant the migration must preserve: the seeded row still exists.
- const verify = `test "$(sqlite3 "$BOATSTACK_MIGRATE_DB" 'SELECT count(*) FROM accounts')" = "1"`
-
- t.Run("skips cleanly when no migration commands are declared", func(t *testing.T) {
- repo, env := migrateGradeRepo(t, "", "")
- result, err := GradeMigrationEffect(repo, env)
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != MigrationEffectSkipped {
- t.Fatalf("unconfigured repo did not skip: %+v", result)
- }
- })
-
- t.Run("safe forward migration grades PASS", func(t *testing.T) {
- repo, env := migrateGradeRepo(t, apply, verify)
- // A declarative migration full of DDL — the guard treats this as data.
- if err := os.WriteFile(filepath.Join(repo, "migrate.sql"),
- []byte("ALTER TABLE accounts ADD COLUMN active INTEGER DEFAULT 1;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- result, err := GradeMigrationEffect(repo, env)
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != MigrationEffectPass {
- t.Fatalf("safe migration did not grade PASS: %+v", result)
- }
- })
-
- t.Run("destructive migration grades FAIL against the disposable db", func(t *testing.T) {
- repo, env := migrateGradeRepo(t, apply, verify)
- // Dropping the populated table is inert as TEXT (the static guard allows it as
- // a data artifact) but its EFFECT is caught by executing it in the sandbox.
- if err := os.WriteFile(filepath.Join(repo, "migrate.sql"),
- []byte("DROP TABLE accounts;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- result, err := GradeMigrationEffect(repo, env)
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != MigrationEffectFail {
- t.Fatalf("destructive migration was not caught by effect grading: %+v", result)
- }
- })
-}
diff --git a/boatstack/migrate_test.go b/boatstack/migrate_test.go
deleted file mode 100644
index 435dbc3..0000000
--- a/boatstack/migrate_test.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func TestMigrateConfigBytes_SyntheticChain(t *testing.T) {
- // Backup and restore
- oldMigrations := configMigrations
- oldOverride := currentConfigSchemaVersionOverride
- defer func() {
- configMigrations = oldMigrations
- currentConfigSchemaVersionOverride = oldOverride
- }()
-
- // Simulate current schema version is 3
- currentConfigSchemaVersionOverride = 3
-
- // Register synthetic v1->v2 and v2->v3 migrations
- configMigrations = []configMigration{
- {
- from: 1,
- to: 2,
- apply: func(data map[string]any) (map[string]any, error) {
- data["v1_to_v2_applied"] = true
- return data, nil
- },
- },
- {
- from: 2,
- to: 3,
- apply: func(data map[string]any) (map[string]any, error) {
- data["v2_to_v3_applied"] = true
- return data, nil
- },
- },
- }
-
- raw := []byte(`{
- "schema_version": 1,
- "project": {
- "name": "test-project"
- }
- }`)
-
- upgraded, from, to, changed, err := MigrateConfigBytes(raw)
- if err != nil {
- t.Fatalf("MigrateConfigBytes failed: %v", err)
- }
-
- if !changed {
- t.Error("expected config to be changed")
- }
- if from != 1 {
- t.Errorf("expected from version 1, got %d", from)
- }
- if to != 3 {
- t.Errorf("expected to version 3, got %d", to)
- }
-
- var parsed map[string]any
- if err := json.Unmarshal(upgraded, &parsed); err != nil {
- t.Fatalf("failed to unmarshal upgraded config: %v", err)
- }
-
- if parsed["v1_to_v2_applied"] != true {
- t.Error("expected v1->v2 migration to be applied")
- }
- if parsed["v2_to_v3_applied"] != true {
- t.Error("expected v2->v3 migration to be applied")
- }
- if int(parsed["schema_version"].(float64)) != 3 {
- t.Errorf("expected upgraded schema_version to be 3, got %v", parsed["schema_version"])
- }
-}
-
-func TestMigrateConfigBytes_NoOp(t *testing.T) {
- oldOverride := currentConfigSchemaVersionOverride
- defer func() { currentConfigSchemaVersionOverride = oldOverride }()
-
- currentConfigSchemaVersionOverride = 1
-
- raw := []byte(`{
- "schema_version": 1,
- "project": {
- "name": "test-project"
- }
- }`)
-
- upgraded, from, to, changed, err := MigrateConfigBytes(raw)
- if err != nil {
- t.Fatalf("MigrateConfigBytes failed: %v", err)
- }
-
- if changed {
- t.Error("expected config to be unchanged (no-op)")
- }
- if from != 1 || to != 1 {
- t.Errorf("expected from=1 and to=1, got from=%d, to=%d", from, to)
- }
- if !bytes.Equal(raw, upgraded) {
- t.Error("expected upgraded bytes to match raw bytes exactly")
- }
-}
-
-func TestMigrateConfigBytes_GapDetection(t *testing.T) {
- oldMigrations := configMigrations
- oldOverride := currentConfigSchemaVersionOverride
- defer func() {
- configMigrations = oldMigrations
- currentConfigSchemaVersionOverride = oldOverride
- }()
-
- currentConfigSchemaVersionOverride = 3
-
- // Missing v2->v3 migration
- configMigrations = []configMigration{
- {
- from: 1,
- to: 2,
- apply: func(data map[string]any) (map[string]any, error) {
- return data, nil
- },
- },
- }
-
- raw := []byte(`{"schema_version": 1}`)
- _, _, _, _, err := MigrateConfigBytes(raw)
- if err == nil {
- t.Fatal("expected error due to missing migration (gap), got nil")
- }
- if !strings.Contains(err.Error(), "no migration found from version 2 to 3") {
- t.Errorf("expected gap error message, got: %v", err)
- }
-}
-
-func TestMigrateConfigBytes_RejectNewer(t *testing.T) {
- oldOverride := currentConfigSchemaVersionOverride
- defer func() { currentConfigSchemaVersionOverride = oldOverride }()
-
- currentConfigSchemaVersionOverride = 1
-
- raw := []byte(`{"schema_version": 2}`)
- _, _, _, _, err := MigrateConfigBytes(raw)
- if err == nil {
- t.Fatal("expected error rejecting newer version, got nil")
- }
- if !strings.Contains(err.Error(), "config was written by a newer Boatstack; update Boatstack") {
- t.Errorf("expected reject newer error message, got: %v", err)
- }
-}
-
-func TestValidateConfig_AcceptanceTable(t *testing.T) {
- oldOverride := currentConfigSchemaVersionOverride
- defer func() { currentConfigSchemaVersionOverride = oldOverride }()
-
- currentConfigSchemaVersionOverride = 2
-
- tests := []struct {
- name string
- schemaVersion int
- wantErr string
- }{
- {
- name: "current version passes",
- schemaVersion: 2,
- wantErr: "",
- },
- {
- name: "older version is behind",
- schemaVersion: 1,
- wantErr: "config schema is behind; run /boatstack-update",
- },
- {
- name: "newer version is ahead",
- schemaVersion: 3,
- wantErr: "config was written by a newer Boatstack; update Boatstack",
- },
- {
- name: "invalid version < 1",
- schemaVersion: 0,
- wantErr: "project config schema_version must be >= 1",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := ProjectConfig{
- SchemaVersion: tt.schemaVersion,
- Project: Project{
- Name: "test-project",
- Commands: map[string]string{
- "test": "go test ./...",
- },
- },
- }
- err := ValidateConfig(cfg)
- if tt.wantErr == "" {
- if err != nil {
- t.Errorf("expected no error, got: %v", err)
- }
- } else {
- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
- t.Errorf("expected error containing %q, got: %v", tt.wantErr, err)
- }
- }
- })
- }
-}
-
-func TestValidateConfigPRVisualEvidencePolicy(t *testing.T) {
- for _, policy := range []string{"", "off", "suggest", "require"} {
- config := testConfig()
- config.Workflow.PRVisualEvidence = policy
- if err := ValidateConfig(config); err != nil {
- t.Fatalf("policy %q should be accepted: %v", policy, err)
- }
- }
- config := testConfig()
- config.Workflow.PRVisualEvidence = "sometimes"
- if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "pr_visual_evidence") {
- t.Fatalf("invalid policy was not rejected: %v", err)
- }
-}
-
-func TestDoctor_SchemaBehindAndAhead(t *testing.T) {
- oldOverride := currentConfigSchemaVersionOverride
- defer func() { currentConfigSchemaVersionOverride = oldOverride }()
-
- currentConfigSchemaVersionOverride = 2
-
- errBehind := fmt.Errorf("config schema is behind; run /boatstack-update")
- errAhead := fmt.Errorf("config was written by a newer Boatstack; update Boatstack")
-
- hintBehind := DoctorRepairHint(errBehind)
- hintAhead := DoctorRepairHint(errAhead)
-
- if !strings.Contains(hintBehind.Error(), "remediation: run /boatstack-update to migrate project configuration") {
- t.Errorf("expected behind hint, got: %v", hintBehind)
- }
- if !strings.Contains(hintAhead.Error(), "remediation: update your Boatstack installation to load this configuration") {
- t.Errorf("expected ahead hint, got: %v", hintAhead)
- }
-}
-
-func TestValidateUpdateWorkspace_ConformanceBlock(t *testing.T) {
- oldOverride := currentConfigSchemaVersionOverride
- t.Cleanup(func() {
- currentConfigSchemaVersionOverride = oldOverride
- })
-
- currentConfigSchemaVersionOverride = 1
-
- now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.5.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
-
- // Create a behind config (version 1)
- currentConfigSchemaVersionOverride = 1
- config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- currentConfigSchemaVersionOverride = 2
-
- // Set the current checked-out branch to the update branch
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
-
- // ValidateUpdateWorkspace with config.SchemaVersion = 1, while current is overridden to 2.
- err = ValidateUpdateWorkspace(repo, config)
- if err == nil {
- t.Fatal("expected ValidateUpdateWorkspace to fail for schema behind, got nil")
- }
- if !strings.Contains(err.Error(), "config schema is behind; run /boatstack-update") {
- t.Errorf("expected error to contain behind message, got: %v", err)
- }
-}
diff --git a/boatstack/mutation.go b/boatstack/mutation.go
deleted file mode 100644
index ade0656..0000000
--- a/boatstack/mutation.go
+++ /dev/null
@@ -1,637 +0,0 @@
-package boatstack
-
-// Transactional mutation boundary.
-//
-// Coding agents propose changes; this deterministic boundary decides what
-// becomes managed-artifact state. A MutationSet is validated in scratch,
-// promoted through one atomic all-or-nothing write, and recorded as a receipt
-// that both permits the next state and makes the change reversible without
-// fresh model reasoning.
-//
-// The boundary is the bounded actuator required by nonblocking supervisory
-// control: whenever the guard removes the raw ability to write, this API is the
-// sanctioned way to reach the next valid state. A rejected precondition (stale
-// base, outdated authority) never persists an identity, so a proposal recomputed
-// against current state can still succeed — rejection never deadlocks.
-//
-// It reuses Boatstack's existing integrity primitives: SHA256* for content
-// identity, atomicWriteMode (temp+fsync+rename) for promotion, gitCommonDir for
-// the receipt store, rejectSymlinkComponents/resolveRepositoryRelativePath for
-// path safety, and the operationID scheme + an O_EXCL lock mirroring
-// withOperationLock for idempotency and mutual exclusion.
-
-import (
- "encoding/base64"
- "errors"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-)
-
-const (
- mutationSchemaVersion = 1
- MutationProtocol = "operator.mutation.v1"
-)
-
-// Sentinel errors let callers (and tests) distinguish deterministic refusals
-// from genuine I/O faults. Every refusal below leaves accepted state unchanged.
-var (
- ErrMutationInvalidCandidate = errors.New("mutation candidate failed validation before promotion")
- ErrMutationStaleBase = errors.New("mutation rejected: a base artifact changed since it was read")
- ErrMutationOutdatedAuthority = errors.New("mutation rejected: supervisor authority changed since it was authorized")
- ErrMutationVerificationFailed = errors.New("mutation rolled back: post-write verification failed")
- ErrMutationScope = errors.New("mutation operation falls outside its declared scope")
- ErrMutationConflict = errors.New("mutation cannot be undone: the artifact diverged from its recorded post-image")
-)
-
-// MutationOperation is a single file change within a transaction. Candidate holds
-// the exact bytes to promote; the boundary asserts the on-disk image hashes to
-// the same value after promotion. When Absent is true the operation deletes the
-// path instead (Candidate is ignored), which makes the inverse of a create
-// expressible as an ordinary MutationSet — the boundary is closed under
-// inversion.
-type MutationOperation struct {
- Path string // repo-relative, slash-separated
- Candidate []byte // exact bytes to promote (ignored when Absent)
- Mode fs.FileMode // file mode; 0 means 0o644
- Absent bool // when true, the post-image is the file's absence (delete)
-}
-
-// MutationAuthority binds a mutation to the supervisor state that authorized it.
-// Expected is the token the proposal was authorized under; Observed is the token
-// recomputed from current supervisor state at apply time. A mismatch means the
-// authority has moved on and the mutation must be re-derived.
-type MutationAuthority struct {
- Expected string
- Observed string
-}
-
-// MutationSet is a proposed atomic change to managed artifacts. It is an
-// in-process request; the PreCheck/PostCheck hooks are not persisted.
-type MutationSet struct {
- Protocol string
- Kind string
- Scope []string // allowed repo-relative paths; every operation path must be listed
- Base map[string]string // repo-relative path -> expected pre-image sha256 ("" or absent = must not exist)
- Authority MutationAuthority
- Operations []MutationOperation
-
- // PreCheck validates the candidate bytes before anything is promoted. A
- // non-zero error means the candidate is invalid and accepted state is left
- // untouched (ErrMutationInvalidCandidate).
- PreCheck func(candidate map[string][]byte) error
- // PostCheck validates the promoted artifacts on disk. A non-zero error
- // triggers automatic rollback to the exact pre-image
- // (ErrMutationVerificationFailed).
- PostCheck func() error
-}
-
-// MutationFileChange records the before/after identity of one promoted path and
-// carries the inverse image needed to reverse the change deterministically.
-type MutationFileChange struct {
- Path string `json:"path"`
- ExistedBefore bool `json:"existed_before"`
- BeforeSHA256 string `json:"before_sha256,omitempty"`
- AfterSHA256 string `json:"after_sha256"`
- Mode fs.FileMode `json:"mode"`
- BeforeBase64 string `json:"before_base64,omitempty"` // inverse image; empty when the file was absent
-}
-
-// MutationReceipt is the durable record of an applied mutation. It is the single
-// source of truth for idempotent replay and for UndoMutation.
-type MutationReceipt struct {
- SchemaVersion int `json:"schema_version"`
- MutationID string `json:"mutation_id"`
- Protocol string `json:"protocol"`
- Kind string `json:"kind"`
- Status string `json:"status"` // APPLIED | ROLLED_BACK | REJECTED | UNDONE
- Reason string `json:"reason,omitempty"`
- Scope []string `json:"scope"`
- Changes []MutationFileChange `json:"changes"`
- Authority string `json:"authority_sha256,omitempty"`
- RecordedAt string `json:"recorded_at"`
- Root string `json:"root,omitempty"`
-}
-
-func mutationDirectory(repo string) (string, error) {
- common, err := gitCommonDir(repo)
- if err != nil {
- return "", err
- }
- return filepath.Join(common, "boatstack", "mutations", "v1"), nil
-}
-
-func mutationPath(repo, id string) (string, error) {
- segment, err := safeCacheSegment(id, "mutation id")
- if err != nil {
- return "", err
- }
- directory, err := mutationDirectory(repo)
- if err != nil {
- return "", err
- }
- path := filepath.Join(directory, segment+".json")
- common, err := gitCommonDir(repo)
- if err != nil {
- return "", err
- }
- if err := rejectSymlinkComponents(common, path); err != nil {
- return "", err
- }
- return path, nil
-}
-
-// withMutationLock mirrors withOperationLock: an O_EXCL lockfile beside the
-// receipt gives mutual exclusion across processes, with stale-lock reclamation.
-func withMutationLock(repo, id string, apply func() error) error {
- path, err := mutationPath(repo, id)
- if err != nil {
- return err
- }
- lock := strings.TrimSuffix(path, ".json") + ".lock"
- common, err := gitCommonDir(repo)
- if err != nil {
- return err
- }
- if err := rejectSymlinkComponents(common, lock); err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil {
- return err
- }
- for attempt := 0; attempt < 100; attempt++ {
- file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if openErr == nil {
- _, _ = fmt.Fprintf(file, "%d %s\n", os.Getpid(), operationTimestamp())
- _ = file.Close()
- defer os.Remove(lock)
- return apply()
- }
- if !isLockContention(openErr, lock) {
- return openErr
- }
- if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute {
- _ = os.Remove(lock)
- continue
- }
- time.Sleep(10 * time.Millisecond)
- }
- return fmt.Errorf("mutation %s is busy", id)
-}
-
-func loadMutationReceipt(repo, id string) (MutationReceipt, bool, error) {
- path, err := mutationPath(repo, id)
- if err != nil {
- return MutationReceipt{}, false, err
- }
- value, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return MutationReceipt{}, false, nil
- }
- if err != nil {
- return MutationReceipt{}, false, err
- }
- var receipt MutationReceipt
- if err := DecodeJSON("load mutation receipt", path, value, &receipt); err != nil {
- return MutationReceipt{}, false, err
- }
- return receipt, true, nil
-}
-
-func saveMutationReceipt(repo string, receipt MutationReceipt) error {
- path, err := mutationPath(repo, receipt.MutationID)
- if err != nil {
- return err
- }
- value, err := MarshalJSON(receipt)
- if err != nil {
- return err
- }
- return atomicWriteMode(path, value, 0o600)
-}
-
-func mutationMode(mode fs.FileMode) fs.FileMode {
- if mode == 0 {
- return 0o644
- }
- return mode
-}
-
-// currentImage returns the on-disk sha256 of a resolved path, or ("", false)
-// when the file is absent. A directory or unreadable file is a hard error.
-func currentImage(native string) (string, bool, error) {
- value, err := os.ReadFile(native)
- if os.IsNotExist(err) {
- return "", false, nil
- }
- if err != nil {
- return "", false, err
- }
- return SHA256Bytes(value), true, nil
-}
-
-// mutationIdentity derives the stable id for a MutationSet. It covers what is
-// being written (kind + each path and candidate hash) and the declared base, so
-// an identical proposal replays and a different proposal is a distinct mutation.
-// It deliberately excludes the transient Authority.Observed value so a rejected
-// authority check does not fork the identity of the corrected retry.
-func mutationIdentity(m MutationSet, ops []resolvedOperation, root string) string {
- parts := make([]string, 0, len(ops))
- for _, op := range ops {
- parts = append(parts, op.rel+"\x1f"+op.candidateHash+"\x1f"+m.Base[op.rel])
- }
- sort.Strings(parts)
- if root != "" {
- parts = append(parts, "root\x1f"+root)
- }
- fingerprint := SHA256Bytes([]byte(strings.Join(parts, "\x1e")))
- target := SHA256Bytes([]byte(strings.Join(sortedScope(m.Scope), "\x1e")))
- return operationID("mutation\x00"+strings.TrimSpace(m.Kind), target, fingerprint)
-}
-
-func sortedScope(scope []string) []string {
- out := append([]string(nil), scope...)
- sort.Strings(out)
- return out
-}
-
-type resolvedOperation struct {
- rel string // repo-relative, slash form
- native string // absolute filesystem path
- candidate []byte
- candidateHash string // "" denotes an absent (deleted) post-image
- mode fs.FileMode
- absent bool
-}
-
-func (m MutationSet) resolve(root string) ([]resolvedOperation, error) {
- if strings.TrimSpace(m.Protocol) != MutationProtocol {
- return nil, fmt.Errorf("mutation protocol must be %s", MutationProtocol)
- }
- if strings.TrimSpace(m.Kind) == "" {
- return nil, fmt.Errorf("mutation requires a kind")
- }
- if len(m.Operations) == 0 {
- return nil, fmt.Errorf("mutation requires at least one operation")
- }
- scope := map[string]bool{}
- for _, path := range m.Scope {
- scope[filepath.ToSlash(strings.TrimSpace(path))] = true
- }
- resolved := make([]resolvedOperation, 0, len(m.Operations))
- seen := map[string]bool{}
- for _, op := range m.Operations {
- rel := filepath.ToSlash(strings.TrimSpace(op.Path))
- if rel == "" {
- return nil, fmt.Errorf("mutation operation path is empty")
- }
- if !scope[rel] {
- return nil, fmt.Errorf("%w: %s", ErrMutationScope, rel)
- }
- if seen[rel] {
- return nil, fmt.Errorf("mutation names %s more than once", rel)
- }
- seen[rel] = true
- native, err := resolveRepositoryRelativePath(root, rel)
- if err != nil {
- return nil, err
- }
- if err := rejectSymlinkComponents(root, native); err != nil {
- return nil, err
- }
- if op.Absent {
- // An absent post-image has no candidate bytes; its hash sentinel is ""
- // so identity and receipts distinguish it from any real content.
- resolved = append(resolved, resolvedOperation{
- rel: rel, native: native, mode: mutationMode(op.Mode), absent: true,
- })
- continue
- }
- resolved = append(resolved, resolvedOperation{
- rel: rel, native: native, candidate: op.Candidate,
- candidateHash: SHA256Bytes(op.Candidate), mode: mutationMode(op.Mode),
- })
- }
- return resolved, nil
-}
-
-// ApplyMutation validates and promotes a MutationSet as one atomic transaction.
-// On success it returns an APPLIED receipt; on a deterministic refusal it returns
-// a receipt whose Status explains the refusal along with the matching sentinel
-// error, having left every artifact untouched.
-func ApplyMutation(repoPath string, m MutationSet) (MutationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return MutationReceipt{}, err
- }
- return applyMutationAt(repo, repo, "", m)
-}
-
-// ApplyControllerMutation promotes controller-owned artifacts beneath the
-// active WorkspaceContext export root while retaining receipts in the owning
-// repository's Git-common ledger.
-func ApplyControllerMutation(repoPath string, m MutationSet) (MutationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return MutationReceipt{}, err
- }
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return MutationReceipt{}, err
- }
- root := ctx.ExportRoot()
- rootMarker := ""
- if filepath.Clean(root) != filepath.Clean(repo) {
- rootMarker = root
- }
- return applyMutationAt(repo, root, rootMarker, m)
-}
-
-func applyMutationAt(repo, root, rootMarker string, m MutationSet) (MutationReceipt, error) {
- ops, err := m.resolve(root)
- if err != nil {
- return MutationReceipt{}, err
- }
- id := mutationIdentity(m, ops, rootMarker)
- authorityHash := SHA256Bytes([]byte(m.Authority.Expected))
-
- var result MutationReceipt
- err = withMutationLock(repo, id, func() error {
- // Idempotency: an identical proposal whose post-image is already on disk
- // replays the recorded receipt without writing again.
- if existing, ok, loadErr := loadMutationReceipt(repo, id); loadErr != nil {
- return loadErr
- } else if ok && existing.Status == "APPLIED" && receiptStillApplied(repo, existing) {
- result = existing
- return nil
- }
-
- // Preconditions run before any write and before any durable identity, so a
- // refusal cannot deadlock a corrected retry.
- if strings.TrimSpace(m.Authority.Expected) != strings.TrimSpace(m.Authority.Observed) {
- result = MutationReceipt{
- SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol,
- Kind: m.Kind, Status: "REJECTED", Reason: "outdated supervisor authority",
- Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(),
- Root: rootMarker,
- }
- return ErrMutationOutdatedAuthority
- }
- for _, op := range ops {
- expected := strings.TrimSpace(m.Base[op.rel])
- current, exists, imgErr := currentImage(op.native)
- if imgErr != nil {
- return imgErr
- }
- observed := ""
- if exists {
- observed = current
- }
- if observed != expected {
- result = MutationReceipt{
- SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol,
- Kind: m.Kind, Status: "REJECTED", Reason: "stale base artifact: " + op.rel,
- Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(),
- Root: rootMarker,
- }
- return ErrMutationStaleBase
- }
- }
-
- // Validate the candidate in scratch (never on the accepted tree).
- if m.PreCheck != nil {
- candidate := map[string][]byte{}
- for _, op := range ops {
- candidate[op.rel] = op.candidate
- }
- if checkErr := m.PreCheck(candidate); checkErr != nil {
- result = MutationReceipt{
- SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol,
- Kind: m.Kind, Status: "REJECTED", Reason: "invalid candidate: " + checkErr.Error(),
- Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(),
- Root: rootMarker,
- }
- return fmt.Errorf("%w: %v", ErrMutationInvalidCandidate, checkErr)
- }
- }
-
- // Capture the inverse image, then promote every file atomically.
- changes := make([]MutationFileChange, 0, len(ops))
- for _, op := range ops {
- before, existed, readErr := readInverse(op.native)
- if readErr != nil {
- return readErr
- }
- change := MutationFileChange{
- Path: op.rel, ExistedBefore: existed, AfterSHA256: op.candidateHash, Mode: op.mode,
- }
- if existed {
- change.BeforeSHA256 = SHA256Bytes(before)
- change.BeforeBase64 = base64.StdEncoding.EncodeToString(before)
- }
- changes = append(changes, change)
- }
-
- promoted := make([]promotedChange, 0, len(ops))
- promoteErr := func() error {
- for i, op := range ops {
- if op.absent {
- if rmErr := os.Remove(op.native); rmErr != nil && !os.IsNotExist(rmErr) {
- return rmErr
- }
- promoted = append(promoted, promotedChange{change: changes[i], native: op.native})
- if _, exists, imgErr := currentImage(op.native); imgErr != nil {
- return imgErr
- } else if exists {
- return fmt.Errorf("deleted artifact %s is still present after promotion", op.rel)
- }
- continue
- }
- if writeErr := atomicWriteMode(op.native, op.candidate, op.mode); writeErr != nil {
- return writeErr
- }
- promoted = append(promoted, promotedChange{change: changes[i], native: op.native})
- got, err := SHA256File(op.native)
- if err != nil {
- return err
- }
- if got != op.candidateHash {
- return fmt.Errorf("promoted bytes for %s do not match the candidate", op.rel)
- }
- }
- return nil
- }()
- if promoteErr != nil {
- rollbackMutation(promoted)
- return promoteErr
- }
-
- // Post-write verification against the real tree; failure rolls back.
- if m.PostCheck != nil {
- if checkErr := m.PostCheck(); checkErr != nil {
- rollbackMutation(promoted)
- result = MutationReceipt{
- SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol,
- Kind: m.Kind, Status: "ROLLED_BACK", Reason: "post-write verification failed: " + checkErr.Error(),
- Scope: sortedScope(m.Scope), RecordedAt: operationTimestamp(),
- Root: rootMarker,
- }
- return fmt.Errorf("%w: %v", ErrMutationVerificationFailed, checkErr)
- }
- }
-
- result = MutationReceipt{
- SchemaVersion: mutationSchemaVersion, MutationID: id, Protocol: MutationProtocol,
- Kind: m.Kind, Status: "APPLIED", Scope: sortedScope(m.Scope),
- Changes: changes, Authority: authorityHash, RecordedAt: operationTimestamp(),
- Root: rootMarker,
- }
- return saveMutationReceipt(repo, result)
- })
- return result, err
-}
-
-func readInverse(native string) ([]byte, bool, error) {
- value, err := os.ReadFile(native)
- if os.IsNotExist(err) {
- return nil, false, nil
- }
- if err != nil {
- return nil, false, err
- }
- return value, true, nil
-}
-
-// promotedChange pairs a recorded change with its resolved filesystem path so
-// rollback can restore the exact pre-image without re-resolving.
-type promotedChange struct {
- change MutationFileChange
- native string
-}
-
-// rollbackMutation restores each already-promoted file to its exact pre-image,
-// removing files that did not exist before. Best-effort: a failed restore leaves
-// the remaining files as-is, and the caller reports the original error.
-func rollbackMutation(promoted []promotedChange) {
- for i := len(promoted) - 1; i >= 0; i-- {
- change := promoted[i].change
- native := promoted[i].native
- if native == "" {
- continue
- }
- if !change.ExistedBefore {
- _ = os.Remove(native)
- continue
- }
- before, decodeErr := base64.StdEncoding.DecodeString(change.BeforeBase64)
- if decodeErr != nil {
- continue
- }
- _ = atomicWriteMode(native, before, mutationMode(change.Mode))
- }
-}
-
-// receiptStillApplied reports whether every recorded post-image is still the
-// on-disk truth, which is the precondition for treating a repeat call as a
-// no-op replay rather than a fresh mutation.
-func receiptStillApplied(repo string, receipt MutationReceipt) bool {
- root := repo
- if receipt.Root != "" {
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil || filepath.Clean(receipt.Root) != filepath.Clean(ctx.ExportRoot()) {
- return false
- }
- root = receipt.Root
- }
- for _, change := range receipt.Changes {
- native, err := resolveRepositoryRelativePath(root, change.Path)
- if err != nil {
- return false
- }
- current, exists, imgErr := currentImage(native)
- if imgErr != nil {
- return false
- }
- if change.AfterSHA256 == "" {
- // The recorded post-image is the file's absence (a delete).
- if exists {
- return false
- }
- continue
- }
- if !exists || current != change.AfterSHA256 {
- return false
- }
- }
- return true
-}
-
-// UndoMutation deterministically reverses an applied mutation by replaying its
-// inverse through the same boundary. The inverse is a first-class MutationSet:
-// each recorded before-image becomes the candidate (or an absent operation when
-// the file did not exist before), and the recorded post-image becomes the base
-// precondition. Because the inverse goes through ApplyMutation it is atomic and
-// verified, and it produces its own reversible receipt — so redo is simply
-// undoing that returned receipt. The base precondition is the divergence guard:
-// if any artifact no longer matches its recorded post-image, the boundary
-// refuses with a stale base, which UndoMutation surfaces as ErrMutationConflict
-// rather than clobbering newer work. Applying an already-reversed mutation
-// replays idempotently through ApplyMutation's identity short-circuit; the
-// original receipt is left immutable as history.
-func UndoMutation(repoPath, mutationID string) (MutationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return MutationReceipt{}, err
- }
- id := strings.TrimSpace(mutationID)
- receipt, ok, err := loadMutationReceipt(repo, id)
- if err != nil {
- return MutationReceipt{}, err
- }
- if !ok {
- return MutationReceipt{}, fmt.Errorf("no mutation receipt for %s", id)
- }
- if receipt.Status != "APPLIED" {
- return MutationReceipt{}, fmt.Errorf("mutation %s is not in an undoable state (%s)", id, receipt.Status)
- }
-
- scope := make([]string, 0, len(receipt.Changes))
- base := map[string]string{}
- ops := make([]MutationOperation, 0, len(receipt.Changes))
- for _, change := range receipt.Changes {
- scope = append(scope, change.Path)
- base[change.Path] = change.AfterSHA256 // "" means the post-image is absent
- if !change.ExistedBefore {
- ops = append(ops, MutationOperation{Path: change.Path, Absent: true})
- continue
- }
- before, decodeErr := base64.StdEncoding.DecodeString(change.BeforeBase64)
- if decodeErr != nil {
- return MutationReceipt{}, decodeErr
- }
- ops = append(ops, MutationOperation{Path: change.Path, Candidate: before, Mode: change.Mode})
- }
-
- inverse := MutationSet{
- Protocol: MutationProtocol,
- Kind: "undo:" + receipt.Kind,
- Scope: scope,
- Base: base,
- Operations: ops,
- }
- var undone MutationReceipt
- var applyErr error
- if receipt.Root != "" {
- undone, applyErr = ApplyControllerMutation(repo, inverse)
- } else {
- undone, applyErr = ApplyMutation(repo, inverse)
- }
- if errors.Is(applyErr, ErrMutationStaleBase) {
- return undone, fmt.Errorf("%w: %s diverged from its recorded post-image", ErrMutationConflict, receipt.MutationID)
- }
- return undone, applyErr
-}
diff --git a/boatstack/mutation_test.go b/boatstack/mutation_test.go
deleted file mode 100644
index 1a761d9..0000000
--- a/boatstack/mutation_test.go
+++ /dev/null
@@ -1,440 +0,0 @@
-package boatstack
-
-import (
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sync"
- "testing"
-)
-
-func writeRepoFile(t *testing.T, repo, rel, content string) string {
- t.Helper()
- path := filepath.Join(repo, filepath.FromSlash(rel))
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
- t.Fatal(err)
- }
- return SHA256Bytes([]byte(content))
-}
-
-func readRepoFile(t *testing.T, repo, rel string) (string, bool) {
- t.Helper()
- value, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(rel)))
- if os.IsNotExist(err) {
- return "", false
- }
- if err != nil {
- t.Fatal(err)
- }
- return string(value), true
-}
-
-func singleFileMutation(rel string, candidate string, base map[string]string) MutationSet {
- return MutationSet{
- Protocol: MutationProtocol,
- Kind: "test-artifact",
- Scope: []string{rel},
- Base: base,
- Authority: MutationAuthority{Expected: "authority-1", Observed: "authority-1"},
- Operations: []MutationOperation{{Path: rel, Candidate: []byte(candidate)}},
- }
-}
-
-// TestMutationBoundaryConformance is the acceptance suite for the transactional
-// mutation boundary. Each subtest is one of the six required properties stated
-// as a black-box behavior of the generic primitive, independent of any artifact.
-func TestMutationBoundaryConformance(t *testing.T) {
- t.Run("invalid candidate leaves accepted state unchanged", func(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "original")
- m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base})
- m.PreCheck = func(candidate map[string][]byte) error { return fmt.Errorf("candidate is not well-formed") }
-
- receipt, err := ApplyMutation(repo, m)
- if !errors.Is(err, ErrMutationInvalidCandidate) {
- t.Fatalf("expected invalid-candidate refusal, got %v", err)
- }
- if receipt.Status != "REJECTED" {
- t.Fatalf("expected REJECTED receipt, got %q", receipt.Status)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "original" {
- t.Fatalf("accepted state changed after invalid candidate: %q", got)
- }
- })
-
- t.Run("valid candidate promotes exact bytes", func(t *testing.T) {
- repo := nextTestRepo(t)
- m := singleFileMutation("src/auth.go", "exact-new-bytes", nil)
-
- receipt, err := ApplyMutation(repo, m)
- if err != nil {
- t.Fatal(err)
- }
- if receipt.Status != "APPLIED" {
- t.Fatalf("expected APPLIED, got %q", receipt.Status)
- }
- got, ok := readRepoFile(t, repo, "src/auth.go")
- if !ok || got != "exact-new-bytes" {
- t.Fatalf("promoted bytes are not exact: %q (present=%v)", got, ok)
- }
- if receipt.Changes[0].AfterSHA256 != SHA256Bytes([]byte("exact-new-bytes")) {
- t.Fatal("receipt after-hash does not match the promoted bytes")
- }
- })
-
- t.Run("post-write validation failure rolls back", func(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "original")
- m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base})
- m.PostCheck = func() error {
- // The candidate is on disk at this point; prove rollback undoes it.
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "rewritten" {
- t.Fatalf("post-check ran before promotion: %q", got)
- }
- return fmt.Errorf("verification failed")
- }
-
- receipt, err := ApplyMutation(repo, m)
- if !errors.Is(err, ErrMutationVerificationFailed) {
- t.Fatalf("expected verification-failed rollback, got %v", err)
- }
- if receipt.Status != "ROLLED_BACK" {
- t.Fatalf("expected ROLLED_BACK, got %q", receipt.Status)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "original" {
- t.Fatalf("rollback did not restore exact bytes: %q", got)
- }
- })
-
- t.Run("duplicate application is idempotent", func(t *testing.T) {
- repo := nextTestRepo(t)
- m := singleFileMutation("src/auth.go", "once", nil)
-
- first, err := ApplyMutation(repo, m)
- if err != nil {
- t.Fatal(err)
- }
- second, err := ApplyMutation(repo, m)
- if err != nil {
- t.Fatal(err)
- }
- if first.MutationID != second.MutationID {
- t.Fatalf("idempotent replay changed identity: %s vs %s", first.MutationID, second.MutationID)
- }
- if second.Status != "APPLIED" {
- t.Fatalf("replay was not reported APPLIED: %q", second.Status)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "once" {
- t.Fatalf("idempotent replay corrupted the artifact: %q", got)
- }
- })
-
- t.Run("changed base rejects the stale mutation", func(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "original")
- // The base drifts after it was read into the proposal.
- writeRepoFile(t, repo, "src/auth.go", "changed-underneath")
- m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base})
-
- receipt, err := ApplyMutation(repo, m)
- if !errors.Is(err, ErrMutationStaleBase) {
- t.Fatalf("expected stale-base refusal, got %v", err)
- }
- if receipt.Status != "REJECTED" {
- t.Fatalf("expected REJECTED, got %q", receipt.Status)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "changed-underneath" {
- t.Fatalf("stale mutation mutated state: %q", got)
- }
- })
-
- t.Run("changed supervisor authority rejects the outdated mutation", func(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "original")
- m := singleFileMutation("src/auth.go", "rewritten", map[string]string{"src/auth.go": base})
- m.Authority = MutationAuthority{Expected: "authorized-under-S1", Observed: "supervisor-now-S2"}
-
- receipt, err := ApplyMutation(repo, m)
- if !errors.Is(err, ErrMutationOutdatedAuthority) {
- t.Fatalf("expected outdated-authority refusal, got %v", err)
- }
- if receipt.Status != "REJECTED" {
- t.Fatalf("expected REJECTED, got %q", receipt.Status)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "original" {
- t.Fatalf("outdated-authority mutation mutated state: %q", got)
- }
- })
-}
-
-// TestMutationBoundaryPreservesALegalTrajectory encodes the nonblocking
-// supervisory-control invariant: a deterministic refusal never deadlocks. A
-// mutation recomputed against the current base and authority still reaches the
-// next valid state.
-func TestMutationBoundaryPreservesALegalTrajectory(t *testing.T) {
- t.Run("after stale-base rejection", func(t *testing.T) {
- repo := nextTestRepo(t)
- stale := writeRepoFile(t, repo, "src/auth.go", "v1")
- current := writeRepoFile(t, repo, "src/auth.go", "v2")
-
- if _, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "v3", map[string]string{"src/auth.go": stale})); !errors.Is(err, ErrMutationStaleBase) {
- t.Fatalf("expected stale-base refusal, got %v", err)
- }
- // Recompute the proposal against current state — a legal trajectory remains.
- receipt, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "v3", map[string]string{"src/auth.go": current}))
- if err != nil {
- t.Fatalf("recomputed mutation was blocked: %v", err)
- }
- if receipt.Status != "APPLIED" {
- t.Fatalf("recomputed mutation did not apply: %q", receipt.Status)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "v3" {
- t.Fatalf("recomputed mutation did not reach the next state: %q", got)
- }
- })
-
- t.Run("after outdated-authority rejection", func(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "v1")
- outdated := singleFileMutation("src/auth.go", "v2", map[string]string{"src/auth.go": base})
- outdated.Authority = MutationAuthority{Expected: "S1", Observed: "S2"}
- if _, err := ApplyMutation(repo, outdated); !errors.Is(err, ErrMutationOutdatedAuthority) {
- t.Fatalf("expected outdated-authority refusal, got %v", err)
- }
- // Re-authorize against the current supervisor token — same identity, now legal.
- current := singleFileMutation("src/auth.go", "v2", map[string]string{"src/auth.go": base})
- current.Authority = MutationAuthority{Expected: "S2", Observed: "S2"}
- receipt, err := ApplyMutation(repo, current)
- if err != nil {
- t.Fatalf("re-authorized mutation was blocked: %v", err)
- }
- if receipt.Status != "APPLIED" {
- t.Fatalf("re-authorized mutation did not apply: %q", receipt.Status)
- }
- })
-}
-
-func TestApplyMutationRejectsOutOfScopePath(t *testing.T) {
- repo := nextTestRepo(t)
- m := MutationSet{
- Protocol: MutationProtocol, Kind: "test-artifact",
- Scope: []string{"src/allowed.go"},
- Authority: MutationAuthority{Expected: "a", Observed: "a"},
- Operations: []MutationOperation{{Path: "src/secret.go", Candidate: []byte("x")}},
- }
- if _, err := ApplyMutation(repo, m); !errors.Is(err, ErrMutationScope) {
- t.Fatalf("expected scope refusal, got %v", err)
- }
- if _, ok := readRepoFile(t, repo, "src/secret.go"); ok {
- t.Fatal("out-of-scope path was written")
- }
-}
-
-func TestApplyMutationRejectsPathEscape(t *testing.T) {
- repo := nextTestRepo(t)
- m := singleFileMutation("../escape.go", "x", nil)
- if _, err := ApplyMutation(repo, m); err == nil {
- t.Fatal("path escaping the repository was accepted")
- }
-}
-
-func TestApplyMutationRejectsSymlinkComponent(t *testing.T) {
- repo := nextTestRepo(t)
- outside := t.TempDir()
- if err := os.Symlink(outside, filepath.Join(repo, "linked")); err != nil {
- t.Skipf("symlinks unavailable: %v", err)
- }
- m := singleFileMutation("linked/auth.go", "x", nil)
- if _, err := ApplyMutation(repo, m); err == nil {
- t.Fatal("mutation through a symlinked component was accepted")
- }
- if _, err := os.Stat(filepath.Join(outside, "auth.go")); err == nil {
- t.Fatal("mutation escaped through a symlink")
- }
-}
-
-func TestApplyMutationMultiFileRollbackIsAllOrNothing(t *testing.T) {
- repo := nextTestRepo(t)
- baseA := writeRepoFile(t, repo, "a.txt", "A0")
- baseB := writeRepoFile(t, repo, "b.txt", "B0")
- // c.txt is absent before the mutation.
- m := MutationSet{
- Protocol: MutationProtocol, Kind: "test-artifact",
- Scope: []string{"a.txt", "b.txt", "c.txt"},
- Base: map[string]string{"a.txt": baseA, "b.txt": baseB},
- Authority: MutationAuthority{Expected: "a", Observed: "a"},
- Operations: []MutationOperation{
- {Path: "a.txt", Candidate: []byte("A1")},
- {Path: "b.txt", Candidate: []byte("B1")},
- {Path: "c.txt", Candidate: []byte("C1")},
- },
- PostCheck: func() error { return fmt.Errorf("reject the whole batch") },
- }
- if _, err := ApplyMutation(repo, m); !errors.Is(err, ErrMutationVerificationFailed) {
- t.Fatalf("expected batch rollback, got %v", err)
- }
- if got, _ := readRepoFile(t, repo, "a.txt"); got != "A0" {
- t.Fatalf("a.txt not restored: %q", got)
- }
- if got, _ := readRepoFile(t, repo, "b.txt"); got != "B0" {
- t.Fatalf("b.txt not restored: %q", got)
- }
- if _, ok := readRepoFile(t, repo, "c.txt"); ok {
- t.Fatal("c.txt was created but existed nowhere before the rolled-back batch")
- }
-}
-
-func TestApplyMutationConcurrentDuplicatesApplyOnce(t *testing.T) {
- repo := nextTestRepo(t)
- m := singleFileMutation("src/auth.go", "converged", nil)
-
- const workers = 8
- var wg sync.WaitGroup
- results := make([]MutationReceipt, workers)
- errs := make([]error, workers)
- wg.Add(workers)
- for i := 0; i < workers; i++ {
- go func(index int) {
- defer wg.Done()
- results[index], errs[index] = ApplyMutation(repo, m)
- }(i)
- }
- wg.Wait()
-
- id := ""
- for i := 0; i < workers; i++ {
- if errs[i] != nil {
- t.Fatalf("worker %d failed: %v", i, errs[i])
- }
- if results[i].Status != "APPLIED" {
- t.Fatalf("worker %d saw %q", i, results[i].Status)
- }
- if id == "" {
- id = results[i].MutationID
- } else if results[i].MutationID != id {
- t.Fatalf("concurrent applies diverged in identity: %s vs %s", id, results[i].MutationID)
- }
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "converged" {
- t.Fatalf("concurrent applies corrupted the artifact: %q", got)
- }
-}
-
-func TestUndoMutationRestoresExactBytes(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "before")
- receipt, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base}))
- if err != nil {
- t.Fatal(err)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "after" {
- t.Fatalf("setup did not apply: %q", got)
- }
- undone, err := UndoMutation(repo, receipt.MutationID)
- if err != nil {
- t.Fatal(err)
- }
- // Undo is itself a mutation: it lands as an APPLIED inverse receipt whose own
- // inverse is the original post-image (that is what makes redo possible).
- if undone.Status != "APPLIED" {
- t.Fatalf("expected the inverse mutation to be APPLIED, got %q", undone.Status)
- }
- if undone.MutationID == receipt.MutationID {
- t.Fatal("undo did not produce a distinct inverse receipt")
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "before" {
- t.Fatalf("undo did not restore exact bytes: %q", got)
- }
-}
-
-func TestUndoMutationRemovesFileThatWasAbsentBefore(t *testing.T) {
- repo := nextTestRepo(t)
- receipt, err := ApplyMutation(repo, singleFileMutation("src/new.go", "created", nil))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := UndoMutation(repo, receipt.MutationID); err != nil {
- t.Fatal(err)
- }
- if _, ok := readRepoFile(t, repo, "src/new.go"); ok {
- t.Fatal("undo did not remove a file that was absent before the mutation")
- }
-}
-
-// TestUndoMutationIsReversibleAsRedo proves the boundary is closed under
-// inversion: undoing an undo receipt restores the original after-image. Because
-// undo lands as an ordinary APPLIED mutation, redo needs no separate verb — it is
-// just undo of the undo receipt.
-func TestUndoMutationIsReversibleAsRedo(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "before")
- applied, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base}))
- if err != nil {
- t.Fatal(err)
- }
- undone, err := UndoMutation(repo, applied.MutationID)
- if err != nil {
- t.Fatal(err)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "before" {
- t.Fatalf("undo did not restore the before-image: %q", got)
- }
- // Redo = undo of the undo receipt → the original after-image returns.
- redone, err := UndoMutation(repo, undone.MutationID)
- if err != nil {
- t.Fatal(err)
- }
- if redone.MutationID == undone.MutationID || redone.Status != "APPLIED" {
- t.Fatalf("redo did not land as a distinct APPLIED inverse: %+v", redone)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "after" {
- t.Fatalf("redo did not restore the after-image: %q", got)
- }
-}
-
-// TestUndoMutationReplayIsIdempotent proves that re-issuing the same undo is a
-// no-op: the inverse MutationSet is content-addressed, so the second call replays
-// the first undo receipt instead of forking a new one or clobbering later state.
-func TestUndoMutationReplayIsIdempotent(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "before")
- applied, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base}))
- if err != nil {
- t.Fatal(err)
- }
- first, err := UndoMutation(repo, applied.MutationID)
- if err != nil {
- t.Fatal(err)
- }
- second, err := UndoMutation(repo, applied.MutationID)
- if err != nil {
- t.Fatal(err)
- }
- if first.MutationID != second.MutationID {
- t.Fatalf("undo replay forked a new receipt: %s vs %s", first.MutationID, second.MutationID)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "before" {
- t.Fatalf("undo replay disturbed restored state: %q", got)
- }
-}
-
-func TestUndoMutationRejectsDivergedState(t *testing.T) {
- repo := nextTestRepo(t)
- base := writeRepoFile(t, repo, "src/auth.go", "before")
- receipt, err := ApplyMutation(repo, singleFileMutation("src/auth.go", "after", map[string]string{"src/auth.go": base}))
- if err != nil {
- t.Fatal(err)
- }
- // A later change touched the same file: undo must refuse rather than clobber.
- writeRepoFile(t, repo, "src/auth.go", "later-work")
- if _, err := UndoMutation(repo, receipt.MutationID); !errors.Is(err, ErrMutationConflict) {
- t.Fatalf("expected conflict refusal, got %v", err)
- }
- if got, _ := readRepoFile(t, repo, "src/auth.go"); got != "later-work" {
- t.Fatalf("undo clobbered diverged state: %q", got)
- }
-}
diff --git a/boatstack/mutation_undo.go b/boatstack/mutation_undo.go
deleted file mode 100644
index 4916888..0000000
--- a/boatstack/mutation_undo.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
-)
-
-// featurePathPattern extracts the feature slug from a managed-artifact path such
-// as ".product-loop/features//plan.lock.json". The mutation boundary is
-// domain-agnostic, but the undo verb needs the owning feature to decide whether a
-// reversal would strand delivery state.
-var featurePathPattern = regexp.MustCompile(`(?:^|/)\.product-loop/features/([a-z0-9]+(?:-[a-z0-9]+)*)/`)
-
-// ListMutationReceipts returns every durable mutation receipt in the repository,
-// most recent first. It backs the read-only `mutation-status` verb so an agent can
-// discover the receipt to reverse (and, for redo, the undo receipt to reverse in
-// turn — the boundary is closed under inversion).
-func ListMutationReceipts(repoPath string) ([]MutationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return nil, err
- }
- directory, err := mutationDirectory(repo)
- if err != nil {
- return nil, err
- }
- entries, err := os.ReadDir(directory)
- if os.IsNotExist(err) {
- return []MutationReceipt{}, nil
- }
- if err != nil {
- return nil, err
- }
- receipts := make([]MutationReceipt, 0, len(entries))
- for _, entry := range entries {
- name := entry.Name()
- if entry.IsDir() || !strings.HasSuffix(name, ".json") {
- continue
- }
- id := strings.TrimSuffix(name, ".json")
- receipt, ok, loadErr := loadMutationReceipt(repo, id)
- if loadErr != nil {
- return nil, loadErr
- }
- if ok {
- receipts = append(receipts, receipt)
- }
- }
- sort.SliceStable(receipts, func(i, j int) bool {
- return receipts[i].RecordedAt > receipts[j].RecordedAt
- })
- return receipts, nil
-}
-
-// GetMutationReceipt loads a single receipt by id for `mutation-status --mutation`.
-func GetMutationReceipt(repoPath, mutationID string) (MutationReceipt, bool, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return MutationReceipt{}, false, err
- }
- return loadMutationReceipt(repo, strings.TrimSpace(mutationID))
-}
-
-// UndoManagedMutation is the agent-facing, state-aware undo verb. It reverses a
-// Boatstack-generated managed artifact by re-applying the receipt's inverse through
-// the same transactional boundary (UndoMutation), but first refuses any reversal
-// that would strand delivery state: undoing a plan activation (or its compiled
-// artifacts) once a delivery gate receipt exists would remove the plan lock the
-// delivery state depends on, deadlocking the workflow at INVALID_STATE. The
-// primitive stays domain-agnostic; this thin wrapper carries the domain guard,
-// mirroring how RepairState self-guards.
-func UndoManagedMutation(repoPath, mutationID string) (MutationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return MutationReceipt{}, err
- }
- id := strings.TrimSpace(mutationID)
- receipt, ok, err := loadMutationReceipt(repo, id)
- if err != nil {
- return MutationReceipt{}, err
- }
- if !ok {
- return MutationReceipt{}, fmt.Errorf("no mutation receipt for %s", id)
- }
- if governsPlanActivation(receipt.Kind) {
- feature := featureForMutation(receipt)
- if feature != "" {
- progressed, gate, guardErr := deliveryProgressed(repo, feature)
- if guardErr != nil {
- return MutationReceipt{}, guardErr
- }
- if progressed {
- return MutationReceipt{}, fmt.Errorf("refusing to undo %s (%s): delivery has progressed for feature %s (%s); undo would strand delivery state without its plan lock", id, receipt.Kind, feature, gate)
- }
- }
- }
- return UndoMutation(repo, id)
-}
-
-// governsPlanActivation reports whether a mutation kind promotes the managed plan
-// artifacts whose removal a live delivery state depends on.
-func governsPlanActivation(kind string) bool {
- kind = strings.TrimSpace(kind)
- return kind == "plan-activation" || kind == "compiled-plan"
-}
-
-// featureForMutation extracts the owning feature slug from a receipt's changed
-// paths, or "" when the receipt does not touch a feature-scoped artifact.
-func featureForMutation(receipt MutationReceipt) string {
- for _, change := range receipt.Changes {
- if match := featurePathPattern.FindStringSubmatch(filepath.ToSlash(change.Path)); match != nil {
- return match[1]
- }
- }
- for _, path := range receipt.Scope {
- if match := featurePathPattern.FindStringSubmatch(filepath.ToSlash(path)); match != nil {
- return match[1]
- }
- }
- return ""
-}
-
-// deliveryProgressed reports whether any delivery gate receipt has been recorded
-// for the feature, which is the point past which undoing the plan activation would
-// strand delivery state. It returns a short human-readable reason on true.
-func deliveryProgressed(repo, feature string) (bool, string, error) {
- statePath, err := deliveryStatePath(repo, feature)
- if err != nil {
- return false, "", err
- }
- receiptsDir := filepath.Join(filepath.Dir(statePath), "receipts")
- sliceDirs, err := os.ReadDir(receiptsDir)
- if os.IsNotExist(err) {
- return false, "", nil
- }
- if err != nil {
- return false, "", err
- }
- for _, sliceDir := range sliceDirs {
- if !sliceDir.IsDir() {
- continue
- }
- gates, gatesErr := os.ReadDir(filepath.Join(receiptsDir, sliceDir.Name()))
- if gatesErr != nil {
- return false, "", gatesErr
- }
- for _, gate := range gates {
- if strings.HasSuffix(gate.Name(), ".json") {
- return true, fmt.Sprintf("gate receipt %s/%s", sliceDir.Name(), strings.TrimSuffix(gate.Name(), ".json")), nil
- }
- }
- }
- return false, "", nil
-}
diff --git a/boatstack/mutation_undo_test.go b/boatstack/mutation_undo_test.go
deleted file mode 100644
index 229f12d..0000000
--- a/boatstack/mutation_undo_test.go
+++ /dev/null
@@ -1,111 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// activationReceiptID returns the id of the APPLIED plan-activation receipt in a
-// repository, failing the test if none exists.
-func activationReceiptID(t *testing.T, repo string) string {
- t.Helper()
- receipts, err := ListMutationReceipts(repo)
- if err != nil {
- t.Fatal(err)
- }
- for _, receipt := range receipts {
- if receipt.Kind == "plan-activation" && receipt.Status == "APPLIED" {
- return receipt.MutationID
- }
- }
- t.Fatalf("no APPLIED plan-activation receipt among %d receipts", len(receipts))
- return ""
-}
-
-// TestUndoManagedMutationReversesFreshActivation proves the bounded undo verb
-// reverses a plan activation right after it happens (before any delivery gate has
-// been recorded): all four managed artifacts are removed by re-applying the
-// receipt's inverse through the same boundary.
-func TestUndoManagedMutationReversesFreshActivation(t *testing.T) {
- root, _, compiled, lock, _ := activatePolicyPlan(t)
- id := activationReceiptID(t, root)
- if _, err := UndoManagedMutation(root, id); err != nil {
- t.Fatalf("undo of a fresh activation should be allowed: %v", err)
- }
- for _, path := range []string{
- filepath.Join(compiled, "tasks.json"),
- filepath.Join(compiled, "test-matrix.json"),
- filepath.Join(compiled, "evidence.md"),
- lock,
- } {
- if _, err := os.Stat(path); !os.IsNotExist(err) {
- t.Fatalf("undo left managed artifact behind: %s", path)
- }
- }
-}
-
-// TestUndoManagedMutationRefusedAfterDeliveryGate proves the domain guard: once a
-// delivery gate receipt exists, undoing the activation would strand delivery state
-// without its plan lock, so the verb refuses (the primitive stays domain-agnostic;
-// the guard lives in the verb layer, mirroring RepairState).
-func TestUndoManagedMutationRefusedAfterDeliveryGate(t *testing.T) {
- root, _, compiled, lock, feature := activatePolicyPlan(t)
- id := activationReceiptID(t, root)
-
- statePath, err := deliveryStatePath(root, feature)
- if err != nil {
- t.Fatal(err)
- }
- gateDir := filepath.Join(filepath.Dir(statePath), "receipts", "slice-one")
- if err := os.MkdirAll(gateDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(gateDir, "test.json"), []byte("{}"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- _, err = UndoManagedMutation(root, id)
- if err == nil || !strings.Contains(err.Error(), "delivery has progressed") {
- t.Fatalf("expected undo to be refused after a delivery gate, got %v", err)
- }
- // The refusal must leave every artifact in place — a rejected undo is a no-op.
- for _, path := range []string{filepath.Join(compiled, "tasks.json"), lock} {
- if _, statErr := os.Stat(path); statErr != nil {
- t.Fatalf("refused undo removed managed artifact %s: %v", path, statErr)
- }
- }
-}
-
-// TestControlledPhaseTransitionAllowsBoundedRecoveryVerbs proves the guard
-// allowlists the two agent-facing verbs at any stage while still rejecting shell
-// metacharacters and non-helper executables.
-func TestControlledPhaseTransitionAllowsBoundedRecoveryVerbs(t *testing.T) {
- allowed := []struct {
- command string
- stage string
- }{
- {"boatstack-helper mutation-status --repo .", "DELIVERY"},
- {"boatstack-helper mutation-status --repo . --mutation abc --json", "INVALID_STATE"},
- {"boatstack-helper undo --repo . --mutation abc", "INVALID_STATE"},
- {"boatstack-helper undo --repo . --mutation abc --json", "DELIVERY"},
- }
- for _, test := range allowed {
- if !controlledPhaseTransition(test.command, test.stage) {
- t.Fatalf("expected %q to be allowed at stage %s", test.command, test.stage)
- }
- }
-
- rejected := []string{
- "boatstack-helper undo --repo . --mutation abc; rm -rf .",
- "boatstack-helper undo --repo . --mutation abc && echo done",
- "git undo --mutation abc",
- "rm -rf .product-loop/plan.lock.json",
- }
- for _, command := range rejected {
- if controlledPhaseTransition(command, "INVALID_STATE") {
- t.Fatalf("expected %q to be rejected", command)
- }
- }
-}
diff --git a/boatstack/next.go b/boatstack/next.go
deleted file mode 100644
index 4d1d366..0000000
--- a/boatstack/next.go
+++ /dev/null
@@ -1,796 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "unicode/utf8"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-const nextStatusSchemaVersion = 2
-
-// NextStatus is the read-only, host-neutral projection of Boatstack's current
-// workflow position. Conversation and terminal context are deliberately absent:
-// adapters may present them as context, but they are not workflow evidence.
-type NextStatus struct {
- SchemaVersion int `json:"schema_version"`
- SupervisionMode string `json:"supervision_mode"`
- ControllerRoot string `json:"controller_root"`
- VerificationStatus string `json:"verification_status"`
- Feature string `json:"feature,omitempty"`
- ActiveSlice string `json:"active_slice,omitempty"`
- SliceIndex int `json:"slice_index,omitempty"`
- TotalSlices int `json:"total_slices,omitempty"`
- ObservedStage string `json:"observed_stage"`
- NextOperation string `json:"next_operation"`
- LifecycleState string `json:"lifecycle_state,omitempty"`
- LifecycleSHA256 string `json:"lifecycle_sha256,omitempty"`
- ObservationID string `json:"observation_id,omitempty"`
- PlanLockSHA256 string `json:"plan_lock_sha256,omitempty"`
- Operator DecisionOperator `json:"operator,omitempty"`
- Reason string `json:"reason"`
- BlockingAmbiguity []string `json:"blocking_ambiguity,omitempty"`
- Lifecycle string `json:"lifecycle,omitempty"`
- GoalEscape string `json:"goal_escape,omitempty"`
- PRPhase string `json:"pr_phase,omitempty"`
- PRReviewDecision string `json:"pr_review_decision,omitempty"`
- PRMergeState string `json:"pr_merge_state,omitempty"`
- PRFailingChecks []string `json:"pr_failing_checks,omitempty"`
- PRURL string `json:"pr_url,omitempty"`
- HeadBranch string `json:"head_branch,omitempty"`
- ParentDelivery string `json:"parent_delivery,omitempty"`
- RunTarget RunTarget `json:"run_target,omitempty"`
- PolicyDecisions int `json:"policy_decisions,omitempty"`
- // VisualPublication surfaces an owed evidence attachment of a published
- // PR ("visual_pending" or "manual_required"); empty otherwise.
- VisualPublication string `json:"visual_publication,omitempty"`
-}
-
-func decorateAutonomyStatus(repo string, status NextStatus) NextStatus {
- if status.Feature == "" {
- return status
- }
- path := filepath.Join(WorkspaceFor(repo).FeatureDir(status.Feature), "autonomy.md")
- value, err := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true)
- if err != nil {
- return status
- }
- status.RunTarget = RunTarget(stringValue(value["target"]))
- if decisions, ok := objectSlice(value["decisions"]); ok {
- status.PolicyDecisions = len(decisions)
- }
- if status.RunTarget == RunTargetVerified && (status.ObservedStage == StatusReviewPassed || status.ObservedStage == "PR_PREVIEW") {
- status.ObservedStage = "VERIFIED_TARGET_REACHED"
- status.NextOperation = "none"
- status.Reason = "The autonomous run reached its build, test, and review target without publishing."
- }
- return status
-}
-
-func blockedNextStatus(stage, operation, reason string, ambiguity ...string) NextStatus {
- return NextStatus{
- SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "BLOCKED",
- ObservedStage: stage, NextOperation: operation, Reason: reason,
- BlockingAmbiguity: ambiguity,
- }
-}
-
-func featurePlanCandidates(repo string) ([]string, error) {
- root := WorkspaceFor(repo).FeatureRoot()
- entries, err := os.ReadDir(root)
- if os.IsNotExist(err) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- features := []string{}
- for _, entry := range entries {
- if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) {
- continue
- }
- directory := filepath.Join(root, entry.Name())
- if !fileExists(filepath.Join(directory, "plan.md")) {
- continue
- }
- // A feature that has been locked (built) or shipped is past planning and
- // must never re-register as an open plan candidate, even when its
- // ephemeral per-worktree delivery state.json was destroyed by worktree
- // cleanup on ship. plan.lock.json / pr.md are the durable committed
- // signals, mirroring orphanedFeatureArtifacts.
- if fileExists(filepath.Join(directory, "plan.lock.json")) || fileExists(filepath.Join(directory, "pr.md")) {
- continue
- }
- statePath, stateErr := deliveryStatePath(repo, entry.Name())
- if stateErr != nil {
- return nil, stateErr
- }
- if !fileExists(statePath) {
- features = append(features, entry.Name())
- }
- }
- sort.Strings(features)
- return features, nil
-}
-
-func orphanedFeatureArtifacts(repo string) ([]string, error) {
- root := WorkspaceFor(repo).FeatureRoot()
- entries, err := os.ReadDir(root)
- if os.IsNotExist(err) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- orphans := []string{}
- for _, entry := range entries {
- if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) {
- continue
- }
- directory := filepath.Join(root, entry.Name())
- if fileExists(filepath.Join(directory, "pr.md")) && !fileExists(filepath.Join(directory, "plan.lock.json")) {
- orphans = append(orphans, entry.Name())
- }
- }
- sort.Strings(orphans)
- return orphans, nil
-}
-
-func nextForDelivery(repo, feature string) (NextStatus, error) {
- snapshot, err := ResolveLifecycleSnapshot(repo, feature)
- if err != nil {
- return NextStatus{}, err
- }
- status := NextStatus{
- SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED",
- Feature: feature, ActiveSlice: snapshot.ActiveSlice, ObservedStage: string(snapshot.State),
- SliceIndex: snapshot.ActiveIndex + 1, TotalSlices: snapshot.TotalSlices,
- LifecycleState: string(snapshot.State), LifecycleSHA256: snapshot.Fingerprint,
- ObservationID: snapshot.ObservationID, PlanLockSHA256: snapshot.PlanLockSHA256,
- }
- switch snapshot.State {
- case deliverycontrol.StateAmendmentRequired, deliverycontrol.StatePlanInvalid:
- status.NextOperation = "amend-plan"
- status.Reason = "The active delivery requires a lifecycle-bound plan amendment before its gates may continue."
- case deliverycontrol.StateAmendmentDrafted:
- status.NextOperation = "plan-gate"
- status.Reason = "The amended plan differs from the active lock and must pass validation and exact approval before reactivation."
- case deliverycontrol.StateAmendmentApproved:
- status.NextOperation = "build"
- status.Reason = "The amended plan is current and authorized; reactivate it to install the replacement lock and resume delivery."
- case StatusBuild:
- status.NextOperation = "build"
- status.Reason = "The approved delivery slice is active and has no current test-gate receipt."
- case StatusTestPassed:
- status.NextOperation = "review-gate"
- status.Reason = "The active delivery slice has current test evidence and still requires review."
- case StatusReviewPassed:
- previewPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "pr.md")
- if preview, previewErr := ParsePRPreview(previewPath); previewErr == nil && preview.Feature == feature && preview.SliceID == snapshot.ActiveSlice {
- status.ObservedStage = "PR_PREVIEW"
- status.Reason = "A reviewer-ready PR preview exists for the reviewed active slice and must be reconfirmed through the ship gate."
- } else {
- status.Reason = "The active delivery slice has current test and review evidence and is ready for PR preparation."
- }
- status.NextOperation = "ship-gate"
- default:
- return NextStatus{}, fmt.Errorf("managed delivery slice %s has unsupported lifecycle state %q", snapshot.ActiveSlice, snapshot.State)
- }
- return decorateAutonomyStatus(repo, status), nil
-}
-
-func nextForPublished(repo string, state DeliveryState) NextStatus {
- pr := observePublishedPR(repo, state)
- persistObservedTerminalPRState(repo, state, pr)
- terminal := resolveDeliveryTerminal(repo, state.Feature)
- status := publishedNextStatus(state, pr, terminal, observeVisualPublication(repo, state.Feature))
- // A fired escape is cached best-effort so the demotion holds offline in a
- // fresh session — the same bounded bypass as the terminal PRState cache.
- // control-law: goal-escape-demotes-to-operator-and-stops
- if terminal == TerminalMerged && status.GoalEscape != "" && status.Lifecycle != "PUBLISHED_MERGED" {
- persistGoalEscape(repo, state, status.GoalEscape)
- }
- return status
-}
-
-// publishedNextStatus is the pure mapping from one live PR observation to the
-// published NextStatus. Split from nextForPublished so the frontier report can
-// present the same projection without nextForPublished's best-effort terminal
-// cache write. control-law: frontier-reports-never-mutates
-// observeVisualPublication reads the owed-attachment state of a feature's
-// visual evidence, best-effort and read-only: any load failure is today's
-// empty answer, never a block, and only the two owed states surface —
-// "pending" belongs to first publication (publish-pr) and "published" owes
-// nothing. control-law: frontier-reports-never-mutates
-func observeVisualPublication(repo, feature string) string {
- key, err := visualEvidenceKey("managed", feature, "")
- if err != nil {
- return ""
- }
- manifest, err := LoadPRVisualEvidence(repo, key)
- if err != nil {
- return ""
- }
- switch manifest.Publication.State {
- case "visual_pending", "manual_required":
- return manifest.Publication.State
- }
- return ""
-}
-
-func publishedNextStatus(state DeliveryState, pr publishedPRObservation, terminal DeliveryTerminal, visualPublication string) NextStatus {
- _, sliceID, _ := deliveryBranchAndSlice(state)
- status := NextStatus{
- SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED",
- Feature: state.Feature, ActiveSlice: sliceID, SliceIndex: len(state.Slices),
- TotalSlices: len(state.Slices), ObservedStage: "PUBLISHED", NextOperation: "none",
- Lifecycle: pr.Lifecycle, PRURL: pr.URL, HeadBranch: pr.Branch,
- ParentDelivery: state.ParentDelivery,
- PRPhase: string(pr.Phase), PRReviewDecision: pr.ReviewDecision,
- PRMergeState: pr.MergeState, PRFailingChecks: pr.FailingChecks,
- }
- if terminal == TerminalMerged && pr.Lifecycle != "PUBLISHED_MERGED" && len(state.Slices) > 0 {
- index := state.ActiveIndex
- if index >= len(state.Slices) {
- index = len(state.Slices) - 1
- }
- status.GoalEscape = evaluateGoalEscape(state.Slices[index], pr)
- }
- switch pr.Lifecycle {
- case "PUBLISHED_MERGED":
- status.ObservedStage = "FEATURE_COMPLETE"
- status.Reason = fmt.Sprintf("The published PR for feature %q is merged.", state.Feature)
- case "PUBLISHED_OPEN":
- // The observed PR phase sharpens the reason when it is known; the
- // pre-phase sentence remains the fallback so a degraded observation
- // reads exactly as it always did.
- switch pr.Phase {
- case PRPhaseChecksPending:
- status.Reason = fmt.Sprintf("Feature %q is published; checks on its PR are still running.", state.Feature)
- case PRPhaseChecksFailing:
- status.Reason = fmt.Sprintf("Feature %q is published; %d PR check(s) are failing (%s).", state.Feature, pr.ChecksFailed, strings.Join(pr.FailingChecks, ", "))
- case PRPhaseChangesRequested:
- status.Reason = fmt.Sprintf("Feature %q is published; its PR review requested changes.", state.Feature)
- case PRPhaseReviewRequired:
- status.Reason = fmt.Sprintf("Feature %q is published; its PR checks pass and a required review approval is still owed.", state.Feature)
- case PRPhaseMergeEligible:
- status.Reason = fmt.Sprintf("Feature %q is published; its PR has passing checks, satisfied reviews, and a clean merge state.", state.Feature)
- default:
- status.Reason = fmt.Sprintf("Feature %q is published in an open PR; review and required checks may still produce a corrective delivery.", state.Feature)
- }
- case "PUBLISHED_CLOSED":
- status.Reason = fmt.Sprintf("The PR for feature %q is closed without a verified merge; a future correction requires a fresh PR.", state.Feature)
- default:
- status.Reason = fmt.Sprintf("Feature %q is published, but its PR state could not be verified.", state.Feature)
- }
- if pr.Lifecycle == "PUBLISHED_OPEN" {
- status.VisualPublication = visualPublication
- switch visualPublication {
- case "visual_pending":
- status.Reason += " Its Boatstack visual-evidence comment is still owed; Boatstack can retry the attachment (attach-evidence)."
- case "manual_required":
- status.Reason += " Its legacy visual-evidence state must be retried through external hosting."
- }
- }
- if status.GoalEscape != "" {
- status.Reason = fmt.Sprintf("Feature %q is published; the merged-goal pursuit is paused because %s. Record the correction to start a new cycle, or handle the pull request yourself.", state.Feature, goalEscapeReason(status.GoalEscape))
- }
- return status
-}
-
-func completedManagedStates(repo string) ([]DeliveryState, error) {
- directory, err := deliveryStateDirectory(repo)
- if err != nil {
- return nil, err
- }
- entries, err := os.ReadDir(directory)
- if os.IsNotExist(err) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- completed := []DeliveryState{}
- for _, entry := range entries {
- if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) {
- continue
- }
- state, err := CurrentDeliveryState(repo, entry.Name())
- if err != nil {
- // A completed delivery that cannot be verified on THIS branch — e.g. a
- // divergent or absent committed plan lock for work shipped on another
- // branch that shares the delivery store — must not poison resolution of
- // an unrelated new feature. Structurally corrupt state is already
- // surfaced upstream by scanManagedDeliveries; skipping here only
- // tolerates cross-branch lock divergence. A delivery the caller actually
- // acts on is still verified at its own boundary (explicit-feature lookup
- // / nextForDelivery). control-law: stale-delivery-cannot-block-unrelated-feature
- continue
- }
- if state.ActiveIndex >= len(state.Slices) {
- completed = append(completed, state)
- }
- }
- sort.Slice(completed, func(i, j int) bool { return completed[i].Feature < completed[j].Feature })
- return completed, nil
-}
-
-// ResolveNext performs bounded, read-only state inspection. Published states
-// use the recorded PR identity when GitHub is available; conversation and
-// process history are never treated as evidence.
-func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return NextStatus{}, err
- }
- if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil {
- status := blockedNextStatus("INVALID_STATE", "attach", workspaceErr.Error())
- status.SupervisionMode = string(SupervisionDetached)
- return status, nil
- }
- defer func() {
- ctx, ok, verifyErr := detachedContextFor(repo)
- if verifyErr != nil {
- result.SupervisionMode = string(SupervisionDetached)
- return
- }
- if !ok {
- ctx = embeddedWorkspace(repo)
- }
- result.SupervisionMode = string(ctx.Mode)
- result.ControllerRoot = ctx.ExportRoot()
- }()
- base := NextStatus{SchemaVersion: nextStatusSchemaVersion}
- if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) {
- base.VerificationStatus = "UNVERIFIED"
- base.ObservedStage = "NOT_INITIALIZED"
- base.NextOperation = "init"
- base.Reason = "This repository has no Boatstack project installation to inspect."
- return decorateAutonomyStatus(repo, base), nil
- }
- config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if configErr != nil {
- // Channel fault: an invalid config cannot be cleared by any mutation verb —
- // the operator repairs the file. Route to the read-only doctor to diagnose,
- // not repair-state (which quarantines a draft and would not help). Coreachability.
- return blockedNextStatus("INVALID_STATE", "doctor", "Boatstack project configuration is invalid; fix the config file, then re-run (doctor diagnoses): "+configErr.Error()), nil
- }
-
- // Read-only boundary: apply the ignored-deliveries filter BEFORE a single
- // invalid delivery can escalate into a repo-wide INVALID_STATE. The strict
- // ActiveManagedDeliveries (used by mutation paths) aborts on any invalid
- // delivery; here we partition instead, filter both lists by the operator's
- // ignore policy, and only then block — and only on a still-unignored invalid
- // delivery, naming it and pointing at the discard-delivery remedy. This is
- // what keeps one stale delivery in the shared store from blocking an
- // unrelated new feature. control-law: stale-delivery-cannot-block-unrelated-feature
- active, invalidDeliveries, scanErr := scanManagedDeliveries(repo)
- if scanErr != nil {
- // Channel fault reading the store: observation loss, diagnosed by doctor —
- // not repaired by quarantining a draft. Coreachability.
- return blockedNextStatus("INVALID_STATE", "doctor", "Boatstack could not read the managed delivery store; diagnose the channel with doctor: "+scanErr.Error()), nil
- }
- active = withoutIgnoredDeliveries(active, config.Workflow.IgnoredDeliveries)
- invalidDeliveries = withoutIgnoredDeliveries(invalidDeliveries, config.Workflow.IgnoredDeliveries)
- if len(invalidDeliveries) > 0 {
- return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack found managed delivery state it cannot verify. Restore its evidence, add it to workflow.ignored_deliveries, or run discard-delivery to clear it before continuing.", invalidDeliveries...), nil
- }
-
- selectedCandidate := ""
- if explicitFeature != "" {
- found := false
- for _, f := range active {
- if f == explicitFeature {
- found = true
- break
- }
- }
- if found {
- active = []string{explicitFeature}
- } else if completedState, completedErr := CurrentDeliveryState(repo, explicitFeature); completedErr == nil && completedState.ActiveIndex >= len(completedState.Slices) {
- return nextForPublished(repo, completedState), nil
- } else {
- // An explicit Boatstack invocation may select one saved, unactivated
- // candidate even when other drafts exist. Selection is routing context,
- // not activation authority: the candidate still passes the normal plan,
- // workspace, approval, and activation checks below.
- // control-law: explicit-selection-scopes-draft-resolution
- candidates, candidatesErr := featurePlanCandidates(repo)
- if candidatesErr != nil {
- return NextStatus{}, candidatesErr
- }
- for _, candidate := range candidates {
- if candidate == explicitFeature {
- selectedCandidate = candidate
- break
- }
- }
- if selectedCandidate == "" {
- // Unverifiable named delivery: discard-delivery accepts and archives it
- // (repair-state refuses registered/tracked dirs). Coreachability.
- return blockedNextStatus("INVALID_STATE", "discard-delivery", fmt.Sprintf("Feature %s is not a verifiable active, published, or saved managed feature; clear it with discard-delivery.", explicitFeature), explicitFeature), nil
- }
- }
- }
-
- if len(active) > 1 {
- base.VerificationStatus = "BLOCKED"
- base.ObservedStage = "AMBIGUOUS"
- base.NextOperation = "resolve-ambiguity"
- base.Operator = OperatorQuery
- base.Reason = "More than one managed delivery is active; Boatstack will not choose by recency."
- base.BlockingAmbiguity = active
- return base, nil
- }
- if len(active) == 1 {
- status, deliveryErr := nextForDelivery(repo, active[0])
- if deliveryErr != nil {
- // Unverifiable active delivery state: discard-delivery archives it
- // (reversibly); repair-state would refuse the registered dir. Coreachability.
- return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack could not verify the active managed delivery; restore its evidence, or archive it with discard-delivery to continue: "+deliveryErr.Error(), active[0]), nil
- }
- return status, nil
- }
-
- orphans, err := orphanedFeatureArtifacts(repo)
- if err != nil {
- return NextStatus{}, err
- }
- if len(orphans) > 0 {
- // An orphan (pr.md, no plan.lock) is a published-then-unlinked delivery.
- // repair-state refuses it (pr.md is a durable-authority blocker); discard-delivery
- // accepts and archives the orphaned artifacts. Coreachability.
- return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack found a PR preview without the plan lock required to verify it; restore the feature evidence, or archive the orphan with discard-delivery.", orphans...), nil
- }
-
- candidates, err := featurePlanCandidates(repo)
- if err != nil {
- return NextStatus{}, err
- }
- if selectedCandidate != "" {
- candidates = []string{selectedCandidate}
- }
- if len(candidates) > 1 {
- base.VerificationStatus = "BLOCKED"
- base.ObservedStage = "AMBIGUOUS"
- base.NextOperation = "resolve-ambiguity"
- base.Reason = "More than one saved feature plan is available; Boatstack will not choose by recency."
- base.BlockingAmbiguity = candidates
- return base, nil
- }
- if len(candidates) == 1 {
- feature := candidates[0]
- directory := WorkspaceFor(repo).FeatureDir(feature)
- base.VerificationStatus = "VERIFIED"
- base.Feature = feature
- policyReady := !config.Workflow.HumanPlanApproval
- autonomyPath := filepath.Join(directory, "autonomy.md")
- if fileExists(autonomyPath) {
- check, checkErr := CheckPlan(filepath.Join(directory, "plan.md"))
- if checkErr != nil {
- return blockedNextStatus("INVALID_STATE", "auto-plan", "The autonomous run plan is no longer valid: "+checkErr.Error(), feature), nil
- }
- autonomy, autonomyErr := CheckAutonomyReceiptForPlanning(autonomyPath, check, repo, RunTargetPlan)
- if autonomyErr != nil {
- return blockedNextStatus("INVALID_STATE", "auto-plan", "The autonomous run receipt is stale or invalid: "+autonomyErr.Error(), feature), nil
- }
- if autonomy.Target == RunTargetPlan {
- base.ObservedStage = "PLAN_READY"
- base.NextOperation = "none"
- base.Reason = "The autonomous run reached its reviewable-plan target."
- return decorateAutonomyStatus(repo, base), nil
- }
- policyReady = true
- }
- if policyReady {
- base.ObservedStage = "POLICY_READY"
- base.NextOperation = "build"
- base.Reason = "The saved feature is ready for fingerprinted policy activation without a human approval receipt."
- if workspaceEnabled(repo) && needsFreshCut(repo, feature) {
- base.NextOperation = "workspace-cut"
- base.Reason = fmt.Sprintf("Feature %q is policy-authorized; cut a fresh workspace from the default branch before building.", feature)
- }
- } else if fileExists(filepath.Join(directory, "approval.md")) {
- base.ObservedStage = "APPROVED"
- base.NextOperation = "build"
- base.Reason = "The saved feature has an approval receipt but no active delivery state."
- // Cut a fresh workspace before building so work never starts on a
- // stale base branch. Local-only check; the cut itself fetches origin.
- if workspaceEnabled(repo) && needsFreshCut(repo, feature) {
- base.NextOperation = "workspace-cut"
- base.Reason = fmt.Sprintf("Feature %q is approved; cut a fresh workspace from the default branch before building.", feature)
- }
- } else {
- base.ObservedStage = "DRAFT_PLAN"
- base.NextOperation = "plan-gate"
- base.Reason = "The saved feature plan has not been approved."
- // Bind the plan to its final feature workspace before readiness,
- // approval, or autonomy records branch identity. Incomplete plans stay
- // on plan-gate and never gain workspace authority.
- if workspaceEnabled(repo) && needsFreshCut(repo, feature) {
- if _, planErr := CheckPlan(filepath.Join(directory, "plan.md")); planErr == nil {
- base.NextOperation = "workspace-cut"
- base.Reason = fmt.Sprintf("Feature %q has a valid plan; establish its branch workspace before readiness and approval.", feature)
- }
- }
- }
- return decorateAutonomyStatus(repo, base), nil
- }
-
- completed, err := completedManagedStates(repo)
- if err != nil {
- // Invalid completed delivery state: discard-delivery archives it reversibly;
- // repair-state refuses a delivery-bearing dir. Coreachability.
- return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack found invalid completed delivery state; restore its evidence, or archive it with discard-delivery to continue: "+err.Error()), nil
- }
- completed = withoutIgnoredDeliveryStates(completed, config.Workflow.IgnoredDeliveries)
- if len(completed) > 0 {
- if len(completed) == 1 {
- base = nextForPublished(repo, completed[0])
- // When workspace management is on and the shipped feature still has a
- // linked worktree locally, surface cleanup only after a verified merge.
- if base.Lifecycle == "PUBLISHED_MERGED" && base.HeadBranch != "" && workspaceEnabled(repo) {
- if path := worktreePathForBranch(repo, base.HeadBranch); path != "" {
- base.NextOperation = "workspace-cleanup"
- base.Reason = fmt.Sprintf("Feature %q is merged; its workspace on %q can be cleaned up.", completed[0].Feature, base.HeadBranch)
- }
- // At the merge checkpoint, prefer the backlog sweep: if reaping is
- // enabled and there are terminal Boatstack workspaces to reclaim,
- // surface workspace-reap so one prompt clears the accumulated backlog
- // rather than only the just-merged feature.
- if reapEnabled(repo) {
- if count := CountReclaimableWorkspaces(repo); count > 0 {
- base.NextOperation = "workspace-reap"
- base.Reason = fmt.Sprintf("Feature %q is merged; %d merged or abandoned Boatstack workspace(s) are reclaimable.", completed[0].Feature, count)
- }
- }
- }
- } else {
- branch, _ := gitCommand(repo, "branch", "--show-current")
- matches := []DeliveryState{}
- for _, state := range completed {
- if stateMatchesBranch(state, strings.TrimSpace(branch)) {
- matches = append(matches, state)
- }
- }
- if len(matches) == 1 {
- base = nextForPublished(repo, matches[0])
- } else {
- base.VerificationStatus = "BLOCKED"
- base.ObservedStage = "AMBIGUOUS"
- base.NextOperation = "resolve-ambiguity"
- base.Reason = "Multiple published deliveries exist and none is uniquely associated with the current branch."
- for _, state := range completed {
- base.BlockingAmbiguity = append(base.BlockingAmbiguity, state.Feature)
- }
- }
- }
- return base, nil
- }
-
- base.VerificationStatus = "VERIFIED"
- base.ObservedStage = "NOT_STARTED"
- base.NextOperation = "auto-plan"
- base.Reason = "No Boatstack feature has started; run auto-plan with the plan produced in the host conversation (--plan )."
- return base, nil
-}
-
-func FormatNextStatus(status NextStatus) string {
- parts := []string{
- "Boatstack stage: " + status.ObservedStage,
- "Verification: " + status.VerificationStatus,
- "Supervision: " + status.SupervisionMode,
- "Controller root: " + status.ControllerRoot,
- }
- if status.Feature != "" {
- parts = append(parts, "Feature: "+status.Feature)
- }
- if status.RunTarget != "" {
- parts = append(parts, "Run target: "+string(status.RunTarget), fmt.Sprintf("Policy decisions: %d", status.PolicyDecisions))
- }
- if status.ActiveSlice != "" {
- if status.TotalSlices > 1 {
- parts = append(parts, fmt.Sprintf("Active slice: %s (PR %d of %d)", status.ActiveSlice, status.SliceIndex, status.TotalSlices))
- } else {
- parts = append(parts, "Active slice: "+status.ActiveSlice)
- }
- }
- if status.Lifecycle != "" {
- parts = append(parts, "Lifecycle: "+status.Lifecycle)
- }
- // An Unknown phase adds nothing the lifecycle line does not already say,
- // so only a positively derived phase earns a line.
- if status.PRPhase != "" && status.PRPhase != string(PRPhaseUnknown) {
- phase := "PR phase: " + status.PRPhase
- if len(status.PRFailingChecks) > 0 {
- phase += " (" + strings.Join(status.PRFailingChecks, ", ") + ")"
- }
- parts = append(parts, phase)
- }
- if status.PRURL != "" {
- parts = append(parts, "PR: "+status.PRURL)
- }
- parts = append(parts, "Reason: "+status.Reason, "Next: "+status.NextOperation)
- if len(status.BlockingAmbiguity) > 0 {
- parts = append(parts, "Candidates: "+strings.Join(status.BlockingAmbiguity, ", "))
- }
- return strings.Join(parts, "\n") + "\n"
-}
-
-// Banner glyphs (Kit C "Flightpath"). Deliberately no glyph for Boatstack itself.
-const (
- bannerGlyphDone = "✓" // a journey node that is finished
- bannerGlyphNow = "▸" // the node in progress right now
- bannerGlyphTodo = "·" // a node not yet reached
- bannerGlyphBlocked = "▲" // the current node needs a human
- bannerGlyphComplete = "✱" // the whole feature is done
- bannerWordmark = "Boatstack"
- bannerRuleWidth = 44
-)
-
-// RenderNextStatusBanner produces the branded, human-facing header shown at the
-// top of every Boatstack message. It is pure presentation of the read-only
-// NextStatus: a wordmark rule, a plain-language subtitle, and an unlabeled
-// four-node progress rail with one friendly active phrase.
-//
-// Control law "banner-hides-internal-machinery": the banner MUST NEVER surface
-// internal stage names or machine codes (BUILD, TEST_PASSED, REVIEW_PASSED,
-// POLICY_READY, PUBLISHED, DRAFT_PLAN, APPROVED, NOT_INITIALIZED, INVALID_STATE,
-// AMBIGUOUS, discard-delivery, repair-state, ship-gate, …) and MUST NOT carry a
-// logo/badge for Boatstack. Every ObservedStage/NextOperation/VerificationStatus/
-// Lifecycle value maps to friendly words or degrades to a safe generic phrase.
-// The renderer emits plain Unicode (no ANSI): the banner lands inside a Markdown
-// response, so colour is a host concern applied later.
-func RenderNextStatusBanner(status NextStatus) string {
- var b strings.Builder
- b.WriteString(bannerRule(bannerWordmark, bannerRuleWidth) + "\n")
-
- if subtitle := bannerSubtitle(status); subtitle != "" {
- b.WriteString(" " + subtitle + "\n")
- }
-
- phrase := friendlyPhrase(status)
- if status.VerificationStatus == "UNVERIFIED" {
- // No feature is being tracked yet: show the wordmark and a plain phrase,
- // no rail (an all-todo rail would imply work is queued when it is not).
- b.WriteString(" " + phrase + "\n")
- } else {
- rail := strings.Join(journeyNodes(status), "──")
- b.WriteString(" " + rail + " " + phrase + "\n")
- }
-
- b.WriteString(strings.Repeat("━", bannerRuleWidth) + "\n")
- return b.String()
-}
-
-// bannerRule renders the top rule "━━ ━━━…" padded to width runes.
-func bannerRule(title string, width int) string {
- prefix := "━━ " + title + " "
- fill := width - utf8.RuneCountInString(prefix)
- if fill < 1 {
- fill = 1
- }
- return prefix + strings.Repeat("━", fill)
-}
-
-// bannerSubtitle is the feature line, using the non-coder word "part" for slices.
-func bannerSubtitle(status NextStatus) string {
- if status.Feature == "" {
- return ""
- }
- if status.TotalSlices > 1 {
- return fmt.Sprintf("%s · part %d of %d", status.Feature, status.SliceIndex, status.TotalSlices)
- }
- return status.Feature
-}
-
-// journeyNodes returns the four rail glyphs. The four nodes are a deliberate
-// user-facing abstraction of the internal machine; they are never labelled.
-func journeyNodes(status NextStatus) []string {
- if status.ObservedStage == "FEATURE_COMPLETE" || status.Lifecycle == "PUBLISHED_MERGED" {
- return []string{bannerGlyphDone, bannerGlyphDone, bannerGlyphDone, bannerGlyphComplete}
- }
- if status.ObservedStage == "PUBLISHED" {
- return []string{bannerGlyphDone, bannerGlyphDone, bannerGlyphDone, bannerGlyphDone}
- }
-
- pos := stagePosition(status.ObservedStage)
- blocked := bannerBlocked(status)
- nodes := make([]string, 4)
- for i := range nodes {
- switch {
- case i < pos:
- nodes[i] = bannerGlyphDone
- case i == pos:
- if blocked {
- nodes[i] = bannerGlyphBlocked
- } else {
- nodes[i] = bannerGlyphNow
- }
- default:
- nodes[i] = bannerGlyphTodo
- }
- }
- return nodes
-}
-
-// stagePosition collapses the internal stages into a 0..3 position on the rail.
-func stagePosition(stage string) int {
- switch stage {
- case "NOT_STARTED", "NOT_INITIALIZED", "DRAFT_PLAN", "AMBIGUOUS":
- return 0
- case "POLICY_READY", "APPROVED", "BUILD", "INVALID_STATE":
- return 1
- case "TEST_PASSED":
- return 2
- case "PR_PREVIEW", "REVIEW_PASSED":
- return 3
- default:
- return 0
- }
-}
-
-func bannerBlocked(status NextStatus) bool {
- return status.VerificationStatus == "BLOCKED" ||
- status.ObservedStage == "AMBIGUOUS" ||
- status.ObservedStage == "INVALID_STATE"
-}
-
-// friendlyPhrase maps the internal status to one plain-language sentence. It must
-// never echo a raw stage name or machine code.
-func friendlyPhrase(status NextStatus) string {
- if status.VerificationStatus == "UNVERIFIED" {
- return "not tracking a feature here yet"
- }
- if bannerBlocked(status) {
- return "needs you: " + friendlyBlockReason(status)
- }
- switch status.ObservedStage {
- case "NOT_STARTED", "NOT_INITIALIZED", "DRAFT_PLAN":
- return "getting your plan ready"
- case "POLICY_READY", "APPROVED":
- return "ready to build"
- case "BUILD":
- return "building your changes"
- case "TEST_PASSED":
- return "checking your changes"
- case "PR_PREVIEW", "REVIEW_PASSED":
- return "ready to ship"
- case "PUBLISHED":
- if status.Lifecycle == "PUBLISHED_MERGED" {
- return "complete"
- }
- return "shipped — in review"
- case "FEATURE_COMPLETE":
- return "complete"
- default:
- return "working on your changes"
- }
-}
-
-// friendlyBlockReason translates NextOperation into a plain "what you need to do"
-// sentence. The raw operation name is never printed.
-func friendlyBlockReason(status NextStatus) string {
- switch status.NextOperation {
- case "resolve-ambiguity":
- return "pick which feature to continue"
- case "discard-delivery":
- return "an old draft needs clearing before we continue"
- case "repair-state":
- return "the workspace needs a quick reset"
- case "init":
- return "Boatstack isn't set up here yet"
- case "plan-gate":
- return "your plan needs approval"
- case "review-gate":
- return "a review check needs attention"
- case "ship-gate":
- return "a ship check needs attention"
- default:
- return "a check needs your attention"
- }
-}
diff --git a/boatstack/next_actor_conformance_test.go b/boatstack/next_actor_conformance_test.go
deleted file mode 100644
index f6c82c7..0000000
--- a/boatstack/next_actor_conformance_test.go
+++ /dev/null
@@ -1,190 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: turn-ends-only-at-the-operator-frontier
-// (cross-reference: response-contract-is-helper-rendered)
-//
-// Every prescribed next step is typed by who performs it. A step belongs to
-// the operator only when it owes operator knowledge or authority; every other
-// step is the agent's, and a working response must not end on it. These tests
-// hold the classifier to that boundary (fail-closed to operator), hold the
-// renderer to marking agent-owned steps with the delegation line, and pin the
-// exported instruction to the delegation reply so a status query stays
-// read-only while the operator's next action is always a single key.
-
-const agentStepMarker = "This step is mine to do."
-
-// Per-stage classification through the real resolution path: pre-activation
-// stages the agent can drive are agent-owned; stages owing operator knowledge
-// (the source-plan path, a feature choice) are operator-owned.
-func TestNextActorPerStage(t *testing.T) {
- expectActor := func(t *testing.T, repo, feature string, want NextActor) FlowNext {
- t.Helper()
- status, err := ResolveNext(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- next, err := nextControlFromStatus(repo, status)
- if err != nil {
- t.Fatal(err)
- }
- if next.Actor != want {
- t.Fatalf("actor = %q, want %q (stage %s)", next.Actor, want, status.ObservedStage)
- }
- return next
- }
-
- t.Run("not_started_owes_plan_path", func(t *testing.T) {
- expectActor(t, nextTestRepo(t), "", NextActorOperator)
- })
-
- t.Run("draft_plan_check_is_agents", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- expectActor(t, repo, "", NextActorAgent)
- })
-
- t.Run("approved_activation_is_agents", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "features", "demo", "approval.md"), []byte("approved\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- expectActor(t, repo, "", NextActorAgent)
- })
-
- t.Run("build_evidence_is_agents", func(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- next := expectActor(t, repo, feature, NextActorAgent)
- if next.Prescribed == nil || len(next.Prescribed.RequiresHumanInput) == 0 {
- t.Fatal("fixture must owe evidence flags — the point is they do not cross the frontier")
- }
- })
-
- t.Run("ambiguous_choice_is_operators", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "plan-one")
- writeSavedFeaturePlan(t, repo, "plan-two")
- expectActor(t, repo, "", NextActorOperator)
- })
-}
-
-// Boundary: the classifier is fail-closed. Terminal states owe nobody an
-// action; publish authority, operator-owed knowledge flags, and unprescribed
-// blocks all resolve to the operator — the worst misclassification is
-// prescribe-and-stop, never a runaway agent.
-func TestNextActorFrontierBoundaries(t *testing.T) {
- cases := []struct {
- name string
- status NextStatus
- next FlowNext
- want NextActor
- }{
- {"feature_complete_is_terminal", NextStatus{ObservedStage: "FEATURE_COMPLETE"}, FlowNext{}, NextActorNone},
- {"merged_publication_is_terminal", NextStatus{ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_MERGED"}, FlowNext{}, NextActorNone},
- {"open_pr_review_is_operators", NextStatus{ObservedStage: "PUBLISHED"}, FlowNext{}, NextActorOperator},
- {"unprescribed_block_is_operators", NextStatus{ObservedStage: "INVALID_STATE"}, FlowNext{}, NextActorOperator},
- {"publish_authority_is_operators", NextStatus{ObservedStage: "REVIEW_PASSED"}, FlowNext{
- Prescribed: &PrescribedCommand{Verb: "publish", Transition: PublishTransition},
- }, NextActorOperator},
- {"owed_knowledge_is_operators", NextStatus{ObservedStage: "BUILD"}, FlowNext{
- Prescribed: &PrescribedCommand{Verb: "record-change", RequiresHumanInput: []string{"--message", "--source-stage", "--classification", "--mechanism"}},
- }, NextActorOperator},
- {"owed_evidence_stays_agents", NextStatus{ObservedStage: "BUILD"}, FlowNext{
- Prescribed: &PrescribedCommand{Verb: "record-delivery-gate", RequiresHumanInput: []string{"--status", "--evidence"}, Transition: deliverycontrol.TransitionID("delivery.record_gate_test")},
- }, NextActorAgent},
- {"owed_visual_attach_retry_is_agents", NextStatus{ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_OPEN", VisualPublication: "visual_pending"}, FlowNext{}, NextActorAgent},
- {"legacy_manual_visual_state_retries_as_hosted", NextStatus{ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_OPEN", VisualPublication: "manual_required"}, FlowNext{}, NextActorAgent},
- {"escaped_pursuit_demotes_despite_owed_attachment", NextStatus{ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_OPEN", VisualPublication: "visual_pending", GoalEscape: "pr_closed"}, FlowNext{Terminal: TerminalMerged}, NextActorOperator},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- if got := classifyNextActor(tc.status, tc.next); got != tc.want {
- t.Fatalf("classifyNextActor = %q, want %q", got, tc.want)
- }
- })
- }
-}
-
-// Rendering: the delegation line appears exactly when the step is the
-// agent's, and it always names the single-key reply — the operator's next
-// action is known even when the work is the agent's.
-func TestResponseMarksAgentOwnedSteps(t *testing.T) {
- t.Run("agent_owned_step_carries_delegation", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- _, output := renderedResponse(t, repo)
- if !strings.Contains(output, agentStepMarker) {
- t.Fatalf("agent-owned step must carry the marker: %q", output)
- }
- if !strings.Contains(output, "Reply `g`") {
- t.Fatalf("agent-owned step must name the delegation reply: %q", output)
- }
- })
-
- t.Run("operator_owned_step_carries_no_delegation", func(t *testing.T) {
- _, output := renderedResponse(t, nextTestRepo(t))
- if strings.Contains(output, agentStepMarker) {
- t.Fatalf("operator-owned step must not claim to be the agent's: %q", output)
- }
- })
-
- t.Run("ambiguous_block_carries_no_delegation", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "plan-one")
- writeSavedFeaturePlan(t, repo, "plan-two")
- _, output := renderedResponse(t, repo)
- if strings.Contains(output, agentStepMarker) {
- t.Fatalf("a feature choice is a human act, never the agent's: %q", output)
- }
- })
-}
-
-// Bypass: the exported boatstack-next instruction carries the frontier rule —
-// the delegation reply, the continue-until-operator loop, and the
-// no-progress stall guard — so no host prose can quietly turn an agent-owned
-// step back into an operator to-do item.
-func TestExportedNextInstructionCarriesFrontierRule(t *testing.T) {
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- inspected := 0
- for path, content := range bundle.Files {
- if !strings.Contains(path, "boatstack-next") {
- continue
- }
- inspected++
- text := string(content)
- for _, rule := range []string{
- agentStepMarker[:len(agentStepMarker)-1], // marker phrase, unpunctuated
- "delegation reply g",
- "repetition without progress is a stall",
- "Never end a response by describing work the agent still has to do",
- } {
- if !strings.Contains(text, rule) {
- t.Fatalf("%s must carry the frontier rule %q", path, rule)
- }
- }
- }
- if inspected == 0 {
- t.Fatal("no exported boatstack-next instruction found — the frontier rule would be vacuous")
- }
- workflow := string(bundle.Files[".product-loop/workflow.md"])
- if !strings.Contains(workflow, "### The operator frontier") {
- t.Fatal("workflow.md must document the operator frontier")
- }
-}
diff --git a/boatstack/next_banner_test.go b/boatstack/next_banner_test.go
deleted file mode 100644
index 3816cec..0000000
--- a/boatstack/next_banner_test.go
+++ /dev/null
@@ -1,203 +0,0 @@
-package boatstack
-
-import (
- "strings"
- "testing"
-)
-
-// Boundary: the human-facing status banner (RenderNextStatusBanner).
-// Control law: banner-hides-internal-machinery — the banner is pure presentation
-// of the read-only NextStatus. It must never surface an internal stage name or
-// machine code, must never carry a logo/badge for Boatstack, must render exactly
-// one "current" marker on a fixed four-node rail, and a blocked status must show
-// the needs-you marker rather than a clean in-progress marker.
-
-// The exhaustive enum value sets the banner must tolerate as inputs.
-var (
- allObservedStages = []string{
- "BUILD", "TEST_PASSED", "REVIEW_PASSED", "PR_PREVIEW", "PUBLISHED",
- "FEATURE_COMPLETE", "NOT_INITIALIZED", "INVALID_STATE", "AMBIGUOUS",
- "POLICY_READY", "APPROVED", "DRAFT_PLAN", "NOT_STARTED",
- }
- allVerificationStatuses = []string{"BLOCKED", "VERIFIED", "UNVERIFIED"}
- allNextOperations = []string{
- "build", "review-gate", "ship-gate", "none", "repair-state",
- "discard-delivery", "init", "resolve-ambiguity", "workspace-cut",
- "plan-gate", "workspace-cleanup", "auto-plan",
- }
- allLifecycles = []string{
- "", "PUBLISHED_UNKNOWN", "PUBLISHED_OPEN", "PUBLISHED_MERGED", "PUBLISHED_CLOSED",
- }
-
- // Tokens that would leak the internal machine into a user-facing surface.
- forbiddenBannerTokens = []string{
- "BUILD", "TEST_PASSED", "REVIEW_PASSED", "PR_PREVIEW", "PUBLISHED",
- "FEATURE_COMPLETE", "NOT_INITIALIZED", "NOT_STARTED", "INVALID_STATE",
- "AMBIGUOUS", "POLICY_READY", "APPROVED", "DRAFT_PLAN",
- "PUBLISHED_UNKNOWN", "PUBLISHED_OPEN", "PUBLISHED_MERGED", "PUBLISHED_CLOSED",
- "discard-delivery", "repair-state", "ship-gate", "review-gate", "plan-gate",
- "resolve-ambiguity", "workspace-cut", "workspace-cleanup", "auto-plan",
- "⚓", // no logo/badge for Boatstack itself
- }
-)
-
-// negative / bypass: no combination of inputs ever leaks internal machinery.
-// control-law: banner-hides-internal-machinery
-func TestBannerNeverLeaksInternalMachinery(t *testing.T) {
- for _, stage := range allObservedStages {
- for _, verification := range allVerificationStatuses {
- for _, op := range allNextOperations {
- for _, lifecycle := range allLifecycles {
- status := NextStatus{
- VerificationStatus: verification,
- ObservedStage: stage,
- NextOperation: op,
- Lifecycle: lifecycle,
- Feature: "roles-access",
- ActiveSlice: "slice-2",
- SliceIndex: 2,
- TotalSlices: 4,
- }
- banner := RenderNextStatusBanner(status)
- for _, token := range forbiddenBannerTokens {
- if strings.Contains(banner, token) {
- t.Fatalf("banner leaked internal token %q for stage=%s verification=%s op=%s lifecycle=%s:\n%s",
- token, stage, verification, op, lifecycle, banner)
- }
- }
- }
- }
- }
- }
-}
-
-// positive: representative states render the expected rail, phrase, and wordmark.
-// control-law: banner-hides-internal-machinery
-func TestBannerRendersExpectedFriendlyStates(t *testing.T) {
- cases := []struct {
- name string
- status NextStatus
- wantRail string
- wantPhrase string
- }{
- {
- name: "building",
- status: NextStatus{VerificationStatus: "VERIFIED", ObservedStage: "BUILD", NextOperation: "build", Feature: "roles-access", SliceIndex: 2, TotalSlices: 4},
- wantRail: "✓──▸──·──·",
- wantPhrase: "building your changes",
- },
- {
- name: "checking",
- status: NextStatus{VerificationStatus: "VERIFIED", ObservedStage: "TEST_PASSED", NextOperation: "review-gate", Feature: "roles-access", SliceIndex: 2, TotalSlices: 4},
- wantRail: "✓──✓──▸──·",
- wantPhrase: "checking your changes",
- },
- {
- name: "ready to ship",
- status: NextStatus{VerificationStatus: "VERIFIED", ObservedStage: "REVIEW_PASSED", NextOperation: "ship-gate", Feature: "roles-access", SliceIndex: 2, TotalSlices: 4},
- wantRail: "✓──✓──✓──▸",
- wantPhrase: "ready to ship",
- },
- {
- name: "complete",
- status: NextStatus{VerificationStatus: "VERIFIED", ObservedStage: "FEATURE_COMPLETE", NextOperation: "none", Feature: "roles-access"},
- wantRail: "✓──✓──✓──✱",
- wantPhrase: "complete",
- },
- {
- name: "blocked needs you",
- status: NextStatus{VerificationStatus: "BLOCKED", ObservedStage: "INVALID_STATE", NextOperation: "discard-delivery", Feature: "roles-access", SliceIndex: 2, TotalSlices: 4},
- wantRail: "✓──▲──·──·",
- wantPhrase: "needs you: an old draft needs clearing before we continue",
- },
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- banner := RenderNextStatusBanner(tc.status)
- if !strings.Contains(banner, bannerWordmark) {
- t.Errorf("banner missing wordmark:\n%s", banner)
- }
- if !strings.Contains(banner, tc.wantRail) {
- t.Errorf("banner missing rail %q:\n%s", tc.wantRail, banner)
- }
- if !strings.Contains(banner, tc.wantPhrase) {
- t.Errorf("banner missing phrase %q:\n%s", tc.wantPhrase, banner)
- }
- })
- }
-}
-
-// relation: the rail is always exactly four nodes.
-// control-law: banner-hides-internal-machinery
-func TestBannerRailIsAlwaysFourNodes(t *testing.T) {
- for _, stage := range allObservedStages {
- nodes := journeyNodes(NextStatus{ObservedStage: stage, VerificationStatus: "VERIFIED"})
- if len(nodes) != 4 {
- t.Fatalf("stage %s produced %d nodes, want 4: %v", stage, len(nodes), nodes)
- }
- }
-}
-
-// relation: a non-terminal, non-blocked state has exactly one in-progress marker
-// and no needs-you marker.
-// control-law: banner-hides-internal-machinery
-func TestBannerSingleCurrentMarkerWhenMidFlight(t *testing.T) {
- midFlight := []string{"NOT_STARTED", "DRAFT_PLAN", "POLICY_READY", "APPROVED", "BUILD", "TEST_PASSED", "REVIEW_PASSED", "PR_PREVIEW"}
- for _, stage := range midFlight {
- banner := RenderNextStatusBanner(NextStatus{ObservedStage: stage, VerificationStatus: "VERIFIED", Feature: "roles-access"})
- if got := strings.Count(banner, bannerGlyphNow); got != 1 {
- t.Errorf("stage %s: want exactly one %q, got %d:\n%s", stage, bannerGlyphNow, got, banner)
- }
- if strings.Contains(banner, bannerGlyphBlocked) {
- t.Errorf("stage %s: mid-flight banner must not show the needs-you marker %q:\n%s", stage, bannerGlyphBlocked, banner)
- }
- }
-}
-
-// bypass / failure-state: a BLOCKED status always shows the needs-you marker and
-// never a clean in-progress marker — a stall can never masquerade as progress.
-// control-law: banner-hides-internal-machinery
-func TestBannerBlockedShowsNeedsYouNotProgress(t *testing.T) {
- for _, stage := range allObservedStages {
- if stage == "FEATURE_COMPLETE" || stage == "PUBLISHED" {
- continue // terminal states are not blockable on the rail
- }
- banner := RenderNextStatusBanner(NextStatus{ObservedStage: stage, VerificationStatus: "BLOCKED", NextOperation: "repair-state", Feature: "roles-access"})
- if !strings.Contains(banner, bannerGlyphBlocked) {
- t.Errorf("blocked stage %s must show the needs-you marker %q:\n%s", stage, bannerGlyphBlocked, banner)
- }
- if strings.Contains(banner, bannerGlyphNow) {
- t.Errorf("blocked stage %s must not show a clean in-progress marker %q:\n%s", stage, bannerGlyphNow, banner)
- }
- if !strings.Contains(banner, "needs you:") {
- t.Errorf("blocked stage %s must lead with a needs-you phrase:\n%s", stage, banner)
- }
- }
-}
-
-// relation: the no-project (UNVERIFIED) banner shows no rail at all.
-// control-law: banner-hides-internal-machinery
-func TestBannerUnverifiedShowsNoRail(t *testing.T) {
- banner := RenderNextStatusBanner(NextStatus{VerificationStatus: "UNVERIFIED", ObservedStage: "NOT_INITIALIZED", NextOperation: "init"})
- for _, glyph := range []string{bannerGlyphNow, bannerGlyphDone, bannerGlyphTodo, bannerGlyphBlocked} {
- if strings.Contains(banner, glyph) {
- t.Errorf("UNVERIFIED banner must not render a rail glyph %q:\n%s", glyph, banner)
- }
- }
- if !strings.Contains(banner, "not tracking a feature here yet") {
- t.Errorf("UNVERIFIED banner should explain nothing is tracked:\n%s", banner)
- }
-}
-
-// relation: subtitle uses the non-coder word "part" and is omitted for single-slice.
-// control-law: banner-hides-internal-machinery
-func TestBannerSubtitleUsesPartAndOmitsSingleSlice(t *testing.T) {
- multi := RenderNextStatusBanner(NextStatus{VerificationStatus: "VERIFIED", ObservedStage: "BUILD", Feature: "roles-access", SliceIndex: 2, TotalSlices: 4})
- if !strings.Contains(multi, "roles-access · part 2 of 4") {
- t.Errorf("multi-slice subtitle wrong:\n%s", multi)
- }
- single := RenderNextStatusBanner(NextStatus{VerificationStatus: "VERIFIED", ObservedStage: "BUILD", Feature: "roles-access", SliceIndex: 1, TotalSlices: 1})
- if strings.Contains(single, "part") {
- t.Errorf("single-slice subtitle should not mention parts:\n%s", single)
- }
-}
diff --git a/boatstack/next_response.go b/boatstack/next_response.go
deleted file mode 100644
index 82fcab7..0000000
--- a/boatstack/next_response.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "strings"
- "unicode"
- "unicode/utf8"
-)
-
-// RenderNextStatusResponse renders the canonical user-facing response contract
-// for the current workflow position: the branded banner, one friendly outcome
-// line, and exactly one "### Next step" block carrying the exact runnable
-// command when one is prescribable. Adapters present this output verbatim
-// instead of re-deriving the state table from prose — the decision was always
-// deterministic (ResolveNext + the prescription layer); this makes the
-// rendering deterministic too.
-//
-// It obeys the banner law (banner-hides-internal-machinery): machine stage
-// names and operation codes never appear as status prose. The prescribed
-// command line is the one legitimate place helper verbs appear — it is the
-// runnable next step, exactly as `flow next` prints it.
-// control-law: response-contract-is-helper-rendered
-func RenderNextStatusResponse(repo string, status NextStatus) (string, error) {
- next, err := nextControlFromStatus(repo, status)
- if err != nil {
- return "", err
- }
-
- var b strings.Builder
- b.WriteString(RenderNextStatusBanner(status))
- b.WriteString("\n" + sentenceCase(friendlyPhrase(status)) + ".\n")
- b.WriteString("\n### Next step\n\n")
-
- switch {
- case next.Prescribed != nil:
- if next.Actor == NextActorAgent {
- // An agent-owned step is never the operator's to perform. This
- // read-only status view renders it as a one-key delegation; a working
- // response must not end here at all — it does the step, re-renders,
- // and repeats until the next step reaches the operator frontier.
- // control-law: turn-ends-only-at-the-operator-frontier
- b.WriteString("This step is mine to do. Reply `g` and I will do it now, then continue to your next decision.\n")
- }
- writePrescribed(&b, next.Prescribed)
- if next.FollowUp != "" {
- fmt.Fprintf(&b, "Then: %s\n", next.FollowUp)
- }
- if next.SubAction != nil {
- title := next.SubAction.Title
- if title != "" {
- title = " — " + title
- }
- fmt.Fprintf(&b, "Next sub-action: %s%s (from the plan task DAG; see `flow tasks`)\n", next.SubAction.ID, title)
- }
- // The solution set as one short secondary sentence — the contract allows
- // exactly one, and the verbs appear only in command position.
- // control-law: solution-set-derives-from-guard-declarations
- if len(next.Alternatives) > 0 {
- verbs := make([]string, 0, solutionSetTextCap)
- for _, alt := range next.Alternatives {
- if len(verbs) == solutionSetTextCap {
- break
- }
- verbs = append(verbs, "`"+alt.Verb+"`")
- }
- fmt.Fprintf(&b, "Other legal moves: %s.\n", strings.Join(verbs, ", "))
- }
- case status.ObservedStage == "FEATURE_COMPLETE",
- status.ObservedStage == "PUBLISHED" && status.Lifecycle == "PUBLISHED_MERGED":
- b.WriteString("No action required.\n")
- case status.ObservedStage == "PUBLISHED":
- if status.PRURL != "" {
- fmt.Fprintf(&b, "Review the pull request: %s\n", status.PRURL)
- } else {
- b.WriteString("Review the pull request.\n")
- }
- case len(status.BlockingAmbiguity) > 0:
- b.WriteString(sentenceCase(friendlyBlockReason(status)) + ":\n")
- for _, candidate := range status.BlockingAmbiguity {
- fmt.Fprintf(&b, "- %s\n", candidate)
- }
- default:
- b.WriteString(sentenceCase(friendlyBlockReason(status)) + ".\n")
- }
- return b.String(), nil
-}
-
-// sentenceCase upper-cases the first rune of a friendly phrase so it can open
-// a sentence without changing the phrase vocabulary.
-func sentenceCase(phrase string) string {
- if phrase == "" {
- return phrase
- }
- first, size := utf8.DecodeRuneInString(phrase)
- return string(unicode.ToUpper(first)) + phrase[size:]
-}
diff --git a/boatstack/next_response_conformance_test.go b/boatstack/next_response_conformance_test.go
deleted file mode 100644
index 1864d5c..0000000
--- a/boatstack/next_response_conformance_test.go
+++ /dev/null
@@ -1,234 +0,0 @@
-package boatstack
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "strings"
- "testing"
-)
-
-// control-law: response-contract-is-helper-rendered
-// (cross-reference: banner-hides-internal-machinery)
-//
-// The "one next action" decision was always deterministic — ResolveNext plus
-// the prescription layer — but its user-facing rendering lived only as prose
-// in the exported operation instructions, re-derived by every agent on every
-// turn. RenderNextStatusResponse compiles that rendering into the helper:
-// one friendly outcome line and exactly one "### Next step" block carrying
-// the identical command line `flow next` prescribes. These tests hold the
-// renderer to the phrase vocabulary, the single-next-step contract, parity
-// with the prescription layer, and the banner law; and they pin the exported
-// skill prose to POINTING at the renderer rather than restating a state table.
-
-// machineTokens must never appear as status prose in a rendered response.
-// Lines that carry the runnable command (`.product-loop/boatstack …`) are the one
-// legitimate exception — the verb IS the next step there.
-var machineTokens = regexp.MustCompile(`DRAFT_PLAN|APPROVED|POLICY_READY|NOT_INITIALIZED|INVALID_STATE|AMBIGUOUS|NOT_STARTED|TEST_PASSED|REVIEW_PASSED|PR_PREVIEW|FEATURE_COMPLETE|repair-state|discard-delivery|plan-gate|ship-gate|review-gate|auto-plan`)
-
-func renderedResponse(t *testing.T, repo string) (NextStatus, string) {
- t.Helper()
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- output, err := RenderNextStatusResponse(repo, status)
- if err != nil {
- t.Fatal(err)
- }
- return status, output
-}
-
-// Positive: per-stage fixtures render the friendly phrase and exactly one
-// "### Next step"; when a command is prescribable the block carries it.
-func TestResponseContractPerStage(t *testing.T) {
- t.Run("not_started", func(t *testing.T) {
- repo := nextTestRepo(t)
- status, output := renderedResponse(t, repo)
- assertResponseShape(t, status, output)
- next, err := nextControlFromStatus(repo, status)
- if err != nil {
- t.Fatal(err)
- }
- if next.Prescribed == nil || !strings.Contains(output, "Run: "+next.Prescribed.CommandLine()) {
- t.Fatalf("NOT_STARTED must carry the prescribed command: %q", output)
- }
- })
-
- t.Run("draft_plan", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- status, output := renderedResponse(t, repo)
- assertResponseShape(t, status, output)
- if !strings.Contains(output, "Run: .product-loop/boatstack check-plan") {
- t.Fatalf("DRAFT_PLAN must carry the prescribed command: %q", output)
- }
- if !strings.Contains(output, "Then: ") {
- t.Fatalf("DRAFT_PLAN must carry the follow-up: %q", output)
- }
- })
-
- t.Run("approved", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "features", "demo", "approval.md"), []byte("approved\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- status, output := renderedResponse(t, repo)
- assertResponseShape(t, status, output)
- if !strings.Contains(output, "Run: .product-loop/boatstack activate-plan") {
- t.Fatalf("APPROVED must carry the prescribed command: %q", output)
- }
- })
-
- t.Run("build", func(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- status, err := ResolveNext(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- output, err := RenderNextStatusResponse(repo, status)
- if err != nil {
- t.Fatal(err)
- }
- assertResponseShape(t, status, output)
- if !strings.Contains(output, "Run: .product-loop/boatstack record-delivery-gate") {
- t.Fatalf("BUILD must carry the oracle-prescribed command: %q", output)
- }
- })
-
- t.Run("ambiguous_lists_candidates", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "plan-one")
- writeSavedFeaturePlan(t, repo, "plan-two")
- status, output := renderedResponse(t, repo)
- assertResponseShape(t, status, output)
- if strings.Contains(output, "Run: ") {
- t.Fatalf("AMBIGUOUS must not fabricate a command: %q", output)
- }
- for _, candidate := range []string{"plan-one", "plan-two"} {
- if !strings.Contains(output, "- "+candidate) {
- t.Fatalf("candidates must be listed: %q", output)
- }
- }
- })
-}
-
-func assertResponseShape(t *testing.T, status NextStatus, output string) {
- t.Helper()
- if got := strings.Count(output, "### Next step"); got != 1 {
- t.Fatalf("response must carry exactly one next step, got %d: %q", got, output)
- }
- phrase := friendlyPhrase(status)
- if !strings.Contains(strings.ToLower(output), strings.ToLower(phrase)) {
- t.Fatalf("response must carry the friendly phrase %q: %q", phrase, output)
- }
-}
-
-// Negative: machine stage names and operation codes never appear as status
-// prose — only command lines may name helper verbs.
-func TestResponseHidesMachineTokens(t *testing.T) {
- fixtures := map[string]func(t *testing.T) string{
- "not_started": func(t *testing.T) string { return nextTestRepo(t) },
- "draft_plan": func(t *testing.T) string {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- return repo
- },
- "ambiguous": func(t *testing.T) string {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "plan-one")
- writeSavedFeaturePlan(t, repo, "plan-two")
- return repo
- },
- }
- for name, fixture := range fixtures {
- t.Run(name, func(t *testing.T) {
- _, output := renderedResponse(t, fixture(t))
- for _, line := range strings.Split(output, "\n") {
- if strings.Contains(line, ".product-loop/boatstack") {
- continue // the runnable command line is the legitimate exception
- }
- if match := machineTokens.FindString(line); match != "" {
- t.Fatalf("machine token %q leaked into status prose: %q", match, line)
- }
- }
- })
- }
-}
-
-// Relation: the response's command line is the identical CommandLine() the
-// flow prescription layer renders — one decision, two surfaces, no drift.
-func TestResponseCommandMatchesFlowPrescription(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
-
- status, output := renderedResponse(t, repo)
- next, err := nextControlFromStatus(repo, status)
- if err != nil {
- t.Fatal(err)
- }
- if next.Prescribed == nil {
- t.Fatal("fixture must prescribe a command")
- }
- if !strings.Contains(output, "Run: "+next.Prescribed.CommandLine()) {
- t.Fatalf("response and flow prescription drifted:\nresponse: %q\nprescribed: %q", output, next.Prescribed.CommandLine())
- }
- if !strings.Contains(FormatFlowNext(next), "Run: "+next.Prescribed.CommandLine()) {
- t.Fatal("FormatFlowNext no longer renders the same command line — parity broken")
- }
-}
-
-// Bypass: the exported boatstack-next instruction points at the helper-rendered
-// contract and no longer restates the per-stage decision table in prose — the
-// prose channel cannot silently reintroduce a second decision surface.
-func TestExportedNextInstructionDefersToRenderer(t *testing.T) {
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- inspected := 0
- for path, content := range bundle.Files {
- if !strings.Contains(path, "boatstack-next") {
- continue
- }
- inspected++
- text := string(content)
- if !strings.Contains(text, "--format response") {
- t.Fatalf("%s must point at the helper-rendered response contract", path)
- }
- for _, restated := range []string{"Distinguish NOT_STARTED", "FEATURE_COMPLETE, which is reserved"} {
- if strings.Contains(text, restated) {
- t.Fatalf("%s restates the state table the helper now renders: %q", path, restated)
- }
- }
- }
- if inspected == 0 {
- t.Fatal("no exported boatstack-next instruction found — the bypass guarantee would be vacuous")
- }
-}
-
-// Failure-state: rendering is read-only — the repository is byte-identical
-// after resolving and rendering the response.
-func TestResponseRenderingIsReadOnly(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- before, err := exec.Command("git", "-C", repo, "status", "--porcelain").CombinedOutput()
- if err != nil {
- t.Fatal(err)
- }
- _, _ = renderedResponse(t, repo)
- after, err := exec.Command("git", "-C", repo, "status", "--porcelain").CombinedOutput()
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatalf("rendering mutated the repository: before=%q after=%q", before, after)
- }
-}
diff --git a/boatstack/next_test.go b/boatstack/next_test.go
deleted file mode 100644
index 7fbfc86..0000000
--- a/boatstack/next_test.go
+++ /dev/null
@@ -1,615 +0,0 @@
-package boatstack
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "reflect"
- "strings"
- "testing"
-)
-
-func nextTestRepo(t *testing.T) string {
- t.Helper()
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop", "features"), 0o755); err != nil {
- t.Fatal(err)
- }
- config := testConfig()
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- return repo
-}
-
-func writeNextDelivery(t *testing.T, repo, feature, status string, activeIndex int) {
- t.Helper()
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- hash := writeNextPlanLock(t, directory)
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: hash,
- ActiveIndex: activeIndex, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: status}},
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-func writeNextPlanLock(t *testing.T, directory string) string {
- t.Helper()
- planPath := filepath.Join(directory, "plan.md")
- if err := os.WriteFile(planPath, []byte("# Synthetic managed plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- planSHA, err := SHA256File(planPath)
- if err != nil {
- t.Fatal(err)
- }
- value, err := MarshalJSON(map[string]any{"plan_sha256": planSHA})
- if err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(directory, "plan.lock.json")
- if err := os.WriteFile(lockPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- hash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- return hash
-}
-
-func writeSavedFeaturePlan(t *testing.T, repo, feature string) {
- t.Helper()
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "plan.md"), []byte("# Plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-// writeShippedFeatureArtifacts models a feature that was built and shipped, then
-// had its worktree (and the per-worktree delivery state.json) removed by cleanup:
-// only the committed plan.md, plan.lock.json, and pr.md survive.
-func writeShippedFeatureArtifacts(t *testing.T, repo, feature string) {
- t.Helper()
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- for name, body := range map[string]string{
- "plan.md": "# Plan\n",
- "plan.lock.json": "lock\n",
- "pr.md": "# PR\n",
- } {
- if err := os.WriteFile(filepath.Join(directory, name), []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
- }
-}
-
-func TestResolveNextReportsNotStartedWhenNoFeatureExists(t *testing.T) {
- repo := nextTestRepo(t)
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "VERIFIED" || status.ObservedStage != "NOT_STARTED" || status.NextOperation != "auto-plan" {
- t.Fatalf("unexpected status: %+v", status)
- }
-}
-
-// TestResolveNextIgnoresAmbientPlanFiles is the conformance guard for the
-// "no Boatstack context for things we did not ship" contract at the state
-// machine: saved, never-shipped plan files sitting in the historically scanned
-// directories must never surface as SOURCE_PLAN_READY or AMBIGUOUS. next-status
-// reports NOT_STARTED regardless.
-func TestResolveNextIgnoresAmbientPlanFiles(t *testing.T) {
- repo := nextTestRepo(t)
- for _, dir := range []string{
- ".product-loop/intake",
- ".cursor/plans",
- ".claude/plans",
- ".codex/plans",
- } {
- absolute := filepath.Join(repo, filepath.FromSlash(dir))
- if err := os.MkdirAll(absolute, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(absolute, "unshipped.md"), []byte("# Unshipped plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "NOT_STARTED" || status.NextOperation != "auto-plan" {
- t.Fatalf("ambient plan files leaked into next-status: %+v", status)
- }
- if status.ObservedStage == "SOURCE_PLAN_READY" {
- t.Fatal("SOURCE_PLAN_READY must no longer be produced")
- }
-}
-
-func TestResolveNextActiveDeliveryIsReported(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "active-feature", "BUILD", 0)
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.Feature != "active-feature" || status.ObservedStage != "BUILD" || status.NextOperation != "build" {
- t.Fatalf("active delivery not reported: %+v", status)
- }
-}
-
-func TestResolveNextOrphanedEvidenceBlocks(t *testing.T) {
- repo := nextTestRepo(t)
- directory := filepath.Join(repo, ".product-loop", "features", "orphan")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "pr.md"), []byte("# Preview\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "discard-delivery" {
- t.Fatalf("orphaned evidence did not block: %+v", status)
- }
-}
-
-func TestResolveNextBlocksHistoricalPlansWithoutSourceIntent(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "historical-one")
- writeSavedFeaturePlan(t, repo, "historical-two")
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- want := []string{"historical-one", "historical-two"}
- if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "AMBIGUOUS" || !reflect.DeepEqual(status.BlockingAmbiguity, want) {
- t.Fatalf("unexpected historical-plan ambiguity: %+v", status)
- }
-}
-
-// TestFeaturePlanCandidatesExcludesLockedAndShippedFeatures is the unit-level
-// guard for the shipped-feature ambiguity bug: locked (plan.lock.json) and
-// shipped (pr.md) feature dirs must never re-register as open plan candidates
-// even after their ephemeral per-worktree state.json was destroyed on cleanup.
-func TestFeaturePlanCandidatesExcludesLockedAndShippedFeatures(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "open-feature")
-
- locked := filepath.Join(repo, ".product-loop", "features", "locked-feature")
- if err := os.MkdirAll(locked, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(locked, "plan.md"), []byte("# Plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(locked, "plan.lock.json"), []byte("lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- writeShippedFeatureArtifacts(t, repo, "shipped-feature")
-
- candidates, err := featurePlanCandidates(repo)
- if err != nil {
- t.Fatal(err)
- }
- if !reflect.DeepEqual(candidates, []string{"open-feature"}) {
- t.Fatalf("locked/shipped features leaked into plan candidates: %v", candidates)
- }
-}
-
-// TestResolveNextIgnoresShippedFeatureCandidates reproduces the linked-worktree scenario
-// through ResolveNext: one genuinely open feature plus several shipped dirs whose
-// state.json was destroyed by worktree cleanup must resolve to the single open
-// candidate, not AMBIGUOUS.
-func TestResolveNextIgnoresShippedFeatureCandidates(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "open-feature")
- writeShippedFeatureArtifacts(t, repo, "shipped-one")
- writeShippedFeatureArtifacts(t, repo, "shipped-two")
- writeShippedFeatureArtifacts(t, repo, "shipped-three")
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage == "AMBIGUOUS" {
- t.Fatalf("shipped features re-registered as ambiguous candidates: %+v", status)
- }
- if status.VerificationStatus != "VERIFIED" || status.Feature != "open-feature" ||
- status.ObservedStage != "DRAFT_PLAN" || status.NextOperation != "plan-gate" {
- t.Fatalf("single open feature did not resolve cleanly: %+v", status)
- }
-}
-
-// TestResolveNextIgnoresShippedFeaturesFromLinkedWorktree reproduces the exact
-// reported symptom: from a fresh linked build worktree, shipped feature dirs
-// (committed plan.md/plan.lock.json/pr.md, no worktree-local state.json) must not
-// re-register as open candidates. Guards the durable-committed-artifact contract
-// under real worktree conditions.
-func TestResolveNextIgnoresShippedFeaturesFromLinkedWorktree(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
-
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop", "features"), 0o755); err != nil {
- t.Fatal(err)
- }
- value, err := MarshalJSON(testConfig())
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- writeSavedFeaturePlan(t, repo, "open-feature")
- writeShippedFeatureArtifacts(t, repo, "shipped-one")
- writeShippedFeatureArtifacts(t, repo, "shipped-two")
-
- runGit(t, repo, "add", "-A")
- runGit(t, repo, "commit", "-m", "seed shipped and open features")
-
- linked := filepath.Join(t.TempDir(), "linked")
- runGit(t, repo, "worktree", "add", "-b", "build-work", linked)
-
- status, err := ResolveNext(linked, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage == "AMBIGUOUS" {
- t.Fatalf("shipped features re-registered as ambiguous from linked worktree: %+v", status)
- }
- if status.VerificationStatus != "VERIFIED" || status.Feature != "open-feature" ||
- status.ObservedStage != "DRAFT_PLAN" || status.NextOperation != "plan-gate" {
- t.Fatalf("linked worktree did not resolve to the single open feature: %+v", status)
- }
-}
-
-func TestResolveNextPlanningStates(t *testing.T) {
- for _, test := range []struct {
- name, approval, stage, next string
- }{
- {name: "draft", stage: "DRAFT_PLAN", next: "plan-gate"},
- {name: "approved", approval: "approved", stage: "APPROVED", next: "build"},
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := nextTestRepo(t)
- directory := filepath.Join(repo, ".product-loop", "features", "recovery")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "plan.md"), []byte("# Plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if test.approval != "" {
- if err := os.WriteFile(filepath.Join(directory, "approval.md"), []byte("# Approval\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != test.stage || status.NextOperation != test.next || status.Feature != "recovery" {
- t.Fatalf("unexpected status: %+v", status)
- }
- })
- }
-}
-
-func TestResolveNextRoutesPolicyAuthorizedPlanToBuild(t *testing.T) {
- repo := nextTestRepo(t)
- configPath := filepath.Join(repo, ".product-loop", "project.json")
- config, _, err := LoadConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- config.Workflow.HumanPlanApproval = false
- value, _ := MarshalJSON(config)
- if err := os.WriteFile(configPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- writeSavedFeaturePlan(t, repo, "policy-ready")
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "POLICY_READY" || status.NextOperation != "build" || status.Feature != "policy-ready" {
- t.Fatalf("policy-authorized plan did not route to build: %+v", status)
- }
-}
-
-func TestResolveNextDeliveryTransitions(t *testing.T) {
- for _, test := range []struct{ state, next string }{
- {state: "BUILD", next: "build"},
- {state: "TEST_PASSED", next: "review-gate"},
- {state: "REVIEW_PASSED", next: "ship-gate"},
- } {
- t.Run(test.state, func(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "recovery", test.state, 0)
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != test.state || status.NextOperation != test.next || status.ActiveSlice != "delivery" {
- t.Fatalf("unexpected status: %+v", status)
- }
- })
- }
-}
-
-func TestResolveNextReportsPublishedUnknownWithoutPRVerification(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "recovery", "PUBLISHED", 1)
- if err := os.WriteFile(filepath.Join(repo, "source-plan.md"), []byte("# Source plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = "recovery"
- plan["source_plan_path"] = "../../../source-plan.md"
- writeMarkdownPlan(t, filepath.Join(repo, ".product-loop", "features", "recovery", "plan.md"), plan, true)
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.SchemaVersion != 2 || status.ObservedStage != "PUBLISHED" || status.Lifecycle != "PUBLISHED_UNKNOWN" || status.NextOperation != "none" {
- t.Fatalf("unexpected status: %+v", status)
- }
- if status.Feature != "recovery" {
- t.Fatalf("expected recovery feature to be marked complete: %+v", status)
- }
- if status.ActiveSlice != "delivery" {
- t.Fatalf("expected final delivery slice to be surfaced: %+v", status)
- }
-}
-
-func TestResolveNextReportsFeatureCompleteOnlyAfterVerifiedMerge(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "recovery", "PUBLISHED", 1)
- state, err := LoadDeliveryState(repo, "recovery")
- if err != nil {
- t.Fatal(err)
- }
- state.Slices[0].HeadBranch = "feat/recovery"
- state.Slices[0].PRURL = "https://example.invalid/pr/1"
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- withRecoveryGh(t, recoveryPR("MERGED", "feat/recovery", "abc123"))
- status, err := ResolveNext(repo, "recovery")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "FEATURE_COMPLETE" || status.Lifecycle != "PUBLISHED_MERGED" || status.NextOperation != "none" {
- t.Fatalf("unexpected merged status: %+v", status)
- }
-}
-
-func TestFormatNextStatusStillRendersSchemaV1Values(t *testing.T) {
- value := FormatNextStatus(NextStatus{SchemaVersion: 1, VerificationStatus: "VERIFIED", Feature: "legacy", ObservedStage: "FEATURE_COMPLETE", NextOperation: "none", Reason: "Legacy published state."})
- for _, expected := range []string{"Feature: legacy", "Boatstack stage: FEATURE_COMPLETE", "Next: none"} {
- if !strings.Contains(value, expected) {
- t.Fatalf("legacy status rendering omitted %q: %s", expected, value)
- }
- }
-}
-
-func TestResolveNextPrefersNewDraftOverCompletedHistory(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "published", "PUBLISHED", 1)
- directory := filepath.Join(repo, ".product-loop", "features", "new-feature")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "plan.md"), []byte("# Plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "DRAFT_PLAN" || status.Feature != "new-feature" || status.NextOperation != "plan-gate" {
- t.Fatalf("completed history masked newer work: %+v", status)
- }
-}
-
-// Positive and negative conformance for
-// control-law: explicit-selection-scopes-draft-resolution. Explicit selection
-// resolves the named draft; the unscoped surface remains honestly ambiguous.
-func TestResolveNextExplicitlySelectsOneOfMultipleDrafts(t *testing.T) {
- repo := nextTestRepo(t)
- writeValidSavedFeaturePlan(t, repo, "first-draft")
- writeValidSavedFeaturePlan(t, repo, "second-draft")
-
- unscoped, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if unscoped.ObservedStage != "AMBIGUOUS" || !reflect.DeepEqual(unscoped.BlockingAmbiguity, []string{"first-draft", "second-draft"}) {
- t.Fatalf("unscoped status hid draft ambiguity: %+v", unscoped)
- }
-
- selected, err := ResolveNext(repo, "second-draft")
- if err != nil {
- t.Fatal(err)
- }
- if selected.VerificationStatus != "VERIFIED" || selected.Feature != "second-draft" || selected.ObservedStage != "DRAFT_PLAN" || selected.NextOperation != "plan-gate" {
- t.Fatalf("explicit draft selection did not resolve deterministically: %+v", selected)
- }
-}
-
-func TestResolveNextBlocksMultipleActiveFeaturesWithoutMutation(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "first", "BUILD", 0)
- writeNextDelivery(t, repo, "second", "BUILD", 0)
- before, err := os.ReadFile(filepath.Join(repo, ".git", "boatstack", "deliveries", "first", "state.json"))
- if err != nil {
- t.Fatal(err)
- }
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- after, err := os.ReadFile(filepath.Join(repo, ".git", "boatstack", "deliveries", "first", "state.json"))
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || !reflect.DeepEqual(status.BlockingAmbiguity, []string{"first", "second"}) {
- t.Fatalf("unexpected ambiguity: %+v", status)
- }
- if !reflect.DeepEqual(before, after) {
- t.Fatal("read-only next inspection changed delivery state")
- }
-}
-
-func setIgnoredDeliveries(t *testing.T, repo string, ignored ...string) {
- t.Helper()
- config := testConfig()
- config.Workflow.IgnoredDeliveries = ignored
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestResolveNextIgnoredActiveDeliveryClearsAmbiguity(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "first", "BUILD", 0)
- writeNextDelivery(t, repo, "second", "BUILD", 0)
- setIgnoredDeliveries(t, repo, "first")
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage == "AMBIGUOUS" {
- t.Fatalf("ignored active delivery did not clear ambiguity: %+v", status)
- }
- if status.Feature != "second" || status.NextOperation != "build" {
- t.Fatalf("remaining active delivery did not resolve uniquely: %+v", status)
- }
-}
-
-func TestResolveNextIgnoredPublishedDeliveryClearsAmbiguity(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "published-one", "PUBLISHED", 1)
- writeNextDelivery(t, repo, "published-two", "PUBLISHED", 1)
- setIgnoredDeliveries(t, repo, "published-one")
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus == "BLOCKED" || status.ObservedStage == "AMBIGUOUS" {
- t.Fatalf("ignored published delivery did not clear ambiguity: %+v", status)
- }
- if status.Feature != "published-two" {
- t.Fatalf("remaining published delivery did not resolve uniquely: %+v", status)
- }
-}
-
-func TestResolveNextNewUnignoredActiveDeliveryStillBlocks(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "first", "BUILD", 0)
- writeNextDelivery(t, repo, "second", "BUILD", 0)
- // Ignoring an unrelated slug must not clear a genuinely ambiguous pair.
- setIgnoredDeliveries(t, repo, "unrelated")
-
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "AMBIGUOUS" || !reflect.DeepEqual(status.BlockingAmbiguity, []string{"first", "second"}) {
- t.Fatalf("un-ignored ambiguous deliveries should still block: %+v", status)
- }
-}
-
-func TestResolveNextBlocksStaleManagedState(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "recovery", "BUILD", 0)
- lockPath := filepath.Join(repo, ".product-loop", "features", "recovery", "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("changed\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "discard-delivery" {
- t.Fatalf("stale managed state was accepted: %+v", status)
- }
-}
-
-func TestResolveNextBlocksMissingLockAndOrphanPreview(t *testing.T) {
- for _, test := range []struct {
- name string
- withState bool
- }{
- {name: "managed state missing lock", withState: true},
- {name: "orphan preview", withState: false},
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := nextTestRepo(t)
- directory := filepath.Join(repo, ".product-loop", "features", "orphan")
- if test.withState {
- writeNextDelivery(t, repo, "orphan", "BUILD", 0)
- if err := os.Remove(filepath.Join(directory, "plan.lock.json")); err != nil {
- t.Fatal(err)
- }
- }
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "pr.md"), []byte("# Preview\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- before, err := os.ReadFile(filepath.Join(directory, "pr.md"))
- if err != nil {
- t.Fatal(err)
- }
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- after, err := os.ReadFile(filepath.Join(directory, "pr.md"))
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.ObservedStage != "INVALID_STATE" || status.NextOperation != "discard-delivery" {
- t.Fatalf("unexpected invalid state: %+v", status)
- }
- if !reflect.DeepEqual(before, after) {
- t.Fatal("invalid-state inspection modified the orphan preview")
- }
- })
- }
-}
diff --git a/boatstack/operation.go b/boatstack/operation.go
deleted file mode 100644
index 91a3704..0000000
--- a/boatstack/operation.go
+++ /dev/null
@@ -1,767 +0,0 @@
-package boatstack
-
-import (
- "crypto/rand"
- "encoding/hex"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "runtime"
- "sort"
- "strings"
- "time"
-)
-
-const (
- operationSchemaVersion = 1
- operationLeaseDuration = 15 * time.Minute
- operationRetention = 7 * 24 * time.Hour
-)
-
-type OperationState string
-
-const (
- OperationPrepared OperationState = "PREPARED"
- OperationAuthorized OperationState = "AUTHORIZED"
- OperationExecuting OperationState = "EXECUTING"
- OperationReconcileRequired OperationState = "RECONCILE_REQUIRED"
- OperationRetryable OperationState = "RETRYABLE"
- OperationSucceeded OperationState = "SUCCEEDED"
- OperationFailedFinal OperationState = "FAILED_FINAL"
-)
-
-type OperationScope struct {
- Feature string `json:"feature,omitempty"`
- Slice string `json:"slice,omitempty"`
- Worktree string `json:"worktree,omitempty"`
- HeadBranch string `json:"head_branch,omitempty"`
-}
-
-type OperationLease struct {
- TokenSHA256 string `json:"token_sha256"`
- AttemptKey string `json:"attempt_key"`
- Tool string `json:"tool,omitempty"`
- Target string `json:"target"`
- ExpiresAt string `json:"expires_at"`
-}
-
-type OperationObservation struct {
- Status string `json:"status,omitempty"`
- Detail string `json:"detail,omitempty"`
- Evidence string `json:"evidence,omitempty"`
- At string `json:"at,omitempty"`
-}
-
-// OperationReceipt is deliberately secret-free. It stores fingerprints and
-// bounded observations, never complete commands, tool arguments, or responses.
-type OperationReceipt struct {
- SchemaVersion int `json:"schema_version"`
- OperationID string `json:"operation_id"`
- Kind string `json:"kind"`
- Scope OperationScope `json:"scope"`
- Target string `json:"target"`
- PackageFingerprint string `json:"package_fingerprint"`
- AuthorizationFingerprint string `json:"authorization_fingerprint,omitempty"`
- State OperationState `json:"state"`
- RetryClass string `json:"retry_class"`
- Attempt int `json:"attempt"`
- MaxAttempts int `json:"max_attempts"`
- ExpectedPostcondition string `json:"expected_postcondition"`
- Lease *OperationLease `json:"lease,omitempty"`
- Observation OperationObservation `json:"observation,omitempty"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
-}
-
-type OperationPrepareOptions struct {
- Repo string
- Kind string
- Scope OperationScope
- Target string
- PackageFingerprint string
- AuthorizationFingerprint string
- RetryClass string
- MaxAttempts int
- ExpectedPostcondition string
-}
-
-type OperationBeginResult struct {
- Receipt OperationReceipt
- LeaseToken string
-}
-
-type OperationStatusResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- Operation *OperationReceipt `json:"operation,omitempty"`
- Blocker string `json:"blocker,omitempty"`
- ReconciliationRequired bool `json:"reconciliation_required"`
- NextOperation string `json:"next_operation"`
-}
-
-var operationNow = time.Now
-
-var ErrOperationInFlight = errors.New("identical operation is already executing")
-
-func operationTimestamp() string {
- return operationNow().UTC().Truncate(time.Second).Format(time.RFC3339)
-}
-
-// operationDirectory holds the operation ledger. It is per-worktree: an operation
-// records a mutation performed by one worktree at one time, so worktree A's ledger
-// must never sit on worktree B's read/write path (a shared ledger produced false
-// "operation identity does not match" blocks across worktrees). The "v2" segment
-// is intentional: the main worktree's Git directory aliases the common directory,
-// so bumping the version cleanly orphans the legacy clone-shared "v1" ledger for
-// every worktree — including main — instead of silently inheriting its receipts.
-func operationDirectory(repo string) (string, error) {
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return "", err
- }
- return ctx.OperationDir()
-}
-
-// pruneLegacyOperationLedger removes the pre-isolation clone-shared "v1" ledger
-// under the Git common directory. Once operations moved to the per-worktree "v2"
-// ledger those receipts are orphaned for every worktree (including main, whose
-// Git directory aliases the common directory). This is best-effort hygiene: the
-// v2 path already guarantees correctness, so any failure here is ignored.
-func pruneLegacyOperationLedger(repo string) {
- common, err := gitCommonDir(repo)
- if err != nil {
- return
- }
- legacy := filepath.Join(common, "boatstack", "operations", "v1")
- _ = os.RemoveAll(legacy)
-}
-
-func operationOwnedPath(repo, operationID string) (controllerPath, error) {
- id, err := safeCacheSegment(operationID, "operation id")
- if err != nil {
- return controllerPath{}, err
- }
- ctx := WorkspaceFor(repo)
- directory, err := ctx.OperationDir()
- if err != nil {
- return controllerPath{}, err
- }
- return ctx.worktreeOwnedPath(filepath.Join(directory, id+".json"))
-}
-
-func operationPath(repo, operationID string) (string, error) {
- owned, err := operationOwnedPath(repo, operationID)
- return owned.path, err
-}
-
-func operationID(kind, target, fingerprint string) string {
- return SHA256Bytes([]byte(strings.TrimSpace(kind) + "\x00" + strings.TrimSpace(target) + "\x00" + strings.TrimSpace(fingerprint)))[:24]
-}
-
-func validOperationState(state OperationState) bool {
- switch state {
- case OperationPrepared, OperationAuthorized, OperationExecuting, OperationReconcileRequired, OperationRetryable, OperationSucceeded, OperationFailedFinal:
- return true
- default:
- return false
- }
-}
-
-func validRetryClass(value string) bool {
- switch value {
- case "READ_ONLY", "ATOMIC_LOCAL", "IDEMPOTENT_EXTERNAL", "RECONCILE_FIRST":
- return true
- default:
- return false
- }
-}
-
-func validateOperation(receipt OperationReceipt) error {
- if receipt.SchemaVersion != operationSchemaVersion || receipt.OperationID == "" || receipt.Kind == "" || receipt.Target == "" || receipt.PackageFingerprint == "" {
- return fmt.Errorf("operation receipt identity is invalid")
- }
- if !validOperationState(receipt.State) || !validRetryClass(receipt.RetryClass) || receipt.MaxAttempts < 1 || receipt.Attempt < 0 || receipt.Attempt > receipt.MaxAttempts {
- return fmt.Errorf("operation receipt state is invalid")
- }
- if receipt.State == OperationExecuting && receipt.Lease == nil {
- return fmt.Errorf("executing operation has no lease")
- }
- return nil
-}
-
-func loadOperation(repo, id string) (OperationReceipt, error) {
- path, err := operationPath(repo, id)
- if err != nil {
- return OperationReceipt{}, err
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return OperationReceipt{}, err
- }
- var receipt OperationReceipt
- if err := DecodeJSON("load operation receipt", path, value, &receipt); err != nil {
- return OperationReceipt{}, err
- }
- if err := validateOperation(receipt); err != nil {
- return OperationReceipt{}, err
- }
- return receipt, nil
-}
-
-func saveOperation(repo string, receipt OperationReceipt) error {
- if err := validateOperation(receipt); err != nil {
- return err
- }
- path, err := operationPath(repo, receipt.OperationID)
- if err != nil {
- return err
- }
- value, err := MarshalJSON(receipt)
- if err != nil {
- return err
- }
- return atomicWriteMode(path, value, 0o600)
-}
-
-func withOperationLock(repo, id string, apply func() error) error {
- path, err := operationOwnedPath(repo, id)
- if err != nil {
- return err
- }
- lockPath, err := path.Sibling(strings.TrimSuffix(filepath.Base(path.path), ".json") + ".lock")
- if err != nil {
- return err
- }
- lock := lockPath.path
- if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil {
- return err
- }
- for attempt := 0; attempt < 100; attempt++ {
- file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if openErr == nil {
- _, _ = fmt.Fprintf(file, "%d %s\n", os.Getpid(), operationTimestamp())
- _ = file.Close()
- defer os.Remove(lock)
- return apply()
- }
- if !isLockContention(openErr, lock) {
- return openErr
- }
- if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute {
- _ = os.Remove(lock)
- continue
- }
- time.Sleep(10 * time.Millisecond)
- }
- return fmt.Errorf("operation %s is busy", id)
-}
-
-// Windows can report ERROR_ACCESS_DENIED while another process owns or has just
-// released an O_EXCL lock file. Retry that condition within the caller's bounded
-// budget; other platforms require the lock path to exist.
-func isLockContention(openErr error, lock string) bool {
- return isLockContentionForOS(openErr, lock, runtime.GOOS)
-}
-
-func isLockContentionForOS(openErr error, lock, goos string) bool {
- if os.IsExist(openErr) {
- return true
- }
- if !os.IsPermission(openErr) {
- return false
- }
- // Windows can keep an exclusive lock handle alive briefly after the owner
- // removes its directory entry. During that interval OpenFile reports
- // ERROR_ACCESS_DENIED while a following Stat can already report not-exist.
- // Retry within the caller's fixed budget; a real ACL failure still exhausts
- // that budget without entering the critical section.
- if goos == "windows" {
- return true
- }
- _, statErr := os.Stat(lock)
- return statErr == nil
-}
-
-func PrepareOperation(options OperationPrepareOptions) (OperationReceipt, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return OperationReceipt{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return OperationReceipt{}, err
- }
- // Retention is best-effort and never prevents a new supervised operation.
- _ = compactOperations(repo)
- kind := strings.TrimSpace(options.Kind)
- target := strings.TrimSpace(options.Target)
- fingerprint := strings.TrimSpace(options.PackageFingerprint)
- if kind == "" || target == "" || fingerprint == "" || strings.TrimSpace(options.ExpectedPostcondition) == "" {
- return OperationReceipt{}, fmt.Errorf("operation requires kind, target, package fingerprint, and expected postcondition")
- }
- retryClass := strings.ToUpper(strings.TrimSpace(options.RetryClass))
- if !validRetryClass(retryClass) {
- return OperationReceipt{}, fmt.Errorf("unsupported operation retry class %q", options.RetryClass)
- }
- maximum := options.MaxAttempts
- if maximum == 0 {
- maximum = 3
- }
- if maximum < 1 || maximum > 10 {
- return OperationReceipt{}, fmt.Errorf("operation max attempts must be between 1 and 10")
- }
- id := operationID(kind, target, fingerprint)
- var result OperationReceipt
- err = withOperationLock(repo, id, func() error {
- existing, loadErr := loadOperation(repo, id)
- if loadErr == nil {
- if existing.Kind != kind || existing.Target != target || existing.PackageFingerprint != fingerprint ||
- existing.RetryClass != retryClass || existing.MaxAttempts != maximum ||
- existing.ExpectedPostcondition != strings.TrimSpace(options.ExpectedPostcondition) || existing.Scope != options.Scope {
- return fmt.Errorf("existing operation identity does not match the prepared package")
- }
- authorization := strings.TrimSpace(options.AuthorizationFingerprint)
- if authorization != "" && existing.AuthorizationFingerprint != "" && existing.AuthorizationFingerprint != authorization {
- return fmt.Errorf("operation authorization fingerprint changed after preparation")
- }
- if authorization != "" && existing.State == OperationPrepared {
- existing.AuthorizationFingerprint = authorization
- existing.State = OperationAuthorized
- existing.UpdatedAt = operationTimestamp()
- if err := saveOperation(repo, existing); err != nil {
- return err
- }
- }
- result = existing
- return nil
- }
- if !os.IsNotExist(loadErr) {
- return loadErr
- }
- now := operationTimestamp()
- state := OperationPrepared
- if strings.TrimSpace(options.AuthorizationFingerprint) != "" {
- state = OperationAuthorized
- }
- result = OperationReceipt{
- SchemaVersion: operationSchemaVersion, OperationID: id, Kind: kind, Scope: options.Scope,
- Target: target, PackageFingerprint: fingerprint, AuthorizationFingerprint: strings.TrimSpace(options.AuthorizationFingerprint),
- State: state, RetryClass: retryClass, MaxAttempts: maximum,
- ExpectedPostcondition: strings.TrimSpace(options.ExpectedPostcondition), CreatedAt: now, UpdatedAt: now,
- }
- return saveOperation(repo, result)
- })
- return result, err
-}
-
-func AuthorizeOperation(repoPath, id, packageFingerprint, authorizationFingerprint string) (OperationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return OperationReceipt{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return OperationReceipt{}, err
- }
- var result OperationReceipt
- err = withOperationLock(repo, id, func() error {
- receipt, loadErr := loadOperation(repo, id)
- if loadErr != nil {
- return loadErr
- }
- if receipt.PackageFingerprint != strings.TrimSpace(packageFingerprint) || strings.TrimSpace(authorizationFingerprint) == "" {
- return fmt.Errorf("operation authorization does not match the prepared package")
- }
- if receipt.AuthorizationFingerprint != "" && receipt.AuthorizationFingerprint != strings.TrimSpace(authorizationFingerprint) {
- return fmt.Errorf("operation authorization fingerprint changed after preparation")
- }
- if receipt.State != OperationPrepared && receipt.State != OperationAuthorized {
- return fmt.Errorf("operation %s cannot be authorized from %s", id, receipt.State)
- }
- receipt.AuthorizationFingerprint = strings.TrimSpace(authorizationFingerprint)
- receipt.State = OperationAuthorized
- receipt.UpdatedAt = operationTimestamp()
- result = receipt
- return saveOperation(repo, receipt)
- })
- return result, err
-}
-
-func randomLeaseToken() (string, error) {
- value := make([]byte, 32)
- if _, err := rand.Read(value); err != nil {
- return "", err
- }
- return hex.EncodeToString(value), nil
-}
-
-func BeginOperation(repoPath, id, attemptKey, tool string) (OperationBeginResult, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return OperationBeginResult{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return OperationBeginResult{}, err
- }
- attemptKey = strings.TrimSpace(attemptKey)
- if attemptKey == "" {
- return OperationBeginResult{}, fmt.Errorf("operation attempt key is required")
- }
- var result OperationBeginResult
- err = withOperationLock(repo, id, func() error {
- receipt, loadErr := loadOperation(repo, id)
- if loadErr != nil {
- return loadErr
- }
- if receipt.State == OperationSucceeded {
- result.Receipt = receipt
- return nil
- }
- if receipt.State == OperationExecuting {
- expires, parseErr := time.Parse(time.RFC3339, receipt.Lease.ExpiresAt)
- if parseErr != nil || !operationNow().Before(expires) {
- receipt.State = OperationReconcileRequired
- receipt.Lease = nil
- receipt.Observation = OperationObservation{Status: "UNKNOWN", Detail: "execution lease expired before completion was observed", At: operationTimestamp()}
- receipt.UpdatedAt = operationTimestamp()
- if err := saveOperation(repo, receipt); err != nil {
- return err
- }
- result.Receipt = receipt
- return fmt.Errorf("operation completion is unknown; reconcile before retry")
- }
- result.Receipt = receipt
- return ErrOperationInFlight
- }
- if receipt.State != OperationAuthorized && receipt.State != OperationRetryable {
- return fmt.Errorf("operation %s cannot begin from %s", id, receipt.State)
- }
- if receipt.AuthorizationFingerprint == "" {
- return fmt.Errorf("operation %s has no fingerprinted authorization", id)
- }
- if receipt.Attempt >= receipt.MaxAttempts {
- receipt.State = OperationFailedFinal
- receipt.Observation = OperationObservation{Status: "FAILED", Detail: "persistent retry budget exhausted", At: operationTimestamp()}
- receipt.UpdatedAt = operationTimestamp()
- _ = saveOperation(repo, receipt)
- result.Receipt = receipt
- return fmt.Errorf("operation retry budget is exhausted")
- }
- token, tokenErr := randomLeaseToken()
- if tokenErr != nil {
- return tokenErr
- }
- receipt.Attempt++
- receipt.State = OperationExecuting
- receipt.Lease = &OperationLease{
- TokenSHA256: SHA256Bytes([]byte(token)), AttemptKey: attemptKey, Tool: strings.TrimSpace(tool), Target: receipt.Target,
- ExpiresAt: operationNow().UTC().Add(operationLeaseDuration).Truncate(time.Second).Format(time.RFC3339),
- }
- receipt.Observation = OperationObservation{}
- receipt.UpdatedAt = operationTimestamp()
- if err := saveOperation(repo, receipt); err != nil {
- return err
- }
- result = OperationBeginResult{Receipt: receipt, LeaseToken: token}
- return nil
- })
- return result, err
-}
-
-// reconcileSucceededInstallUpdate reopens only a local atomic Boatstack update
-// whose previously observed postcondition no longer holds. A terminal receipt is
-// evidence about an observation in time, not permanent authority to suppress a
-// later explicit update after the repository was restored or otherwise regressed.
-// Other operation kinds keep their existing terminal replay semantics.
-func reconcileSucceededInstallUpdate(repoPath, id, detail, evidence string) (OperationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return OperationReceipt{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return OperationReceipt{}, err
- }
- var result OperationReceipt
- err = withOperationLock(repo, id, func() error {
- receipt, loadErr := loadOperation(repo, id)
- if loadErr != nil {
- return loadErr
- }
- if receipt.State != OperationSucceeded {
- return fmt.Errorf("operation %s is no longer a succeeded update", id)
- }
- if receipt.Kind != "install-update" || receipt.RetryClass != "ATOMIC_LOCAL" {
- return fmt.Errorf("operation %s does not support terminal postcondition reconciliation", id)
- }
- receipt.State = OperationRetryable
- receipt.Attempt = 0
- receipt.Lease = nil
- receipt.Observation = OperationObservation{
- Status: "POSTCONDITION_MISSING", Detail: boundedObservation(detail),
- Evidence: boundedObservation(evidence), At: operationTimestamp(),
- }
- receipt.UpdatedAt = operationTimestamp()
- result = receipt
- return saveOperation(repo, receipt)
- })
- return result, err
-}
-
-func completeOperation(repoPath, id, leaseToken, attemptKey, outcome, detail, evidence string, trustedAttempt bool) (OperationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return OperationReceipt{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return OperationReceipt{}, err
- }
- var result OperationReceipt
- err = withOperationLock(repo, id, func() error {
- receipt, loadErr := loadOperation(repo, id)
- if loadErr != nil {
- return loadErr
- }
- if receipt.State == OperationSucceeded || receipt.State == OperationFailedFinal {
- result = receipt
- return nil
- }
- if receipt.State != OperationExecuting || receipt.Lease == nil {
- return fmt.Errorf("operation %s has no executing attempt to complete", id)
- }
- if trustedAttempt {
- if strings.TrimSpace(attemptKey) == "" || receipt.Lease.AttemptKey != strings.TrimSpace(attemptKey) {
- return fmt.Errorf("operation completion does not match the active attempt")
- }
- } else if SHA256Bytes([]byte(strings.TrimSpace(leaseToken))) != receipt.Lease.TokenSHA256 {
- return fmt.Errorf("operation lease is invalid or replayed")
- }
- outcome = strings.ToUpper(strings.TrimSpace(outcome))
- switch outcome {
- case "SUCCEEDED":
- receipt.State = OperationSucceeded
- case "RETRYABLE":
- if receipt.RetryClass == "RECONCILE_FIRST" {
- return fmt.Errorf("reconcile-first operation cannot be marked retryable without reconciliation")
- }
- if receipt.Attempt >= receipt.MaxAttempts {
- receipt.State = OperationFailedFinal
- } else {
- receipt.State = OperationRetryable
- }
- case "UNKNOWN":
- receipt.State = OperationReconcileRequired
- case "FAILED_FINAL":
- receipt.State = OperationFailedFinal
- default:
- return fmt.Errorf("unsupported operation outcome %q", outcome)
- }
- receipt.Lease = nil
- receipt.Observation = OperationObservation{Status: outcome, Detail: boundedObservation(detail), Evidence: boundedObservation(evidence), At: operationTimestamp()}
- receipt.UpdatedAt = operationTimestamp()
- result = receipt
- return saveOperation(repo, receipt)
- })
- return result, err
-}
-
-func boundedObservation(value string) string {
- value = strings.TrimSpace(value)
- secretAssignment := regexp.MustCompile(`(?i)\b(token|password|secret|authorization|api[_-]?key)\s*[:=]\s*[^\s,;]+`)
- value = secretAssignment.ReplaceAllString(value, "$1=")
- bearer := regexp.MustCompile(`(?i)\bbearer\s+[^\s,;]+`)
- value = bearer.ReplaceAllString(value, "Bearer ")
- if len(value) > 512 {
- return value[:512]
- }
- return value
-}
-
-func CompleteOperation(repo, id, leaseToken, outcome, detail, evidence string) (OperationReceipt, error) {
- return completeOperation(repo, id, leaseToken, "", outcome, detail, evidence, false)
-}
-
-func CompleteOperationAttempt(repo, id, attemptKey, outcome, detail, evidence string) (OperationReceipt, error) {
- return completeOperation(repo, id, "", attemptKey, outcome, detail, evidence, true)
-}
-
-func RecordOperationReconciliation(repoPath, id, result, detail, evidence string) (OperationReceipt, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return OperationReceipt{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return OperationReceipt{}, err
- }
- var output OperationReceipt
- err = withOperationLock(repo, id, func() error {
- receipt, loadErr := loadOperation(repo, id)
- if loadErr != nil {
- return loadErr
- }
- if receipt.State != OperationReconcileRequired {
- return fmt.Errorf("operation %s does not require reconciliation", id)
- }
- switch strings.ToUpper(strings.TrimSpace(result)) {
- case "OBSERVED_SUCCEEDED":
- receipt.State = OperationSucceeded
- case "OBSERVED_ABSENT", "OBSERVED_PARTIAL":
- if receipt.Attempt >= receipt.MaxAttempts {
- receipt.State = OperationFailedFinal
- } else {
- receipt.State = OperationRetryable
- }
- case "STILL_UNKNOWN":
- receipt.State = OperationReconcileRequired
- default:
- return fmt.Errorf("unsupported reconciliation result %q", result)
- }
- receipt.Observation = OperationObservation{Status: strings.ToUpper(strings.TrimSpace(result)), Detail: boundedObservation(detail), Evidence: boundedObservation(evidence), At: operationTimestamp()}
- receipt.UpdatedAt = operationTimestamp()
- output = receipt
- return saveOperation(repo, receipt)
- })
- return output, err
-}
-
-func operationReceipts(repo string) ([]OperationReceipt, error) {
- directory, err := operationDirectory(repo)
- if err != nil {
- return nil, err
- }
- entries, err := os.ReadDir(directory)
- if os.IsNotExist(err) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- values := []OperationReceipt{}
- for _, entry := range entries {
- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
- continue
- }
- receipt, loadErr := loadOperation(repo, strings.TrimSuffix(entry.Name(), ".json"))
- if loadErr != nil {
- return nil, loadErr
- }
- values = append(values, receipt)
- }
- sort.Slice(values, func(i, j int) bool { return values[i].OperationID < values[j].OperationID })
- return values, nil
-}
-
-func refreshExpiredOperation(repo, id string) (OperationReceipt, error) {
- var result OperationReceipt
- err := withOperationLock(repo, id, func() error {
- receipt, err := loadOperation(repo, id)
- if err != nil {
- return err
- }
- if receipt.State == OperationExecuting && receipt.Lease != nil {
- expires, parseErr := time.Parse(time.RFC3339, receipt.Lease.ExpiresAt)
- if parseErr != nil || !operationNow().Before(expires) {
- receipt.State = OperationReconcileRequired
- receipt.Lease = nil
- receipt.Observation = OperationObservation{Status: "UNKNOWN", Detail: "execution lease expired before completion was observed", At: operationTimestamp()}
- receipt.UpdatedAt = operationTimestamp()
- if err := saveOperation(repo, receipt); err != nil {
- return err
- }
- }
- }
- result = receipt
- return nil
- })
- return result, err
-}
-
-func ResolveOperationStatus(repoPath, id string) (OperationStatusResult, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return OperationStatusResult{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return OperationStatusResult{}, err
- }
- if strings.TrimSpace(id) != "" {
- receipt, loadErr := refreshExpiredOperation(repo, strings.TrimSpace(id))
- if loadErr != nil {
- return OperationStatusResult{}, loadErr
- }
- return operationStatusFor(receipt), nil
- }
- branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current"))
- receipts, err := operationReceipts(repo)
- if err != nil {
- return OperationStatusResult{}, err
- }
- active := []OperationReceipt{}
- for _, receipt := range receipts {
- if receipt.State == OperationExecuting {
- if refreshed, refreshErr := refreshExpiredOperation(repo, receipt.OperationID); refreshErr == nil {
- receipt = refreshed
- }
- }
- if receipt.State == OperationSucceeded || receipt.State == OperationFailedFinal {
- continue
- }
- if receipt.Scope.HeadBranch == "" || receipt.Scope.HeadBranch == branch {
- active = append(active, receipt)
- }
- }
- if len(active) == 0 {
- return OperationStatusResult{SchemaVersion: operationSchemaVersion, VerificationStatus: "VERIFIED", NextOperation: "none"}, nil
- }
- if len(active) > 1 {
- return OperationStatusResult{SchemaVersion: operationSchemaVersion, VerificationStatus: "AMBIGUOUS", Blocker: "more than one unfinished operation matches the current branch", NextOperation: "specify_operation"}, nil
- }
- return operationStatusFor(active[0]), nil
-}
-
-func operationStatusFor(receipt OperationReceipt) OperationStatusResult {
- next := "none"
- blocker := ""
- switch receipt.State {
- case OperationPrepared:
- next = "authorize"
- case OperationAuthorized, OperationRetryable:
- next = "execute"
- case OperationExecuting:
- next = "wait"
- blocker = "an authorized attempt is already executing"
- case OperationReconcileRequired:
- next = "reconcile"
- blocker = "completion was not observed"
- case OperationFailedFinal:
- next = "manual_recovery"
- blocker = receipt.Observation.Detail
- }
- copy := receipt
- return OperationStatusResult{
- SchemaVersion: operationSchemaVersion, VerificationStatus: "VERIFIED", Operation: ©,
- Blocker: blocker, ReconciliationRequired: receipt.State == OperationReconcileRequired, NextOperation: next,
- }
-}
-
-func compactOperations(repo string) error {
- receipts, err := operationReceipts(repo)
- if err != nil {
- return err
- }
- cutoff := operationNow().Add(-operationRetention)
- for _, receipt := range receipts {
- if receipt.State != OperationSucceeded && receipt.State != OperationFailedFinal {
- continue
- }
- updated, parseErr := time.Parse(time.RFC3339, receipt.UpdatedAt)
- if parseErr != nil || updated.After(cutoff) || receipt.Observation.Detail == "" {
- continue
- }
- receipt.Observation.Detail = "terminal receipt compacted"
- receipt.Observation.Evidence = ""
- if err := saveOperation(repo, receipt); err != nil {
- return err
- }
- }
- return nil
-}
diff --git a/boatstack/operation_test.go b/boatstack/operation_test.go
deleted file mode 100644
index 17ff212..0000000
--- a/boatstack/operation_test.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package boatstack
-
-import (
- "errors"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
- "time"
-)
-
-func operationTestRepo(t *testing.T) string {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "README.md")
- runGit(t, repo, "commit", "-m", "base")
- return repo
-}
-
-func activeOperationTestRepo(t *testing.T) string {
- t.Helper()
- repo := safetyTestRepo(t)
- feature := "durable-tool"
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(directory, "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- state := DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash,
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "BUILD", BaseBranch: "main", HeadBranch: "main"}},
- }
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- if err := syncEngagementLease(repo, state); err != nil {
- t.Fatal(err)
- }
- return repo
-}
-
-func TestHostHooksCreateAndCompleteOneDurableAttempt(t *testing.T) {
- fixtures := map[string]struct{ pre, post string }{
- "cursor": {
- pre: `{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`,
- post: `{"hook_event_name":"postToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_result":"ok"}`,
- },
- "claude": {
- pre: `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`,
- post: `{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_response":"ok"}`,
- },
- "codex": {
- pre: `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`,
- post: `{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_response":"ok"}`,
- },
- "gemini": {
- pre: `{"hook_event_name":"BeforeTool","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`,
- post: `{"hook_event_name":"AfterTool","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_response":"ok"}`,
- },
- }
- for host, fixture := range fixtures {
- t.Run(host, func(t *testing.T) {
- repo := activeOperationTestRepo(t)
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.pre)}); denied {
- t.Fatalf("first supervised tool call was denied: %s", output)
- }
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.post)}); denied {
- t.Fatalf("completion event was denied: %s", output)
- }
- status, err := ResolveOperationStatus(repo, "")
- if err != nil || status.Operation != nil {
- t.Fatalf("completed operation remained active: %+v %v", status, err)
- }
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.pre)})
- if !denied || !strings.Contains(string(output), "already") {
- t.Fatalf("late duplicate was not suppressed: %s", output)
- }
- })
- }
-}
-
-// Bypass conformance for control-law: active-delivery-effects-are-supervised.
-// Generic query tools and live SQL clients are not proven read-only by their
-// names, so both enter durable operation supervision while a delivery is active.
-func TestAmbiguousQueryAndLiveSQLAreSupervisedDuringActiveDelivery(t *testing.T) {
- tests := map[string][]byte{
- "generic MCP query": []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__database__query","tool_input":{"query":"SELECT account_id FROM accounts WHERE account_id = 1"}}`),
- "live SQL client": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"psql -c 'SELECT account_id FROM accounts WHERE account_id = 1'"}}`),
- }
- for name, input := range tests {
- t.Run(name, func(t *testing.T) {
- repo := activeOperationTestRepo(t)
- if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: input}); denied {
- t.Fatalf("first supervised operation was denied: %s", output)
- }
- status, err := ResolveOperationStatus(repo, "")
- if err != nil || status.Operation == nil || status.Operation.State != OperationExecuting {
- t.Fatalf("ambiguous effect bypassed active-delivery supervision: %+v %v", status, err)
- }
- })
- }
-}
-
-func TestAsyncCompletionCannotInitiateAnOperation(t *testing.T) {
- repo := activeOperationTestRepo(t)
- post := []byte(`{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"never-started.go","content":"x"},"tool_response":"ok"}`)
- if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: post}); denied {
- t.Fatalf("unmatched completion should be consumed without authority: %s", output)
- }
- status, err := ResolveOperationStatus(repo, "")
- if err != nil || status.Operation != nil {
- t.Fatalf("async completion created workflow authority: %+v %v", status, err)
- }
-}
-
-func TestHostFailureEventsRequireReconciliation(t *testing.T) {
- fixtures := map[string]struct{ pre, post string }{
- "cursor": {
- pre: `{"hook_event_name":"preToolUse","tool_call_id":"c-1","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`,
- post: `{"hook_event_name":"postToolUseFailure","tool_call_id":"c-1","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"error":"write interrupted"}`,
- },
- "claude": {
- pre: `{"hook_event_name":"PreToolUse","tool_use_id":"c-2","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`,
- post: `{"hook_event_name":"PostToolUseFailure","tool_use_id":"c-2","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"error":"write interrupted"}`,
- },
- "codex": {
- pre: `{"hook_event_name":"PreToolUse","tool_call_id":"c-3","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`,
- post: `{"hook_event_name":"PostToolUse","tool_call_id":"c-3","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"tool_response":{"error":"write interrupted"}}`,
- },
- "gemini": {
- pre: `{"hook_event_name":"BeforeTool","call_id":"c-4","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`,
- post: `{"hook_event_name":"AfterTool","call_id":"c-4","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"tool_response":{"error":"write interrupted"}}`,
- },
- }
- for host, fixture := range fixtures {
- t.Run(host, func(t *testing.T) {
- repo := activeOperationTestRepo(t)
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.pre)}); denied {
- t.Fatalf("pre event denied: %s", output)
- }
- if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.post)}); denied {
- t.Fatalf("post-failure observation denied: %s", output)
- }
- status, err := ResolveOperationStatus(repo, "")
- if err != nil || status.Operation == nil || status.Operation.State != OperationReconcileRequired || !status.ReconciliationRequired {
- t.Fatalf("failure was not preserved as unknown: %+v %v", status, err)
- }
- })
- }
-}
-
-func TestSafetyFindingOperationFieldsRemainSecretFree(t *testing.T) {
- finding := SafetyFinding{Category: "operation-in-flight", OperationID: "abc", OperationState: "EXECUTING", AttemptNumber: 2, ReconciliationRequired: false}
- value, err := MarshalJSON(finding)
- if err != nil {
- t.Fatal(err)
- }
- for _, expected := range []string{`"operation_id": "abc"`, `"operation_state": "EXECUTING"`, `"attempt_number": 2`} {
- if !strings.Contains(string(value), expected) {
- t.Fatalf("missing safety field %s: %s", expected, value)
- }
- }
- if strings.Contains(string(value), "token") || strings.Contains(string(value), "command") {
- t.Fatalf("safety finding leaked execution detail: %s", value)
- }
-}
-
-func TestOperationObservationsRedactObviousSecrets(t *testing.T) {
- value := boundedObservation("request failed authorization=BearerValue token:abc123 password=hunter2 Bearer xyz")
- for _, secret := range []string{"BearerValue", "abc123", "hunter2", " xyz"} {
- if strings.Contains(value, secret) {
- t.Fatalf("operation observation retained %q: %s", secret, value)
- }
- }
-}
-
-func preparedOperation(t *testing.T, repo, fingerprint, retryClass string, attempts int) OperationReceipt {
- t.Helper()
- receipt, err := PrepareOperation(OperationPrepareOptions{
- Repo: repo, Kind: "test-write", Target: "artifact.json", PackageFingerprint: fingerprint,
- AuthorizationFingerprint: "approved-" + fingerprint, RetryClass: retryClass, MaxAttempts: attempts,
- ExpectedPostcondition: "artifact hash equals " + fingerprint,
- })
- if err != nil {
- t.Fatal(err)
- }
- return receipt
-}
-
-// control-law: controller-effects-use-the-owning-storage-boundary
-func TestDetachedOperationLifecycleUsesExternalOwnedBoundary(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/detached-operations.git")
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- receipt := preparedOperation(t, repo, "detached-package", "ATOMIC_LOCAL", 1)
- if receipt.State != OperationAuthorized {
- t.Fatalf("operation was not authorized: %+v", receipt)
- }
- path, err := operationPath(repo, receipt.OperationID)
- if err != nil {
- t.Fatal(err)
- }
- ctx := WorkspaceFor(repo)
- directory, err := ctx.OperationDir()
- if err != nil {
- t.Fatal(err)
- }
- if !strings.HasPrefix(path, directory+string(filepath.Separator)) || strings.HasPrefix(path, repo+string(filepath.Separator)) {
- t.Fatalf("detached operation escaped its external ledger: %s", path)
- }
- if _, err := os.Stat(path); err != nil {
- t.Fatalf("detached operation receipt was not written: %v", err)
- }
- gitDir, err := worktreeGitDir(repo)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(filepath.Join(gitDir, "boatstack", "operations", "v2", filepath.Base(path))); !os.IsNotExist(err) {
- t.Fatalf("detached receipt also entered the Git directory: %v", err)
- }
-}
-
-// control-law: detached-owned-boundaries-reject-symlink-escapes
-func TestDetachedOperationRejectsSymlinkedControllerPath(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink creation requires privileges on Windows")
- }
- repo := detachedTestRepo(t, "https://github.com/acme/detached-operation-symlink.git")
- if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil {
- t.Fatal(err)
- }
- base, err := WorkspaceFor(repo).worktreeControlDir()
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(base, 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(t.TempDir(), filepath.Join(base, "operations")); err != nil {
- t.Fatal(err)
- }
- _, err = PrepareOperation(OperationPrepareOptions{
- Repo: repo, Kind: "test-write", Target: "artifact.json", PackageFingerprint: "escape",
- AuthorizationFingerprint: "approved", RetryClass: "ATOMIC_LOCAL", MaxAttempts: 1,
- ExpectedPostcondition: "artifact exists",
- })
- if err == nil || !strings.Contains(err.Error(), "symlinked path") {
- t.Fatalf("symlinked detached operation path was not rejected: %v", err)
- }
-}
-
-func TestOperationLifecycleAndReplayProtection(t *testing.T) {
- repo := operationTestRepo(t)
- receipt := preparedOperation(t, repo, "package-a", "ATOMIC_LOCAL", 2)
- if receipt.State != OperationAuthorized || receipt.Attempt != 0 {
- t.Fatalf("unexpected prepared receipt: %+v", receipt)
- }
- begin, err := BeginOperation(repo, receipt.OperationID, "host-call-1", "Write")
- if err != nil || begin.Receipt.State != OperationExecuting || begin.Receipt.Attempt != 1 || begin.LeaseToken == "" {
- t.Fatalf("unexpected begin: %+v %v", begin, err)
- }
- if _, err := BeginOperation(repo, receipt.OperationID, "host-call-1", "Write"); !errors.Is(err, ErrOperationInFlight) {
- t.Fatalf("identical active operation relaunched: %v", err)
- }
- if _, err := CompleteOperation(repo, receipt.OperationID, "wrong", "SUCCEEDED", "", ""); err == nil {
- t.Fatal("invalid lease completed the operation")
- }
- completed, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "postcondition observed", "sha256:package-a")
- if err != nil || completed.State != OperationSucceeded || completed.Lease != nil {
- t.Fatalf("unexpected completion: %+v %v", completed, err)
- }
- resumed, err := BeginOperation(repo, receipt.OperationID, "late-notification", "Write")
- if err != nil || resumed.Receipt.State != OperationSucceeded || resumed.LeaseToken != "" {
- t.Fatalf("terminal identity did not suppress a late duplicate: %+v %v", resumed, err)
- }
-}
-
-func TestTerminalPostconditionReconciliationIsUpdateLocalOnly(t *testing.T) {
- repo := operationTestRepo(t)
- receipt := preparedOperation(t, repo, "generic-package", "ATOMIC_LOCAL", 2)
- begin, err := BeginOperation(repo, receipt.OperationID, "generic-attempt", "Write")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "observed", "artifact"); err != nil {
- t.Fatal(err)
- }
- if _, err := reconcileSucceededInstallUpdate(repo, receipt.OperationID, "missing", "artifact"); err == nil || !strings.Contains(err.Error(), "does not support") {
- t.Fatalf("generic terminal operation was reopened: %v", err)
- }
-}
-
-func TestOperationRejectsChangedPackageAndAuthorization(t *testing.T) {
- repo := operationTestRepo(t)
- receipt := preparedOperation(t, repo, "package-auth", "ATOMIC_LOCAL", 2)
- if _, err := PrepareOperation(OperationPrepareOptions{
- Repo: repo, Kind: "test-write", Target: "artifact.json", PackageFingerprint: "package-auth",
- AuthorizationFingerprint: "different-approval", RetryClass: "ATOMIC_LOCAL", MaxAttempts: 2,
- ExpectedPostcondition: "artifact hash equals package-auth",
- }); err == nil || !strings.Contains(err.Error(), "authorization fingerprint changed") {
- t.Fatalf("changed authorization was not rejected: %v", err)
- }
- if _, err := AuthorizeOperation(repo, receipt.OperationID, "different-package", "approved-package-auth"); err == nil {
- t.Fatal("changed package fingerprint was not rejected")
- }
-}
-
-func TestUnknownCompletionRequiresReconciliationBeforeRetry(t *testing.T) {
- repo := operationTestRepo(t)
- receipt := preparedOperation(t, repo, "package-b", "RECONCILE_FIRST", 3)
- begin, err := BeginOperation(repo, receipt.OperationID, "call-b", "mcp__github__create_pull_request")
- if err != nil {
- t.Fatal(err)
- }
- unknown, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "UNKNOWN", "transport ended before a response", "")
- if err != nil || unknown.State != OperationReconcileRequired {
- t.Fatalf("unknown completion did not require reconciliation: %+v %v", unknown, err)
- }
- if _, err := BeginOperation(repo, receipt.OperationID, "call-b-retry", "mcp__github__create_pull_request"); err == nil {
- t.Fatal("reconcile-first operation retried blindly")
- }
- retryable, err := RecordOperationReconciliation(repo, receipt.OperationID, "OBSERVED_ABSENT", "exact PR was not found", "head:abc")
- if err != nil || retryable.State != OperationRetryable {
- t.Fatalf("absence did not permit bounded retry: %+v %v", retryable, err)
- }
- second, err := BeginOperation(repo, receipt.OperationID, "call-b-retry", "mcp__github__create_pull_request")
- if err != nil || second.Receipt.Attempt != 2 {
- t.Fatalf("reconciled operation did not retry: %+v %v", second, err)
- }
-}
-
-func TestExpiredLeaseBecomesUnknownAndBudgetPersists(t *testing.T) {
- repo := operationTestRepo(t)
- now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
- previous := operationNow
- operationNow = func() time.Time { return now }
- t.Cleanup(func() { operationNow = previous })
- receipt := preparedOperation(t, repo, "package-c", "ATOMIC_LOCAL", 1)
- if _, err := BeginOperation(repo, receipt.OperationID, "call-c", "Write"); err != nil {
- t.Fatal(err)
- }
- now = now.Add(operationLeaseDuration + time.Second)
- if _, err := BeginOperation(repo, receipt.OperationID, "call-c", "Write"); err == nil {
- t.Fatal("expired attempt did not block for reconciliation")
- }
- status, err := ResolveOperationStatus(repo, receipt.OperationID)
- if err != nil || status.Operation == nil || status.Operation.State != OperationReconcileRequired || !status.ReconciliationRequired {
- t.Fatalf("unexpected expired status: %+v %v", status, err)
- }
- terminal, err := RecordOperationReconciliation(repo, receipt.OperationID, "OBSERVED_ABSENT", "destination hash unchanged", "")
- if err != nil || terminal.State != OperationFailedFinal {
- t.Fatalf("persisted budget was not exhausted: %+v %v", terminal, err)
- }
-}
-
-// TestOperationLedgerIsolatedPerWorktree encodes the deliberate contract reversal
-// from the pre-isolation design: the operation ledger is now per-worktree, so one
-// worktree neither observes nor blocks another worktree's operations. Two worktrees
-// running the same operation identity each hold their own receipt and their own lock.
-func TestOperationLedgerIsolatedPerWorktree(t *testing.T) {
- repo := operationTestRepo(t)
- linked := filepath.Join(t.TempDir(), "linked")
- runGit(t, repo, "worktree", "add", "-b", "linked-test", linked)
-
- // An operation prepared in the main worktree is invisible in a linked worktree:
- // the linked worktree cannot resolve it from its own separate ledger.
- receipt := preparedOperation(t, repo, "package-d", "ATOMIC_LOCAL", 2)
- if _, err := ResolveOperationStatus(linked, receipt.OperationID); err == nil {
- t.Fatal("linked worktree observed another worktree's operation ledger")
- }
-
- // The same identity begins independently in each worktree — no cross-worktree
- // in-flight serialization and no identity collision.
- if _, err := BeginOperation(repo, receipt.OperationID, "main-attempt", "Write"); err != nil {
- t.Fatalf("main worktree could not begin its own operation: %v", err)
- }
- linkedReceipt := preparedOperation(t, linked, "package-d", "ATOMIC_LOCAL", 2)
- if linkedReceipt.OperationID != receipt.OperationID {
- t.Fatalf("same identity produced different operation ids: %s vs %s", linkedReceipt.OperationID, receipt.OperationID)
- }
- if _, err := BeginOperation(linked, linkedReceipt.OperationID, "linked-attempt", "Write"); err != nil {
- t.Fatalf("linked worktree blocked by another worktree's in-flight operation: %v", err)
- }
-}
-
-func TestWindowsAccessDeniedLockRaceIsContentionAfterEntryDisappears(t *testing.T) {
- lock := filepath.Join(t.TempDir(), "removed.lock")
- openErr := &os.PathError{Op: "open", Path: lock, Err: os.ErrPermission}
-
- if !isLockContentionForOS(openErr, lock, "windows") {
- t.Fatal("Windows access-denied race was not classified as bounded contention")
- }
- if isLockContentionForOS(openErr, lock, "linux") {
- t.Fatal("missing Unix lock path was misclassified as contention")
- }
-}
-
-// TestSameVersionUpdateFromTwoWorktreesDoesNotCollide reproduces the reported
-// incident: a same-version install-update prepared from a second worktree used to
-// fail with "existing operation identity does not match the prepared package"
-// because the clone-shared ledger compared a differing scope. Per-worktree ledgers
-// make each prepare independent.
-func TestSameVersionUpdateFromTwoWorktreesDoesNotCollide(t *testing.T) {
- repo := operationTestRepo(t)
- linked := filepath.Join(t.TempDir(), "linked")
- runGit(t, repo, "worktree", "add", "-b", "update-b", linked)
-
- options := func(root, scopeWorktree, branch string) OperationPrepareOptions {
- return OperationPrepareOptions{
- Repo: root,
- Kind: "install-update",
- Target: "boatstack-install:v9.9.9",
- PackageFingerprint: "fp-999",
- AuthorizationFingerprint: "auth-999",
- RetryClass: "ATOMIC_LOCAL",
- MaxAttempts: 2,
- ExpectedPostcondition: "generated runtime matches the pinned release",
- Scope: OperationScope{Worktree: scopeWorktree, HeadBranch: branch},
- }
- }
-
- a, err := PrepareOperation(options(repo, "update-a", "chore/update-boatstack-v9.9.9"))
- if err != nil {
- t.Fatalf("worktree A prepare failed: %v", err)
- }
- b, err := PrepareOperation(options(linked, "update-b", "chore/update-boatstack-v9.9.9"))
- if err != nil {
- t.Fatalf("worktree B blocked by worktree A's operation: %v", err)
- }
- if a.OperationID != b.OperationID {
- t.Fatalf("expected identical operation ids across worktrees, got %s vs %s", a.OperationID, b.OperationID)
- }
-}
-
-func TestOperationStatusDoesNotChooseAmbiguousWorkByRecency(t *testing.T) {
- repo := operationTestRepo(t)
- preparedOperation(t, repo, "one", "ATOMIC_LOCAL", 1)
- preparedOperation(t, repo, "two", "ATOMIC_LOCAL", 1)
- status, err := ResolveOperationStatus(repo, "")
- if err != nil || status.VerificationStatus != "AMBIGUOUS" || status.NextOperation != "specify_operation" {
- t.Fatalf("unexpected ambiguity result: %+v %v", status, err)
- }
-}
diff --git a/boatstack/paths.go b/boatstack/paths.go
deleted file mode 100644
index 675ed06..0000000
--- a/boatstack/paths.go
+++ /dev/null
@@ -1,433 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "path/filepath"
- "sort"
- "strings"
- "sync"
-)
-
-// SupervisionMode selects where Boatstack keeps its controller-owned state.
-type SupervisionMode string
-
-const (
- // SupervisionEmbedded keeps controller state inside the target repository
- // (.product-loop/**, host adapter dirs) and its Git directory — the original,
- // repository-owned layout.
- SupervisionEmbedded SupervisionMode = "embedded"
- // SupervisionDetached keeps controller state under an external, developer-local
- // control root, leaving the target repository free of Boatstack-owned files.
- // Detached resolution is wired in a later stage; the type is mode-aware now so
- // callers route every controller path through this one seam.
- SupervisionDetached SupervisionMode = "detached"
-)
-
-const (
- // productLoopDirName is the in-repo embedded controller directory.
- productLoopDirName = ".product-loop"
- // sourceConfigName is the editable root configuration file (embedded mode).
- sourceConfigName = ".boatstack-project.json"
- // controlDirName is the Boatstack subtree inside a Git directory (embedded) or
- // external control root (detached) that holds mutable controller state.
- controlDirName = "boatstack"
-)
-
-// WorkspaceContext is the single resolver for every Boatstack-owned path. Callers
-// obtain controller locations through its methods instead of joining onto the
-// repository root directly, so the embedded and detached layouts differ in exactly
-// one place. The plant — product files, commits, branches, PRs — is always
-// addressed through RepoRoot; everything controller-owned flows through the roots
-// below.
-//
-// Path classes:
-// - embedded controller (generated, model-visible): GeneratedRoot / *ConfigPath
-// - per-worktree mutable controller: DeliveryDir / OperationDir / FlowDir
-// - shared immutable runtime: RuntimeDir
-// - host activation: HostActivationRoot
-type WorkspaceContext struct {
- Mode SupervisionMode
- RepoRoot string // git worktree top-level (the plant)
- RepoID string // stable repository identity (detached binding key; empty in embedded)
- WorktreeID string // stable per-worktree identity (detached; empty in embedded)
-
- // controlRoot is the base for generated/config state: RepoRoot in embedded
- // mode, the external repositories/ root in detached mode.
- controlRoot string
- // worktreeControlRoot is the base for per-worktree mutable state. In embedded
- // mode it is left empty and derived lazily from the Git worktree directory; in
- // detached mode it is the external per-worktree directory.
- worktreeControlRoot string
- // sharedControlRoot is the base for shared immutable runtime state. In embedded
- // mode it is left empty and derived lazily from the Git common directory; in
- // detached mode it is the external shared root.
- sharedControlRoot string
-}
-
-// controllerPath carries a Boatstack-owned path together with the boundary that
-// owns it. Effectful callers validate this value instead of independently
-// choosing a repository, Git, or detached-state root.
-type controllerPath struct {
- path string
- root string
-}
-
-func newControllerPath(root, target string) (controllerPath, error) {
- if root == "" || target == "" {
- return controllerPath{}, fmt.Errorf("controller path ownership is incomplete")
- }
- owned := controllerPath{path: filepath.Clean(target), root: filepath.Clean(root)}
- if err := owned.Validate(); err != nil {
- return controllerPath{}, err
- }
- return owned, nil
-}
-
-func (p controllerPath) Validate() error {
- return rejectSymlinkComponents(p.root, p.path)
-}
-
-// Sibling derives another target without losing the owning boundary.
-func (p controllerPath) Sibling(name string) (controllerPath, error) {
- if filepath.Base(name) != name || name == "." || name == ".." {
- return controllerPath{}, fmt.Errorf("invalid controller path name: %s", name)
- }
- return newControllerPath(p.root, filepath.Join(filepath.Dir(p.path), name))
-}
-
-func (w WorkspaceContext) worktreeOwnedPath(target string) (controllerPath, error) {
- if w.Mode == SupervisionDetached {
- return newControllerPath(w.sharedControlRoot, target)
- }
- root, err := worktreeGitDir(w.RepoRoot)
- if err != nil {
- return controllerPath{}, err
- }
- return newControllerPath(root, target)
-}
-
-func (w WorkspaceContext) sharedOwnedPath(target string) (controllerPath, error) {
- if w.Mode == SupervisionDetached {
- return newControllerPath(w.sharedControlRoot, target)
- }
- root, err := gitCommonDir(w.RepoRoot)
- if err != nil {
- return controllerPath{}, err
- }
- return newControllerPath(root, target)
-}
-
-// WorkspaceFor returns the resolver for a repository. It consults the external
-// attachment registry and returns a detached context whenever the repository is
-// attached. Verification failures do not redirect paths into the repository:
-// strict operational entry points deny through ResolveWorkspaceContext, while
-// best-effort path projection remains external. Results are cached per input
-// path; attach/detach invalidate the cache.
-func WorkspaceFor(repo string) WorkspaceContext {
- workspaceCacheMu.Lock()
- if cached, ok := workspaceCache[repo]; ok {
- workspaceCacheMu.Unlock()
- return cached
- }
- workspaceCacheMu.Unlock()
-
- resolved := embeddedWorkspace(repo)
- if ctx, ok, _ := detachedContextFor(repo); ok {
- resolved = ctx
- }
-
- workspaceCacheMu.Lock()
- workspaceCache[repo] = resolved
- workspaceCacheMu.Unlock()
- return resolved
-}
-
-// ResolveWorkspaceContext is the strict resolver used at entry points that must
-// fail closed: it returns an error for an attached-but-unverifiable repository
-// (bad or missing binding, identity mismatch) instead of silently falling back to
-// the embedded layout.
-func ResolveWorkspaceContext(repo string) (WorkspaceContext, error) {
- ctx, ok, err := detachedContextFor(repo)
- if err != nil {
- return WorkspaceContext{}, err
- }
- if ok {
- return ctx, nil
- }
- return embeddedWorkspace(repo), nil
-}
-
-func embeddedWorkspace(repo string) WorkspaceContext {
- return WorkspaceContext{Mode: SupervisionEmbedded, RepoRoot: repo, controlRoot: repo}
-}
-
-func pathWithin(root, target string) bool {
- root = canonicalizeExistingAncestor(root)
- target = canonicalizeExistingAncestor(target)
- relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(target))
- return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
-}
-
-// ResolveControllerRepository maps either a product path or a detached
-// controller path back to the repository whose identity owns it. This is the
-// inverse boundary required by plan validation after FeatureDir moves outside
-// the Git worktree.
-func ResolveControllerRepository(path string) (string, error) {
- stateRoot, err := detachedStateRoot()
- if err != nil {
- return "", err
- }
- registry, err := loadRegistry(stateRoot)
- if err != nil {
- return "", err
- }
- var matches []string
- for repo := range registry.Repositories {
- ctx, ok, _ := detachedContextFor(repo)
- if !ok {
- continue
- }
- if pathWithin(ctx.ExportRoot(), path) {
- matches = append(matches, repo)
- }
- }
- sort.Strings(matches)
- if len(matches) == 1 {
- return matches[0], nil
- }
- if len(matches) > 1 {
- return "", fmt.Errorf("controller path has multiple verified repository aliases; supply the invoking repository explicitly: %s (%s)", path, strings.Join(matches, ", "))
- }
- if repo, err := ResolveRepository(path); err == nil {
- return repo, nil
- }
- return "", fmt.Errorf("path is not owned by a repository or verified detached controller: %s", path)
-}
-
-// ResolveControllerRepositoryFor validates a controller path against an
-// explicit invoking repository. Detached controller roots are intentionally
-// shared by aliases of one Git repository, so effectful operations must carry
-// the caller's worktree identity forward instead of reconstructing it from the
-// non-injective controller path.
-func ResolveControllerRepositoryFor(repoPath, path string) (string, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return "", err
- }
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return "", err
- }
- if !pathWithin(ctx.ExportRoot(), path) {
- return "", fmt.Errorf("controller path is not owned by the invoking repository %s: %s", repo, path)
- }
- return repo, nil
-}
-
-var (
- workspaceCacheMu sync.Mutex
- workspaceCache = map[string]WorkspaceContext{}
-)
-
-// invalidateWorkspaceCache clears the WorkspaceFor cache. Call it after any change
-// to the attachment registry or bindings so subsequent resolution reflects it.
-func invalidateWorkspaceCache() {
- workspaceCacheMu.Lock()
- workspaceCache = map[string]WorkspaceContext{}
- workspaceCacheMu.Unlock()
-}
-
-// configBase is the root under which the generated controller bundle (.product-loop
-// and host adapter dirs) lives: the repository in embedded mode, the external
-// control root in detached mode. Detached mirrors the embedded bundle layout so
-// generation and guard resolution are reused unchanged, only relocated.
-func (w WorkspaceContext) configBase() string {
- if w.Mode == SupervisionDetached {
- return w.controlRoot
- }
- return w.RepoRoot
-}
-
-// GeneratedRoot is the root of generated, model-visible controller files
-// (references, templates, project.json). Embedded: /.product-loop.
-func (w WorkspaceContext) GeneratedRoot() string {
- return filepath.Join(w.configBase(), productLoopDirName)
-}
-
-// HelperPath is the generated checkout runtime binary owned by this workspace.
-func (w WorkspaceContext) HelperPath() string {
- return filepath.Join(w.GeneratedRoot(), "bin", helperName())
-}
-
-// LauncherPath is the tracked exact-runtime command boundary in embedded mode.
-// Detached supervision has no repository-owned launcher and continues to use
-// HelperPath from its external controller root.
-func (w WorkspaceContext) LauncherPath(powerShell bool) string {
- name := "boatstack"
- if powerShell {
- name += ".ps1"
- }
- return filepath.Join(w.GeneratedRoot(), name)
-}
-
-// projectLocalLauncherCommand is the portable repository-relative spelling used
-// in rendered embedded-mode prescriptions. Path ownership remains centralized
-// here even though the command is displayed before a concrete workspace exists.
-func projectLocalLauncherCommand() string {
- return filepath.ToSlash(filepath.Join(productLoopDirName, "boatstack"))
-}
-
-// ExportRoot is the base beneath which generated bundle paths are materialized.
-// Bundle keys include .product-loop and host-adapter directories, so callers
-// must pass this root — never RepoRoot — to export write/check operations.
-func (w WorkspaceContext) ExportRoot() string {
- return w.configBase()
-}
-
-// FeatureRoot owns generated planning and delivery artifacts. Source plans are
-// product inputs and remain at their declared repository paths; everything
-// compiled from them lives below this controller-owned root.
-func (w WorkspaceContext) FeatureRoot() string {
- return filepath.Join(w.GeneratedRoot(), "features")
-}
-
-// FeatureDir returns one validated feature package directory. Invalid slugs
-// return an empty path so no caller can accidentally escape the ownership root.
-func (w WorkspaceContext) FeatureDir(feature string) string {
- if !featureSlugPattern.MatchString(feature) {
- return ""
- }
- return filepath.Join(w.FeatureRoot(), feature)
-}
-
-// ProjectConfigPath is the generated runtime configuration copy that runtime
-// operations read. Embedded: /.product-loop/project.json.
-func (w WorkspaceContext) ProjectConfigPath() string {
- return filepath.Join(w.configBase(), productLoopDirName, "project.json")
-}
-
-// SourceConfigPath is the editable, authoritative configuration source. Embedded:
-// /.boatstack-project.json. Detached: the external control root's copy.
-func (w WorkspaceContext) SourceConfigPath() string {
- return filepath.Join(w.configBase(), sourceConfigName)
-}
-
-// HostActivationRoot is the base under which a host adapter's activation files are
-// written. Embedded: the repository root (adapters live in /.claude, ...).
-// Detached: the external control root (host dirs live outside the repository).
-func (w WorkspaceContext) HostActivationRoot() string {
- return w.configBase()
-}
-
-// worktreeControlDir is the base subtree for this worktree's mutable controller
-// state. Embedded: /boatstack; detached: the external per-worktree
-// control directory.
-func (w WorkspaceContext) worktreeControlDir() (string, error) {
- if w.Mode == SupervisionDetached {
- return w.worktreeControlRoot, nil
- }
- gitDir, err := worktreeGitDir(w.RepoRoot)
- if err != nil {
- return "", err
- }
- return filepath.Join(gitDir, controlDirName), nil
-}
-
-// sharedControlDir is the base subtree for shared immutable runtime state.
-// Embedded: /boatstack; detached: the external shared root.
-func (w WorkspaceContext) sharedControlDir() (string, error) {
- if w.Mode == SupervisionDetached {
- return w.sharedControlRoot, nil
- }
- common, err := gitCommonDir(w.RepoRoot)
- if err != nil {
- return "", err
- }
- return filepath.Join(common, controlDirName), nil
-}
-
-// DeliveryDir holds per-worktree delivery state.
-func (w WorkspaceContext) DeliveryDir() (string, error) {
- base, err := w.worktreeControlDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "deliveries"), nil
-}
-
-// OperationDir holds the per-worktree operation ledger. The "v2" segment orphans
-// the pre-isolation clone-shared "v1" ledger (see operation.go).
-func (w WorkspaceContext) OperationDir() (string, error) {
- base, err := w.worktreeControlDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "operations", "v2"), nil
-}
-
-// FlowDir holds the per-worktree append-only flow trajectory log.
-func (w WorkspaceContext) FlowDir() (string, error) {
- base, err := w.worktreeControlDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "flow"), nil
-}
-
-// InsightDir is the tracked repository inbox for independent insight captures.
-// Unlike controller state, insight content is a plant artifact: every capture
-// and event must be visible as a reviewable Git diff and must never be routed to
-// the Git directory or detached control root.
-func (w WorkspaceContext) InsightDir() (string, error) {
- return filepath.Join(w.RepoRoot, "docs", "insights"), nil
-}
-
-// GuardDir holds the per-worktree guard bookkeeping (the denial ledger). It is
-// worktree-partitioned like the delivery state: one worktree's denial history
-// must never escalate a sibling's denials.
-// control-law: repeated-denials-escalate-to-solutions
-func (w WorkspaceContext) GuardDir() (string, error) {
- base, err := w.worktreeControlDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "guard"), nil
-}
-
-// RuntimeDir holds the shared, version-namespaced runtime binary for the current
-// platform. version and sourceCommit are validated as single safe path segments.
-func (w WorkspaceContext) RuntimeDir(version, sourceCommit string) (string, error) {
- version, err := safeCacheSegment(version, "Boatstack version")
- if err != nil {
- return "", err
- }
- sourceCommit, err = safeCacheSegment(sourceCommit, "source commit")
- if err != nil {
- return "", err
- }
- base, err := w.sharedControlDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "runtimes", version, sourceCommit, platformKey()), nil
-}
-
-// BootstrapRuntimeDir holds the exact runtime used by tracked launchers and
-// repository guards before supervision mode can be resolved by trusted Go code.
-// It is always Git-common, including for detached supervision. The bootstrap
-// helper then activates the mode-aware shared runtime through HydrateWorktree.
-func (w WorkspaceContext) BootstrapRuntimeDir(version, sourceCommit string) (string, error) {
- version, err := safeCacheSegment(version, "Boatstack version")
- if err != nil {
- return "", err
- }
- sourceCommit, err = safeCacheSegment(sourceCommit, "source commit")
- if err != nil {
- return "", err
- }
- common, err := gitCommonDir(w.RepoRoot)
- if err != nil {
- return "", err
- }
- return filepath.Join(common, controlDirName, "runtimes", version, sourceCommit, platformKey()), nil
-}
diff --git a/boatstack/plan.go b/boatstack/plan.go
deleted file mode 100644
index 2f06cd4..0000000
--- a/boatstack/plan.go
+++ /dev/null
@@ -1,1595 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-const (
- planMarkerStart = ""
- planMarkerEnd = ""
- approvalMarkerStart = ""
- approvalMarkerEnd = ""
-)
-
-func stringValue(value any) string {
- result, _ := value.(string)
- return result
-}
-
-func stringSlice(value any) ([]string, bool) {
- items, ok := value.([]any)
- if !ok {
- return nil, false
- }
- result := make([]string, 0, len(items))
- for _, item := range items {
- text, ok := item.(string)
- if !ok {
- return nil, false
- }
- result = append(result, text)
- }
- return result, true
-}
-
-func objectSlice(value any) ([]map[string]any, bool) {
- items, ok := value.([]any)
- if !ok {
- return nil, false
- }
- result := make([]map[string]any, 0, len(items))
- for _, item := range items {
- object, ok := item.(map[string]any)
- if !ok {
- return nil, false
- }
- result = append(result, object)
- }
- return result, true
-}
-
-func validationSlice(value any) ([]map[string]any, bool) {
- items, ok := value.([]any)
- if !ok || len(items) == 0 {
- return nil, false
- }
- result := make([]map[string]any, 0, len(items))
- for _, item := range items {
- validation, ok := item.(map[string]any)
- if !ok {
- return nil, false
- }
- for _, field := range []string{"run", "origin", "oracle", "independence"} {
- if strings.TrimSpace(stringValue(validation[field])) == "" {
- return nil, false
- }
- }
- criteria, criteriaOK := stringSlice(validation["criteria"])
- if !criteriaOK || len(criteria) == 0 {
- return nil, false
- }
- result = append(result, validation)
- }
- return result, true
-}
-
-func validateJourneyEvidence(plan map[string]any, version float64) error {
- if version < 3 {
- return nil
- }
- decision, ok := plan["journey_evidence"].(map[string]any)
- if !ok {
- return fmt.Errorf("schema-v3 plan requires a journey_evidence decision")
- }
- relevance := strings.ToLower(strings.TrimSpace(stringValue(decision["relevance"])))
- switch relevance {
- case "not_relevant":
- if strings.TrimSpace(stringValue(decision["reason"])) == "" {
- return fmt.Errorf("journey_evidence not_relevant requires a reason")
- }
- if oracles, ok := objectSlice(decision["oracles"]); ok && len(oracles) > 0 {
- return fmt.Errorf("journey_evidence not_relevant must not declare oracles")
- }
- return nil
- case "relevant":
- default:
- return fmt.Errorf("journey_evidence.relevance must be relevant or not_relevant")
- }
- oracles, ok := objectSlice(decision["oracles"])
- if !ok || len(oracles) == 0 {
- return fmt.Errorf("relevant journey_evidence requires at least one typed oracle")
- }
- criteria, _ := objectSlice(plan["acceptance_criteria"])
- criterionIDs := map[string]bool{}
- for _, criterion := range criteria {
- criterionIDs[stringValue(criterion["id"])] = true
- }
- seen := map[string]bool{}
- for _, oracle := range oracles {
- id := strings.TrimSpace(stringValue(oracle["id"]))
- mapped, mappedOK := stringSlice(oracle["criteria"])
- steps, stepsOK := stringSlice(oracle["steps"])
- expected, expectedOK := stringSlice(oracle["expected"])
- if id == "" || seen[id] {
- return fmt.Errorf("journey oracle ids must be present and unique")
- }
- seen[id] = true
- if !mappedOK || len(mapped) == 0 || !stepsOK || len(steps) == 0 || !expectedOK || len(expected) == 0 {
- return fmt.Errorf("journey oracle %s requires criteria, steps, and expected lists", id)
- }
- for _, values := range [][]string{steps, expected} {
- for _, value := range values {
- if strings.TrimSpace(value) == "" {
- return fmt.Errorf("journey oracle %s steps and expected values must be non-empty", id)
- }
- }
- }
- for _, criterion := range mapped {
- if !criterionIDs[criterion] {
- return fmt.Errorf("journey oracle %s maps unknown criterion: %s", id, criterion)
- }
- }
- for _, field := range []string{"type", "entry_point", "run", "oracle", "independence"} {
- if strings.TrimSpace(stringValue(oracle[field])) == "" {
- return fmt.Errorf("journey oracle %s requires %s", id, field)
- }
- }
- }
- return nil
-}
-
-func fencedJSONBlocks(value string) ([]string, error) {
- lines := strings.Split(value, "\n")
- blocks := []string{}
- inJSON := false
- current := []string{}
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if !inJSON {
- if trimmed == "```json" {
- inJSON = true
- current = nil
- }
- continue
- }
- if trimmed == "```" {
- blocks = append(blocks, strings.Join(current, "\n"))
- inJSON = false
- current = nil
- continue
- }
- current = append(current, line)
- }
- if inJSON {
- return nil, fmt.Errorf("unterminated json fence")
- }
- return blocks, nil
-}
-
-func markedJSON(value, label, startMarker, endMarker string, allowLegacy bool) ([]byte, error) {
- startCount := strings.Count(value, startMarker)
- endCount := strings.Count(value, endMarker)
- if startCount == 0 && endCount == 0 {
- if !allowLegacy {
- return nil, fmt.Errorf("%s is missing %s markers", label, label)
- }
- blocks, err := fencedJSONBlocks(value)
- if err != nil {
- return nil, fmt.Errorf("invalid %s: %w", label, err)
- }
- if len(blocks) != 1 {
- return nil, fmt.Errorf("%s requires exactly one json fence; found %d", label, len(blocks))
- }
- return []byte(blocks[0]), nil
- }
- if startCount != 1 || endCount != 1 {
- return nil, fmt.Errorf("%s requires exactly one marker pair", label)
- }
- start := strings.Index(value, startMarker) + len(startMarker)
- end := strings.Index(value, endMarker)
- if end <= start {
- return nil, fmt.Errorf("%s markers are out of order", label)
- }
- blocks, err := fencedJSONBlocks(value[start:end])
- if err != nil {
- return nil, fmt.Errorf("invalid %s: %w", label, err)
- }
- if len(blocks) != 1 {
- return nil, fmt.Errorf("marked %s requires exactly one json fence; found %d", label, len(blocks))
- }
- return []byte(blocks[0]), nil
-}
-
-func loadJSONObject(path, label, startMarker, endMarker string, allowLegacyMarkdown bool) (map[string]any, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- payload := value
- if strings.EqualFold(filepath.Ext(path), ".md") {
- payload, err = markedJSON(string(value), label, startMarker, endMarker, allowLegacyMarkdown)
- if err != nil {
- return nil, err
- }
- }
- var plan map[string]any
- if err := DecodeJSON("load "+label, path, payload, &plan); err != nil {
- return nil, fmt.Errorf("invalid %s json: %w", label, err)
- }
- return plan, nil
-}
-
-func LoadPlan(path string) (map[string]any, error) {
- if !strings.EqualFold(filepath.Ext(path), ".md") {
- return nil, fmt.Errorf("structured plan must be a Markdown file: %s", path)
- }
- return loadJSONObject(path, "structured plan", planMarkerStart, planMarkerEnd, true)
-}
-
-func CheckSourcePlan(path string) error {
- if strings.TrimSpace(path) == "" {
- return fmt.Errorf("source plan path is required; start in the host Plan mode and save its plan before running auto-plan")
- }
- info, err := os.Stat(path)
- if err != nil || !info.Mode().IsRegular() {
- return fmt.Errorf("source plan does not exist as a regular file: %s", path)
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return fmt.Errorf("source plan is unreadable: %w", err)
- }
- if strings.TrimSpace(string(value)) == "" {
- return fmt.Errorf("source plan is empty: %s", path)
- }
- return nil
-}
-
-// DiscoverSourcePlan resolves the source plan from an explicit path supplied by
-// the caller (the host coding agent). Boatstack never scans directories for
-// ambient plan files: the plan produced in the host conversation must be passed
-// explicitly via --plan so no unshipped saved plan becomes blocking context.
-//
-// The resolved path is recorded as source_plan_path and hashed into the plan
-// fingerprint, then re-validated for drift through build. A plan file outside
-// the repository cannot satisfy that invariant: its absolute path does not
-// travel with clones or linked worktrees and it is never committed alongside the
-// feature, so build activation later fails on a missing file or hash drift. We
-// reject it up front and require an in-repo, durable path instead of surfacing
-// the failure downstream at build time.
-func DiscoverSourcePlan(repo, explicit string) (string, error) {
- repoAbsolute, err := filepath.Abs(repo)
- if err != nil {
- return "", err
- }
- if strings.TrimSpace(explicit) == "" {
- return "", fmt.Errorf("no source plan provided; pass --plan to the plan produced in the host conversation")
- }
- candidate := explicit
- if !filepath.IsAbs(candidate) {
- candidate = filepath.Join(repoAbsolute, candidate)
- }
- candidate = filepath.Clean(candidate)
- if err := CheckSourcePlan(candidate); err != nil {
- return "", err
- }
- relative, err := filepath.Rel(repoAbsolute, candidate)
- if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
- return "", fmt.Errorf("source plan %s is outside the repository; copy the plan into the repo and pass a durable in-repo path to --plan so it stays present and hash-current through build", explicit)
- }
- return filepath.ToSlash(relative), nil
-}
-
-func sourcePlanForStructuredPlan(planPath, repo string) (string, error) {
- plan, err := LoadPlan(planPath)
- if err != nil {
- return "", err
- }
- sourcePlan := stringValue(plan["source_plan_path"])
- if strings.TrimSpace(sourcePlan) == "" {
- return "", fmt.Errorf("source_plan_path is required")
- }
- if !filepath.IsAbs(sourcePlan) {
- planRelative := filepath.Clean(filepath.Join(filepath.Dir(planPath), sourcePlan))
- if fileExists(planRelative) {
- return planRelative, nil
- }
- if repo == "" {
- repo, _ = ResolveControllerRepository(filepath.Dir(planPath))
- }
- if repo != "" {
- repoRelative := filepath.Clean(filepath.Join(repo, sourcePlan))
- if fileExists(repoRelative) {
- return repoRelative, nil
- }
- // Packages imported from the embedded layout retain their original
- // relative source-plan reference. Resolve it against the virtual
- // embedded feature directory without rewriting fingerprinted bytes.
- feature := stringValue(plan["feature_id"])
- legacyRelative := filepath.Clean(filepath.Join(repo, productLoopDirName, "features", feature, sourcePlan))
- if fileExists(legacyRelative) {
- return legacyRelative, nil
- }
- }
- sourcePlan = planRelative
- }
- return filepath.Clean(sourcePlan), nil
-}
-
-func SourcePlanForStructuredPlan(planPath string) (string, error) {
- return sourcePlanForStructuredPlan(planPath, "")
-}
-
-func SpecForStructuredPlan(planPath string) (string, error) {
- plan, err := LoadPlan(planPath)
- if err != nil {
- return "", err
- }
- spec := stringValue(plan["spec_path"])
- if strings.TrimSpace(spec) == "" {
- return "", fmt.Errorf("spec_path is required")
- }
- if !filepath.IsAbs(spec) {
- spec = filepath.Join(filepath.Dir(planPath), spec)
- }
- return filepath.Clean(spec), nil
-}
-
-func checkNonEmptyFile(path, label string) error {
- info, err := os.Stat(path)
- if err != nil || !info.Mode().IsRegular() {
- return fmt.Errorf("%s does not exist as a regular file: %s", label, path)
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return fmt.Errorf("%s is unreadable: %w", label, err)
- }
- if strings.TrimSpace(string(value)) == "" {
- return fmt.Errorf("%s is empty: %s", label, path)
- }
- return nil
-}
-
-type PlanCheck struct {
- Plan map[string]any
- PlanPath string
- SourcePlanPath string
- SpecPath string
- PlanHash string
- SourcePlanHash string
- SpecHash string
- Fingerprint string
-}
-
-func checkPlanForRepository(repoRoot, planPath string) (PlanCheck, error) {
- plan, err := LoadPlan(planPath)
- if err != nil {
- return PlanCheck{}, err
- }
- opts := &ValidatePlanOptions{
- PlanPath: planPath,
- RepoRoot: repoRoot,
- }
- if err := ValidatePlan(plan, opts); err != nil {
- return PlanCheck{}, err
- }
- sourcePlan, err := sourcePlanForStructuredPlan(planPath, repoRoot)
- if err != nil {
- return PlanCheck{}, err
- }
- if err := CheckSourcePlan(sourcePlan); err != nil {
- return PlanCheck{}, err
- }
- spec, err := SpecForStructuredPlan(planPath)
- if err != nil {
- return PlanCheck{}, err
- }
- if err := checkNonEmptyFile(spec, "feature spec"); err != nil {
- return PlanCheck{}, err
- }
- planHash, err := SHA256File(planPath)
- if err != nil {
- return PlanCheck{}, err
- }
- sourcePlanHash, err := SHA256File(sourcePlan)
- if err != nil {
- return PlanCheck{}, err
- }
- specHash, err := SHA256File(spec)
- if err != nil {
- return PlanCheck{}, err
- }
- fingerprintInput, err := MarshalJSON(map[string]any{
- "schema_version": 1,
- "plan_path": filepath.Base(planPath),
- "plan_sha256": planHash,
- "source_plan_path": filepath.ToSlash(filepath.Clean(stringValue(plan["source_plan_path"]))),
- "source_plan_sha256": sourcePlanHash,
- "spec_path": filepath.ToSlash(filepath.Clean(stringValue(plan["spec_path"]))),
- "spec_sha256": specHash,
- })
- if err != nil {
- return PlanCheck{}, err
- }
- return PlanCheck{
- Plan: plan, PlanPath: filepath.Clean(planPath), SourcePlanPath: sourcePlan, SpecPath: spec,
- PlanHash: planHash, SourcePlanHash: sourcePlanHash, SpecHash: specHash,
- Fingerprint: SHA256Bytes(fingerprintInput),
- }, nil
-}
-
-func CheckPlan(planPath string) (PlanCheck, error) {
- repoRoot, err := ResolveControllerRepository(filepath.Dir(planPath))
- if err != nil && strings.Contains(err.Error(), "multiple verified repository aliases") {
- return PlanCheck{}, err
- }
- // Standalone Markdown validation remains path-only. Repository-dependent
- // callers use CheckPlanForRepository; only the non-injective detached inverse
- // is an error at this compatibility projection.
- return checkPlanForRepository(repoRoot, planPath)
-}
-
-// CheckPlanForRepository validates a plan while preserving the caller's
-// explicit worktree identity across a shared detached controller root.
-func CheckPlanForRepository(repoPath, planPath string) (PlanCheck, error) {
- repoRoot, err := ResolveControllerRepositoryFor(repoPath, filepath.Dir(planPath))
- if err != nil {
- return PlanCheck{}, err
- }
- return checkPlanForRepository(repoRoot, planPath)
-}
-
-func checkApprovalSourcePlan(options ApprovalOptions) error {
- repo := ""
- var err error
- if strings.TrimSpace(options.Repo) != "" {
- repo, err = ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath))
- if err != nil {
- return err
- }
- }
- expected, err := sourcePlanForStructuredPlan(options.PlanPath, repo)
- if err != nil {
- return err
- }
- expectedAbsolute, err := filepath.Abs(expected)
- if err != nil {
- return err
- }
- suppliedAbsolute, err := filepath.Abs(options.SourcePlanPath)
- if err != nil {
- return err
- }
- if filepath.Clean(expectedAbsolute) != filepath.Clean(suppliedAbsolute) {
- return fmt.Errorf("source-plan does not match structured plan source_plan_path: expected %s", expected)
- }
- return CheckSourcePlan(expected)
-}
-
-func ValidatePlan(plan map[string]any, opts *ValidatePlanOptions) error {
- version, ok := plan["schema_version"].(float64)
- if !ok || (version != float64(1) && version != float64(2) && version != float64(3)) {
- return fmt.Errorf("schema_version must be 1, 2, or 3")
- }
-
- if version >= float64(2) {
- if err := validateArchitectureGrounding(plan, opts); err != nil {
- return err
- }
- }
- if err := validateJourneyEvidence(plan, version); err != nil {
- return err
- }
-
- if err := validateSystemicBoundaries(plan); err != nil {
- return err
- }
- if err := validatePRVisualEvidence(plan); err != nil {
- return err
- }
- if err := validatePlanAutonomy(plan, opts); err != nil {
- return err
- }
- if err := requireConfiguredPRVisualEvidenceDecision(plan, opts); err != nil {
- return err
- }
- if err := requireVisualSurfaceEvidence(plan, opts); err != nil {
- return err
- }
-
- if stringValue(plan["feature_id"]) == "" {
- return fmt.Errorf("feature_id is required")
- }
- if stringValue(plan["source_plan_path"]) == "" {
- return fmt.Errorf("source_plan_path is required")
- }
- if questions, present := plan["blocking_questions"]; present {
- values, ok := stringSlice(questions)
- if !ok {
- return fmt.Errorf("blocking_questions must be a list of question ids")
- }
- if len(values) > 0 {
- return fmt.Errorf("unresolved blocking questions: %s", strings.Join(values, ", "))
- }
- }
- criteria, ok := objectSlice(plan["acceptance_criteria"])
- if !ok || len(criteria) == 0 {
- return fmt.Errorf("at least one acceptance criterion is required")
- }
- tasks, ok := objectSlice(plan["tasks"])
- if !ok || len(tasks) == 0 {
- return fmt.Errorf("at least one task is required")
- }
- criterionIDs := map[string]bool{}
- for _, criterion := range criteria {
- id := stringValue(criterion["id"])
- if id == "" || criterionIDs[id] {
- return fmt.Errorf("acceptance criterion ids must be present and unique")
- }
- criterionIDs[id] = true
- }
- taskIDs := map[string]bool{}
- for _, task := range tasks {
- id := stringValue(task["id"])
- if id == "" || taskIDs[id] {
- return fmt.Errorf("task ids must be present and unique")
- }
- taskIDs[id] = true
- }
- covered := map[string]bool{}
- validationCovered := map[string]bool{}
- graph := map[string][]string{}
- for _, task := range tasks {
- id := stringValue(task["id"])
- dependencies, dependenciesOK := stringSlice(task["depends_on"])
- if task["depends_on"] == nil {
- dependencies, dependenciesOK = []string{}, true
- }
- mapped, mappedOK := stringSlice(task["acceptance_criteria"])
- if task["acceptance_criteria"] == nil {
- mapped, mappedOK = []string{}, true
- }
- validations, validationsOK := validationSlice(task["validation"])
- if !dependenciesOK || !mappedOK || !validationsOK {
- return fmt.Errorf("task %s requires list dependencies, criteria, and at least one validation with criteria, run, origin, oracle, and independence", id)
- }
- for _, dependency := range dependencies {
- if dependency == id {
- return fmt.Errorf("task %s cannot depend on itself", id)
- }
- if !taskIDs[dependency] {
- return fmt.Errorf("task %s has unknown dependency: %s", id, dependency)
- }
- }
- for _, criterion := range mapped {
- if !criterionIDs[criterion] {
- return fmt.Errorf("task %s maps unknown criterion: %s", id, criterion)
- }
- covered[criterion] = true
- }
- for _, validation := range validations {
- validationCriteria, _ := stringSlice(validation["criteria"])
- for _, criterion := range validationCriteria {
- if !contains(mapped, criterion) {
- return fmt.Errorf("task %s validation maps criterion %s not served by the task", id, criterion)
- }
- validationCovered[criterion] = true
- }
- }
- if len(mapped) == 0 && stringValue(task["enabling_reason"]) == "" {
- return fmt.Errorf("task %s must map acceptance criteria or state an enabling_reason", id)
- }
- if err := validateTaskSafety(task); err != nil {
- return fmt.Errorf("task %s safety: %w", id, err)
- }
- graph[id] = dependencies
- }
- uncovered := []string{}
- for criterion := range criterionIDs {
- if !covered[criterion] {
- uncovered = append(uncovered, criterion)
- }
- }
- if len(uncovered) > 0 {
- sort.Strings(uncovered)
- return fmt.Errorf("uncovered acceptance criteria: %v", uncovered)
- }
- unvalidated := []string{}
- for criterion := range criterionIDs {
- if !validationCovered[criterion] {
- unvalidated = append(unvalidated, criterion)
- }
- }
- if len(unvalidated) > 0 {
- sort.Strings(unvalidated)
- return fmt.Errorf("acceptance criteria without validation procedures: %v", unvalidated)
- }
- visiting := map[string]bool{}
- visited := map[string]bool{}
- var visit func(string) error
- visit = func(id string) error {
- if visiting[id] {
- return fmt.Errorf("task dependency cycle includes %s", id)
- }
- if visited[id] {
- return nil
- }
- visiting[id] = true
- for _, dependency := range graph[id] {
- if err := visit(dependency); err != nil {
- return err
- }
- }
- delete(visiting, id)
- visited[id] = true
- return nil
- }
- for id := range taskIDs {
- if err := visit(id); err != nil {
- return err
- }
- }
- if _, err := deliveryDefinitions(plan); err != nil {
- return err
- }
- return nil
-}
-
-func taskSafetyText(task map[string]any) string {
- parts := []string{stringValue(task["title"]), stringValue(task["rollback_boundary"])}
- if validations, ok := objectSlice(task["validation"]); ok {
- for _, validation := range validations {
- parts = append(parts, stringValue(validation["run"]), stringValue(validation["origin"]))
- }
- }
- if paths, ok := stringSlice(task["affected_paths"]); ok {
- parts = append(parts, paths...)
- }
- return strings.ToLower(strings.Join(parts, " "))
-}
-
-func taskHasExternalWrite(task map[string]any) bool {
- text := taskSafetyText(task)
- for _, marker := range []string{
- "database", "migration", "migrate", "seed database", "deploy", "supabase", "postgres",
- "terraform", "pulumi", "kubectl", "cloud", "production", "staging", "external write",
- } {
- if strings.Contains(text, marker) {
- return true
- }
- }
- return false
-}
-
-func destructiveRollback(value string) bool {
- text := strings.ToLower(strings.TrimSpace(value))
- if text == "" {
- return false
- }
- for _, safePrefix := range []string{"no reset", "never reset", "do not reset", "operator-only", "operator only"} {
- if strings.Contains(text, safePrefix) {
- return false
- }
- }
- for _, marker := range []string{"reset database", "reset db", "reset local db", "drop schema", "drop database", "truncate", "wipe database", "destroy infrastructure"} {
- if strings.Contains(text, marker) {
- return true
- }
- }
- return false
-}
-
-func validateTaskSafety(task map[string]any) error {
- if destructiveRollback(stringValue(task["rollback_boundary"])) {
- return fmt.Errorf("destructive rollback is not executable authority; use transactional rollback, fix-forward recovery, or an operator-only runbook")
- }
- if !taskHasExternalWrite(task) {
- return nil
- }
- paths, pathsOK := stringSlice(task["affected_paths"])
- if !pathsOK || len(paths) == 0 {
- return fmt.Errorf("external-write tasks require affected_paths")
- }
- effects, effectsOK := objectSlice(task["side_effects"])
- if !effectsOK || len(effects) == 0 {
- return fmt.Errorf("external-write tasks require structured side_effects")
- }
- for index, effect := range effects {
- for _, field := range []string{"kind", "target", "reversibility", "failure_policy"} {
- if strings.TrimSpace(stringValue(effect[field])) == "" {
- return fmt.Errorf("side_effects[%d].%s is required", index, field)
- }
- }
- target := strings.ToLower(strings.TrimSpace(stringValue(effect["target"])))
- for _, ambiguous := range []string{"unknown", "tbd", "database", "staging", "production", "local database"} {
- if target == ambiguous {
- return fmt.Errorf("side_effects[%d].target must use an immutable target identity", index)
- }
- }
- destructive, ok := effect["destructive"].(bool)
- if !ok {
- return fmt.Errorf("side_effects[%d].destructive must be boolean", index)
- }
- if destructive {
- return fmt.Errorf("side_effects[%d] requests a destructive operation; move it to an operator-owned surface", index)
- }
- reversibility := strings.ToLower(stringValue(effect["reversibility"]))
- if reversibility != "transactional" && reversibility != "fix-forward" && reversibility != "reversible" {
- return fmt.Errorf("side_effects[%d].reversibility must be transactional, fix-forward, or reversible", index)
- }
- failurePolicy := strings.ToLower(stringValue(effect["failure_policy"]))
- if failurePolicy != "rollback-transaction" && failurePolicy != "stop-and-fix-forward" {
- return fmt.Errorf("side_effects[%d].failure_policy must be rollback-transaction or stop-and-fix-forward", index)
- }
- }
- return nil
-}
-
-func CompilePlan(plan map[string]any, opts *ValidatePlanOptions) (map[string]any, map[string]any, string, error) {
- if err := ValidatePlan(plan, opts); err != nil {
- return nil, nil, "", err
- }
- criteria, _ := objectSlice(plan["acceptance_criteria"])
- tasks, _ := objectSlice(plan["tasks"])
- deliverySlices, _ := deliveryDefinitions(plan)
- rows := make([]any, 0, len(criteria))
- evidence := []string{
- "# Evidence ledger: " + stringValue(plan["feature_id"]), "",
- "- Authorized plan lock: pending", "- Test gate: `BLOCKED`", "- Review gate: `BLOCKED`", "- Ship gate: `BLOCKED`", "",
- }
- if plan["delivery_slices"] != nil {
- evidence = append(evidence, "## Delivery slices", "")
- for _, slice := range deliverySlices {
- evidence = append(evidence,
- "### "+slice.ID+": "+slice.Title, "",
- "- Test gate ("+slice.ID+"): `BLOCKED`",
- "- Review gate ("+slice.ID+"): `BLOCKED`",
- "- Ship gate ("+slice.ID+"): `BLOCKED`", "",
- )
- }
- }
- evidence = append(evidence, "## Acceptance evidence", "", "| Criterion | Tasks | Result | Evidence |", "|---|---|---|---|")
- for _, criterion := range criteria {
- criterionID := stringValue(criterion["id"])
- servingIDs := []string{}
- validations := []any{}
- for _, task := range tasks {
- mapped, _ := stringSlice(task["acceptance_criteria"])
- if !contains(mapped, criterionID) {
- continue
- }
- taskID := stringValue(task["id"])
- servingIDs = append(servingIDs, taskID)
- checks, _ := validationSlice(task["validation"])
- for _, check := range checks {
- checkCriteria, _ := stringSlice(check["criteria"])
- if !contains(checkCriteria, criterionID) {
- continue
- }
- validations = append(validations, map[string]any{
- "task_id": taskID,
- "check": check["run"],
- "origin": check["origin"],
- "oracle": check["oracle"],
- "independence": check["independence"],
- })
- }
- }
- row := map[string]any{
- "criterion_id": criterionID,
- "criterion": stringValue(criterion["text"]),
- "tasks": servingIDs,
- "validations": validations,
- "result": "BLOCKED",
- "evidence": nil,
- }
- rows = append(rows, row)
- evidence = append(evidence, fmt.Sprintf("| %s: %s | %s | `BLOCKED` | |", criterionID, stringValue(criterion["text"]), strings.Join(servingIDs, ", ")))
- }
- evidence = append(evidence, "", "## Safety evidence", "", "- Operational diff safety: `BLOCKED`", "- External target and recovery evidence: pending", "", "## Commands and checks", "", "## Review findings", "", "## Known gaps", "", "## Rollout and rollback", "")
- taskGraph := map[string]any{
- "schema_version": 1,
- "feature_id": plan["feature_id"],
- "source_plan_path": plan["source_plan_path"],
- "source_plan_status": "HASH_LOCKED_INPUT",
- "structured_plan_status": "HUMAN_APPROVED",
- "tasks": plan["tasks"],
- }
- testMatrix := map[string]any{
- "schema_version": 1,
- "feature_id": plan["feature_id"],
- "requirements": rows,
- }
- deliveryValues := make([]any, 0, len(deliverySlices))
- for _, slice := range deliverySlices {
- deliveryValues = append(deliveryValues, map[string]any{
- "id": slice.ID, "title": slice.Title, "task_ids": slice.TaskIDs,
- "acceptance_criteria": slice.AcceptanceCriteria, "affected_paths": slice.AffectedPaths,
- "base_branch": slice.BaseBranch, "head_branch": slice.HeadBranch,
- })
- }
- taskGraph["delivery_slices"] = deliveryValues
- return taskGraph, testMatrix, strings.Join(evidence, "\n"), nil
-}
-
-func CompilePlanFiles(planPath, outDir string) error {
- return compilePlanFiles(planPath, outDir, "HUMAN_APPROVED")
-}
-
-// canonicalizeExistingAncestor resolves symlinks on the deepest existing prefix
-// of an absolute path and rejoins any not-yet-created remainder. It lets a target
-// path that lives under a symlinked volume (e.g. macOS /var -> /private/var) be
-// compared against a symlink-resolved repository root even before its parent
-// directories exist.
-func canonicalizeExistingAncestor(path string) string {
- remainder := ""
- current := filepath.Clean(path)
- for {
- if resolved, err := filepath.EvalSymlinks(current); err == nil {
- if remainder == "" {
- return resolved
- }
- return filepath.Join(resolved, remainder)
- }
- parent := filepath.Dir(current)
- if parent == current {
- return path
- }
- remainder = filepath.Join(filepath.Base(current), remainder)
- current = parent
- }
-}
-
-func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error {
- repoRoot, err := ResolveControllerRepository(filepath.Dir(planPath))
- if err != nil {
- return err
- }
- artifacts, err := compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus)
- if err != nil {
- return err
- }
- // Promote the compiled plan (four files that must land together) through the
- // transactional mutation boundary: an all-or-nothing atomic write with an
- // automatic rollback on post-write verification failure and a durable receipt.
- // This replaces three independent non-atomic os.WriteFile calls that could
- // leave a compiled graph without its evidence ledger on a mid-write crash.
- mutation := MutationSet{
- Protocol: MutationProtocol,
- Kind: "compiled-plan",
- Scope: artifacts.scope,
- Base: artifacts.base,
- Authority: MutationAuthority{Expected: artifacts.authority, Observed: artifacts.authority},
- Operations: artifacts.ops,
- PostCheck: artifacts.postCheck,
- }
- if _, err := ApplyControllerMutation(repoRoot, mutation); err != nil {
- return err
- }
- return nil
-}
-
-// compiledArtifacts holds the compiled set's mutation operations, their scope,
-// per-path base preconditions, the authorizing plan fingerprint, the compiled
-// tasks.json bytes (so the plan lock can bind their hash without a disk read),
-// and a post-write verifier. It is the shared spine of both the compiled-plan
-// mutation and the fully atomic plan-activation mutation.
-type compiledArtifacts struct {
- ops []MutationOperation
- scope []string
- base map[string]string
- authority string
- tasksJSON []byte
- journeyJSON []byte
- postCheck func() error
-}
-
-func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) (compiledArtifacts, error) {
- plan, err := LoadPlan(planPath)
- if err != nil {
- return compiledArtifacts{}, err
- }
- sourcePlan, err := sourcePlanForStructuredPlan(planPath, repoRoot)
- if err != nil {
- return compiledArtifacts{}, err
- }
- if err := CheckSourcePlan(sourcePlan); err != nil {
- return compiledArtifacts{}, err
- }
- opts := &ValidatePlanOptions{
- PlanPath: planPath,
- RepoRoot: repoRoot,
- }
- tasks, matrix, evidence, err := CompilePlan(plan, opts)
- if err != nil {
- return compiledArtifacts{}, err
- }
- tasks["structured_plan_status"] = structuredPlanStatus
- tasksJSON, err := MarshalJSON(tasks)
- if err != nil {
- return compiledArtifacts{}, err
- }
- matrixJSON, err := MarshalJSON(matrix)
- if err != nil {
- return compiledArtifacts{}, err
- }
- journeyJSON, err := CompileJourneyManifest(plan)
- if err != nil {
- return compiledArtifacts{}, err
- }
- absOut, err := filepath.Abs(outDir)
- if err != nil {
- return compiledArtifacts{}, err
- }
- // Canonicalize through symlinks so repository-relative resolution matches the
- // symlink-resolved repo root (e.g. /var -> /private/var on macOS). The output
- // directory (and one or more of its parents) may not exist yet, so resolve the
- // deepest existing ancestor and rejoin the not-yet-created remainder.
- absOut = canonicalizeExistingAncestor(absOut)
- workspace, err := ResolveWorkspaceContext(repoRoot)
- if err != nil {
- return compiledArtifacts{}, err
- }
- artifactRoot := workspace.ExportRoot()
- relTasks, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "tasks.json"))
- if err != nil {
- return compiledArtifacts{}, err
- }
- relMatrix, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "test-matrix.json"))
- if err != nil {
- return compiledArtifacts{}, err
- }
- relEvidence, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "evidence.md"))
- if err != nil {
- return compiledArtifacts{}, err
- }
- relJourney, err := repositoryRelativePath(artifactRoot, filepath.Join(absOut, "journey-oracles.json"))
- if err != nil {
- return compiledArtifacts{}, err
- }
- authority := ""
- if check, checkErr := CheckPlanForRepository(repoRoot, planPath); checkErr == nil {
- authority = check.Fingerprint
- }
- scope := []string{relTasks, relMatrix, relEvidence, relJourney}
- base := map[string]string{}
- for _, rel := range scope {
- if hash, hashErr := SHA256File(filepath.Join(artifactRoot, filepath.FromSlash(rel))); hashErr == nil {
- base[rel] = hash
- }
- }
- postCheck := func() error {
- for _, rel := range []string{relTasks, relMatrix, relJourney} {
- value, readErr := os.ReadFile(filepath.Join(artifactRoot, filepath.FromSlash(rel)))
- if readErr != nil {
- return readErr
- }
- if validateErr := ValidateJSON("verify promoted compiled plan", rel, value); validateErr != nil {
- return validateErr
- }
- }
- info, statErr := os.Stat(filepath.Join(artifactRoot, filepath.FromSlash(relEvidence)))
- if statErr != nil || info.Size() == 0 {
- return fmt.Errorf("promoted evidence ledger is missing or empty")
- }
- return nil
- }
- return compiledArtifacts{
- ops: []MutationOperation{
- {Path: relTasks, Candidate: tasksJSON},
- {Path: relMatrix, Candidate: matrixJSON},
- {Path: relEvidence, Candidate: []byte(evidence)},
- {Path: relJourney, Candidate: journeyJSON},
- },
- scope: scope,
- base: base,
- authority: authority,
- tasksJSON: tasksJSON,
- journeyJSON: journeyJSON,
- postCheck: postCheck,
- }, nil
-}
-
-type ApprovalOptions struct {
- Repo string
- SourcePlanPath string
- SpecPath string
- PlanPath string
- TasksPath string
- ApprovedBy string
- ApprovedAt string
- AuthorizationMode string
- SourceCommit string
- OutputPath string
- BaselineDiffSHA256 string
- BaselineChangedPaths []string
- Readiness ReadinessReceipt
- AutonomyPath string
- AutonomyFingerprint string
- RunTarget RunTarget
-}
-
-type ApprovalReceipt struct {
- SchemaVersion int
- Status string
- ApprovedBy string
- ApprovedAt string
- Fingerprint string
- BaselineDiffSHA256 string
- BaselineChangedPaths []string
- Readiness ReadinessReceipt
- LifecycleSHA256 string
- PlanLockSHA256 string
- ObservationID string
-}
-
-func LoadApprovalReceipt(path string) (ApprovalReceipt, error) {
- if !strings.EqualFold(filepath.Ext(path), ".md") {
- return ApprovalReceipt{}, fmt.Errorf("approval receipt must be a Markdown file: %s", path)
- }
- value, err := loadJSONObject(path, "approval receipt", approvalMarkerStart, approvalMarkerEnd, false)
- if err != nil {
- return ApprovalReceipt{}, err
- }
- receipt := ApprovalReceipt{
- SchemaVersion: intValue(value["schema_version"]),
- Status: stringValue(value["status"]),
- ApprovedBy: stringValue(value["approved_by"]),
- ApprovedAt: stringValue(value["approved_at"]),
- Fingerprint: stringValue(value["approval_fingerprint"]),
- BaselineDiffSHA256: stringValue(value["baseline_diff_sha256"]),
- BaselineChangedPaths: []string{},
- LifecycleSHA256: stringValue(value["lifecycle_sha256"]),
- PlanLockSHA256: stringValue(value["plan_lock_sha256"]),
- ObservationID: stringValue(value["observation_id"]),
- Readiness: ReadinessReceipt{
- Fingerprint: stringValue(value["readiness_fingerprint"]),
- BaseBranch: stringValue(value["base_branch"]), HeadBranch: stringValue(value["head_branch"]),
- BaseCommit: stringValue(value["base_commit"]), HeadCommit: stringValue(value["head_commit"]),
- Upstream: stringValue(value["upstream"]), Relation: stringValue(value["upstream_relation"]),
- JourneyManifestSHA256: stringValue(value["journey_manifest_sha256"]),
- },
- }
- if paths, ok := stringSlice(value["baseline_changed_paths"]); ok {
- receipt.BaselineChangedPaths = paths
- } else if receipt.SchemaVersion == 2 {
- return ApprovalReceipt{}, fmt.Errorf("approval receipt baseline_changed_paths must be a string list")
- }
- if receipt.SchemaVersion != 1 && receipt.SchemaVersion != 2 && receipt.SchemaVersion != 3 {
- return ApprovalReceipt{}, fmt.Errorf("approval receipt schema_version must be 1, 2, or 3")
- }
- if receipt.Status != "APPROVED" {
- return ApprovalReceipt{}, fmt.Errorf("approval receipt status must be APPROVED")
- }
- if strings.TrimSpace(receipt.ApprovedBy) == "" {
- return ApprovalReceipt{}, fmt.Errorf("approval receipt must name the human approver")
- }
- if _, err := time.Parse(time.RFC3339, receipt.ApprovedAt); err != nil {
- return ApprovalReceipt{}, fmt.Errorf("approval receipt approved_at must be RFC3339: %w", err)
- }
- if strings.TrimSpace(receipt.Fingerprint) == "" {
- return ApprovalReceipt{}, fmt.Errorf("approval receipt fingerprint is required")
- }
- if receipt.SchemaVersion == 3 && (receipt.Readiness.Fingerprint == "" || receipt.Readiness.BaseCommit == "" ||
- receipt.Readiness.HeadCommit == "" || receipt.Readiness.BaseBranch == "" || receipt.Readiness.HeadBranch == "" ||
- receipt.Readiness.Relation == "" || receipt.Readiness.JourneyManifestSHA256 == "") {
- return ApprovalReceipt{}, fmt.Errorf("schema-v3 approval receipt requires complete readiness evidence")
- }
- if receipt.SchemaVersion == 3 {
- receipt.Readiness.PlanFingerprint = receipt.Fingerprint
- expected, err := readinessFingerprint(receipt.Readiness)
- if err != nil || expected != receipt.Readiness.Fingerprint {
- return ApprovalReceipt{}, fmt.Errorf("schema-v3 approval receipt readiness fingerprint does not match its fields")
- }
- }
- return receipt, nil
-}
-
-func intValue(value any) int {
- number, ok := value.(float64)
- if !ok {
- return 0
- }
- return int(number)
-}
-
-func checkApprovalReceipt(path string, planCheck PlanCheck, repo string) (ApprovalReceipt, error) {
- receipt, err := LoadApprovalReceipt(path)
- if err != nil {
- return ApprovalReceipt{}, err
- }
- if receipt.Fingerprint != planCheck.Fingerprint {
- return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: fingerprint does not match the current source plan, spec, and plan")
- }
- if version, _ := planCheck.Plan["schema_version"].(float64); version >= 3 && receipt.SchemaVersion < 3 {
- return ApprovalReceipt{}, fmt.Errorf("legacy approval receipt has no readiness evidence; refresh approval against the current schema-v3 plan")
- }
- if repo == "" {
- repo, err = ResolveControllerRepository(filepath.Dir(planCheck.PlanPath))
- if err != nil {
- return ApprovalReceipt{}, err
- }
- }
- if receipt.SchemaVersion == 3 {
- receipt.Readiness.PlanFingerprint = receipt.Fingerprint
- current, readinessErr := checkPlanReadiness(repo, planCheck.PlanPath)
- if readinessErr != nil {
- return ApprovalReceipt{}, readinessErr
- }
- if current.Fingerprint != receipt.Readiness.Fingerprint {
- return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: readiness fingerprint changed after approval")
- }
- }
- baseline, err := productBaseline(repo, planCheck.PlanPath, planCheck.SourcePlanPath, planCheck.SpecPath, path)
- if err != nil {
- return ApprovalReceipt{}, err
- }
- if receipt.SchemaVersion == 1 {
- if baseline.DiffSHA256 != "" {
- return ApprovalReceipt{}, fmt.Errorf("schema-v1 approval receipts remain valid only with a clean pre-activation product baseline; changed paths: %s", strings.Join(baseline.ChangedPaths, ", "))
- }
- } else if receipt.BaselineDiffSHA256 != baseline.DiffSHA256 || strings.Join(receipt.BaselineChangedPaths, "\x00") != strings.Join(baseline.ChangedPaths, "\x00") {
- return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: baseline product diff changed after approval")
- }
- return receipt, nil
-}
-
-func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, error) {
- return checkApprovalReceipt(path, planCheck, "")
-}
-
-type ActivationOptions struct {
- Repo string
- PlanPath string
- ApprovalPath string
- OutDir string
- OutputPath string
- SourceCommit string
- AutonomyPath string
-}
-
-func ActivatePlan(options ActivationOptions) error {
- if strings.TrimSpace(options.Repo) == "" {
- return fmt.Errorf("plan activation requires the invoking repository context")
- }
- repo, err := ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath))
- if err != nil {
- return err
- }
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return err
- }
- for _, owned := range []struct{ label, path string }{
- {"plan", options.PlanPath}, {"compiled output", options.OutDir}, {"plan lock", options.OutputPath},
- {"approval", options.ApprovalPath}, {"autonomy receipt", options.AutonomyPath},
- } {
- if strings.TrimSpace(owned.path) != "" && !pathWithin(ctx.ExportRoot(), owned.path) {
- return fmt.Errorf("%s path is outside the invoking repository's verified controller boundary: %s", owned.label, owned.path)
- }
- }
- check, err := CheckPlanForRepository(repo, options.PlanPath)
- if err != nil {
- return err
- }
- config, _, err := LoadConfig(ctx.ProjectConfigPath())
- if err != nil {
- return fmt.Errorf("plan activation requires a valid Boatstack project configuration: %w", err)
- }
- feature := strings.TrimSpace(stringValue(check.Plan["feature_id"]))
- if statePath, statePathErr := deliveryStatePath(repo, feature); statePathErr == nil && fileExists(statePath) {
- state, loadErr := LoadDeliveryState(repo, feature)
- if loadErr != nil {
- return loadErr
- }
- if state.Mode == "AMENDMENT_REQUIRED" || state.Mode == "PLAN_INVALID" {
- snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, feature)
- if snapshotErr != nil {
- return snapshotErr
- }
- if snapshot.State != deliverycontrol.StateAmendmentApproved {
- return fmt.Errorf("active delivery amendment is not currently approved for activation: %s", snapshot.State)
- }
- }
- }
- // Once a feature's workspace worktree is cut, activation must happen inside it,
- // never from the main worktree on the base branch — otherwise the compiled
- // artifacts and delivery ledger land on the base branch and compete with the
- // cut worktree's own state.
- if err := guardManagedActivationWorktree(repo, config, stringValue(check.Plan["feature_id"])); err != nil {
- return err
- }
- authorizationMode := "policy"
- structuredPlanStatus := "POLICY_ACTIVATED"
- receipt := ApprovalReceipt{}
- baseline := PlanningBaseline{}
- autonomy := AutonomyReceipt{}
- if strings.TrimSpace(options.AutonomyPath) != "" {
- autonomy, err = CheckAutonomyReceipt(options.AutonomyPath, check, repo, RunTargetVerified, "")
- if err != nil {
- return err
- }
- baseline, err = productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.AutonomyPath, options.OutputPath)
- if err != nil {
- return err
- }
- } else if config.Workflow.HumanPlanApproval {
- authorizationMode = "human"
- structuredPlanStatus = "HUMAN_APPROVED"
- if strings.TrimSpace(options.ApprovalPath) == "" {
- return fmt.Errorf("human_plan_approval requires --approval")
- }
- receipt, err = checkApprovalReceipt(options.ApprovalPath, check, repo)
- if err != nil {
- return err
- }
- baseline = PlanningBaseline{DiffSHA256: receipt.BaselineDiffSHA256, ChangedPaths: receipt.BaselineChangedPaths}
- } else {
- baseline, err = productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.ApprovalPath, options.OutputPath)
- if err != nil {
- return err
- }
- }
- safety, err := CheckRepositorySafety(repo)
- if err != nil {
- return err
- }
- if safety.Status != "PASS" {
- return fmt.Errorf("operational diff contains an irreversible capability: %s", safety.Findings[0].Category)
- }
- tasksPath := filepath.Join(options.OutDir, "tasks.json")
- approval := ApprovalOptions{
- Repo: repo,
- SourcePlanPath: check.SourcePlanPath,
- SpecPath: check.SpecPath,
- PlanPath: options.PlanPath,
- TasksPath: tasksPath,
- ApprovedBy: receipt.ApprovedBy,
- ApprovedAt: receipt.ApprovedAt,
- AuthorizationMode: authorizationMode,
- SourceCommit: options.SourceCommit,
- OutputPath: options.OutputPath,
- BaselineDiffSHA256: baseline.DiffSHA256,
- BaselineChangedPaths: baseline.ChangedPaths,
- Readiness: receipt.Readiness,
- AutonomyPath: options.AutonomyPath,
- AutonomyFingerprint: autonomy.Fingerprint,
- RunTarget: autonomy.Target,
- }
- if version, _ := check.Plan["schema_version"].(float64); version >= 3 && authorizationMode == "policy" {
- approval.Readiness, err = checkPlanReadiness(repo, options.PlanPath)
- if err != nil {
- return err
- }
- }
- if fileExists(options.OutputPath) {
- if err := CheckApprovalLock(approval); err == nil {
- return initializeDeliveryState(repo, stringValue(check.Plan["feature_id"]), options.PlanPath, options.OutputPath)
- }
- value, readErr := os.ReadFile(options.OutputPath)
- if readErr != nil {
- return fmt.Errorf("existing plan lock cannot be verified: %w", readErr)
- }
- var existing map[string]any
- if err := DecodeJSON("inspect existing plan lock", options.OutputPath, value, &existing); err != nil {
- return fmt.Errorf("%w; do not overwrite activation state", err)
- }
- currentPlanHash, _ := SHA256File(options.PlanPath)
- currentSourceHash, _ := SHA256File(check.SourcePlanPath)
- currentSpecHash, _ := SHA256File(check.SpecPath)
- if stringValue(existing["plan_sha256"]) == currentPlanHash &&
- stringValue(existing["source_plan_sha256"]) == currentSourceHash &&
- stringValue(existing["spec_sha256"]) == currentSpecHash {
- return fmt.Errorf("existing activation state is invalid for the unchanged authorized plan; repair it instead of resetting delivery progress")
- }
- } else if statePath, statePathErr := deliveryStatePath(repo, stringValue(check.Plan["feature_id"])); statePathErr == nil && fileExists(statePath) {
- return fmt.Errorf("managed delivery state exists without its plan lock; do not reset delivery progress")
- }
- // Refuse an amendment that would alter, drop, or reorder an already-published
- // delivery slice BEFORE any artifact is promoted, so a rejected re-activation
- // leaves the prior plan lock and delivery state fully intact rather than
- // half-applied. The same invariant is re-checked in initializeDeliveryState.
- if err := guardReactivationPreservesProgress(repo, stringValue(check.Plan["feature_id"]), options.PlanPath); err != nil {
- return err
- }
- // Assemble the single activation MutationSet: the compiled set plus the plan
- // lock, all promoted all-or-nothing through the transactional boundary so no
- // crash or failed post-write check can leave a compiled graph without its lock
- // (or a lock without its graph). The bytes are built before the atomic promote;
- // nothing is written to disk until ApplyMutation succeeds end to end.
- mutation, err := activationMutation(repo, options, structuredPlanStatus, approval)
- if err != nil {
- return err
- }
- // Baseline drift guard runs immediately before the atomic promote so a product
- // change concurrent with approval cannot be sealed into the lock. The compiled
- // output directory and the lock path are excluded from the baseline.
- currentBaseline, err := productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.ApprovalPath, options.AutonomyPath, options.OutputPath, options.OutDir)
- if err != nil {
- return err
- }
- if currentBaseline.DiffSHA256 != baseline.DiffSHA256 || strings.Join(currentBaseline.ChangedPaths, "\x00") != strings.Join(baseline.ChangedPaths, "\x00") {
- return fmt.Errorf("pre-activation product baseline drifted before the plan lock could be created; expected paths %s, observed paths %s", strings.Join(baseline.ChangedPaths, ", "), strings.Join(currentBaseline.ChangedPaths, ", "))
- }
- if approval.Readiness.Fingerprint != "" {
- currentReadiness, readinessErr := checkPlanReadiness(repo, options.PlanPath)
- if readinessErr != nil {
- return readinessErr
- }
- if currentReadiness.Fingerprint != approval.Readiness.Fingerprint {
- return fmt.Errorf("pre-activation readiness drifted before the immutable plan lock could be created")
- }
- }
- if _, err := ApplyControllerMutation(repo, mutation); err != nil {
- return err
- }
- return initializeDeliveryState(repo, stringValue(check.Plan["feature_id"]), options.PlanPath, options.OutputPath)
-}
-
-// activationMutation assembles the five-artifact activation MutationSet: the
-// compiled set (tasks.json, test-matrix.json, evidence.md, journey-oracles.json)
-// and the plan lock.
-// The lock binds the compiled task graph hash from the in-memory candidate bytes
-// so all five land in a single atomic promote, and its PostCheck verifies the
-// promoted lock against the plan/spec/source-plan and the promoted task graph.
-func activationMutation(repoRoot string, options ActivationOptions, structuredPlanStatus string, approval ApprovalOptions) (MutationSet, error) {
- artifacts, err := compileArtifacts(repoRoot, options.PlanPath, options.OutDir, structuredPlanStatus)
- if err != nil {
- return MutationSet{}, err
- }
- lockBytes, err := buildApprovalLock(approval, SHA256Bytes(artifacts.tasksJSON))
- if err != nil {
- return MutationSet{}, err
- }
- absLock, err := filepath.Abs(options.OutputPath)
- if err != nil {
- return MutationSet{}, err
- }
- absLock = canonicalizeExistingAncestor(absLock)
- workspace, err := ResolveWorkspaceContext(repoRoot)
- if err != nil {
- return MutationSet{}, err
- }
- artifactRoot := workspace.ExportRoot()
- relLock, err := repositoryRelativePath(artifactRoot, absLock)
- if err != nil {
- return MutationSet{}, err
- }
- scope := append(append([]string{}, artifacts.scope...), relLock)
- base := map[string]string{}
- for rel, hash := range artifacts.base {
- base[rel] = hash
- }
- if hash, hashErr := SHA256File(filepath.Join(artifactRoot, filepath.FromSlash(relLock))); hashErr == nil {
- base[relLock] = hash
- }
- ops := append(append([]MutationOperation{}, artifacts.ops...), MutationOperation{Path: relLock, Candidate: lockBytes})
- trioPostCheck := artifacts.postCheck
- mutation := MutationSet{
- Protocol: MutationProtocol,
- Kind: "plan-activation",
- Scope: scope,
- Base: base,
- Authority: MutationAuthority{Expected: artifacts.authority, Observed: artifacts.authority},
- Operations: ops,
- PostCheck: func() error {
- if err := trioPostCheck(); err != nil {
- return err
- }
- return CheckApprovalLock(approval)
- },
- }
- return mutation, nil
-}
-
-func gitCommit(directory string) string {
- command := exec.Command("git", "-C", directory, "rev-parse", "HEAD")
- value, err := command.Output()
- if err != nil {
- return "unknown"
- }
- return strings.TrimSpace(string(value))
-}
-
-// buildApprovalLock constructs the plan.lock.json bytes. The compiled task graph
-// hash is supplied by the caller rather than read from disk so the lock can be
-// promoted in the same atomic MutationSet as tasks.json — during activation the
-// task graph does not yet exist on disk when the lock bytes are assembled.
-func buildApprovalLock(options ApprovalOptions, tasksSHA256 string) ([]byte, error) {
- mode := strings.ToLower(strings.TrimSpace(options.AuthorizationMode))
- if mode == "" {
- mode = "human"
- }
- if mode != "human" && mode != "policy" {
- return nil, fmt.Errorf("authorization mode must be human or policy")
- }
- if mode == "human" && strings.TrimSpace(options.ApprovedBy) == "" {
- return nil, fmt.Errorf("approved-by must name the human who explicitly approved the plan")
- }
- if err := checkApprovalSourcePlan(options); err != nil {
- return nil, err
- }
- for _, path := range []string{options.SpecPath, options.PlanPath} {
- if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
- return nil, fmt.Errorf("required approved artifact does not exist: %s", path)
- }
- }
- if strings.TrimSpace(tasksSHA256) == "" {
- return nil, fmt.Errorf("required approved artifact does not exist: %s", options.TasksPath)
- }
- approvedAt := options.ApprovedAt
- if approvedAt == "" {
- approvedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339)
- }
- sourceCommit := options.SourceCommit
- if sourceCommit == "" {
- sourceCommit = gitCommit(filepath.Dir(options.SpecPath))
- }
- specHash, _ := SHA256File(options.SpecPath)
- sourcePlanHash, _ := SHA256File(options.SourcePlanPath)
- planHash, _ := SHA256File(options.PlanPath)
- baselinePaths := options.BaselineChangedPaths
- if baselinePaths == nil {
- baselinePaths = []string{}
- }
- lock := map[string]any{
- "schema_version": 2,
- "status": "LOCKED",
- "authorization_mode": mode,
- "activated_at": approvedAt,
- "source_commit": sourceCommit,
- "source_plan_path": options.SourcePlanPath,
- "source_plan_sha256": sourcePlanHash,
- "spec_path": options.SpecPath,
- "spec_sha256": specHash,
- "plan_path": options.PlanPath,
- "plan_sha256": planHash,
- "task_graph_path": options.TasksPath,
- "task_graph_sha256": tasksSHA256,
- "invalidated_at": nil,
- "invalidation_reason": nil,
- "baseline_diff_sha256": options.BaselineDiffSHA256,
- "baseline_changed_paths": baselinePaths,
- }
- if options.AutonomyPath != "" {
- autonomyHash, err := SHA256File(options.AutonomyPath)
- if err != nil {
- return nil, fmt.Errorf("autonomy receipt is missing or unreadable: %w", err)
- }
- lock["autonomy_path"] = options.AutonomyPath
- lock["autonomy_sha256"] = autonomyHash
- lock["autonomy_fingerprint"] = options.AutonomyFingerprint
- lock["run_target"] = options.RunTarget
- }
- if options.Readiness.Fingerprint != "" {
- lock["schema_version"] = 3
- lock["readiness_fingerprint"] = options.Readiness.Fingerprint
- lock["readiness_plan_fingerprint"] = options.Readiness.PlanFingerprint
- lock["base_branch"] = options.Readiness.BaseBranch
- lock["head_branch"] = options.Readiness.HeadBranch
- lock["base_commit"] = options.Readiness.BaseCommit
- lock["head_commit"] = options.Readiness.HeadCommit
- lock["upstream"] = options.Readiness.Upstream
- lock["upstream_relation"] = options.Readiness.Relation
- lock["journey_manifest_sha256"] = options.Readiness.JourneyManifestSHA256
- }
- if mode == "human" {
- lock["approved_by"] = options.ApprovedBy
- lock["approved_at"] = approvedAt
- }
- return MarshalJSON(lock)
-}
-
-func CreateApprovalLock(options ApprovalOptions) error {
- if info, err := os.Stat(options.TasksPath); err != nil || !info.Mode().IsRegular() {
- return fmt.Errorf("required approved artifact does not exist: %s", options.TasksPath)
- }
- tasksHash, err := SHA256File(options.TasksPath)
- if err != nil {
- return err
- }
- value, err := buildApprovalLock(options, tasksHash)
- if err != nil {
- return err
- }
- return writeFile(options.OutputPath, value, 0o644)
-}
-
-func CheckApprovalLock(options ApprovalOptions) error {
- if err := checkApprovalSourcePlan(options); err != nil {
- return err
- }
- value, err := os.ReadFile(options.OutputPath)
- if err != nil {
- return fmt.Errorf("plan lock is missing or unreadable: %w", err)
- }
- lock := map[string]any{}
- if err := DecodeJSON("check plan approval lock", options.OutputPath, value, &lock); err != nil {
- return err
- }
- mismatches := []string{}
- paths := map[string]string{"source_plan": options.SourcePlanPath, "spec": options.SpecPath, "plan": options.PlanPath, "task_graph": options.TasksPath}
- for _, label := range []string{"source_plan", "spec", "plan", "task_graph"} {
- hash, hashErr := SHA256File(paths[label])
- if hashErr != nil || stringValue(lock[label+"_sha256"]) != hash {
- mismatches = append(mismatches, label)
- }
- }
- schemaVersion := intValue(lock["schema_version"])
- mode := strings.ToLower(stringValue(lock["authorization_mode"]))
- validStatus := schemaVersion == 1 && stringValue(lock["status"]) == "APPROVED"
- if schemaVersion == 2 || schemaVersion == 3 {
- validStatus = stringValue(lock["status"]) == "LOCKED" && (mode == "human" || mode == "policy")
- }
- if !validStatus || lock["invalidated_at"] != nil {
- mismatches = append(mismatches, "status")
- }
- if schemaVersion == 1 || mode == "human" {
- if stringValue(lock["approved_by"]) == "" {
- mismatches = append(mismatches, "approver")
- }
- }
- expectedMode := strings.ToLower(strings.TrimSpace(options.AuthorizationMode))
- if expectedMode != "" && (schemaVersion == 2 || schemaVersion == 3) && mode != expectedMode {
- mismatches = append(mismatches, "authorization_mode")
- }
- if expectedMode == "policy" && schemaVersion == 1 {
- mismatches = append(mismatches, "authorization_mode")
- }
- if options.AutonomyPath != "" {
- hash, hashErr := SHA256File(options.AutonomyPath)
- if hashErr != nil || stringValue(lock["autonomy_sha256"]) != hash || stringValue(lock["autonomy_fingerprint"]) != options.AutonomyFingerprint || stringValue(lock["run_target"]) != string(options.RunTarget) {
- mismatches = append(mismatches, "autonomy")
- }
- }
- if options.BaselineDiffSHA256 != "" && stringValue(lock["baseline_diff_sha256"]) != options.BaselineDiffSHA256 {
- mismatches = append(mismatches, "baseline_diff")
- }
- if schemaVersion == 3 {
- for _, field := range []string{"readiness_fingerprint", "readiness_plan_fingerprint", "base_branch", "head_branch", "base_commit", "head_commit", "upstream_relation", "journey_manifest_sha256"} {
- if stringValue(lock[field]) == "" {
- mismatches = append(mismatches, field)
- }
- }
- storedReadiness := ReadinessReceipt{
- Fingerprint: stringValue(lock["readiness_fingerprint"]), PlanFingerprint: stringValue(lock["readiness_plan_fingerprint"]),
- BaseBranch: stringValue(lock["base_branch"]), HeadBranch: stringValue(lock["head_branch"]),
- BaseCommit: stringValue(lock["base_commit"]), HeadCommit: stringValue(lock["head_commit"]),
- Upstream: stringValue(lock["upstream"]), Relation: stringValue(lock["upstream_relation"]),
- JourneyManifestSHA256: stringValue(lock["journey_manifest_sha256"]),
- }
- if fingerprint, fingerprintErr := readinessFingerprint(storedReadiness); fingerprintErr != nil || fingerprint != storedReadiness.Fingerprint {
- mismatches = append(mismatches, "readiness_fingerprint")
- }
- repo := ""
- var repoErr error
- if strings.TrimSpace(options.Repo) != "" {
- repo, repoErr = ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath))
- } else {
- repo, repoErr = ResolveControllerRepository(filepath.Dir(options.PlanPath))
- }
- plan, planErr := LoadPlan(options.PlanPath)
- if repoErr != nil || planErr != nil {
- mismatches = append(mismatches, "journey_manifest")
- } else {
- manifest, manifestErr := loadJourneyManifest(journeyManifestPath(repo, stringValue(plan["feature_id"])))
- if manifestErr != nil || stringValue(manifest["manifest_sha256"]) != stringValue(lock["journey_manifest_sha256"]) {
- mismatches = append(mismatches, "journey_manifest")
- }
- }
- }
- if schemaVersion != 1 && schemaVersion != 2 && schemaVersion != 3 {
- mismatches = append(mismatches, "schema_version")
- }
- if len(mismatches) > 0 {
- return fmt.Errorf("stale or invalid plan lock: %s", strings.Join(mismatches, ", "))
- }
- return nil
-}
diff --git a/boatstack/plan_test.go b/boatstack/plan_test.go
deleted file mode 100644
index a7ffba1..0000000
--- a/boatstack/plan_test.go
+++ /dev/null
@@ -1,629 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func validPlan() map[string]any {
- return map[string]any{
- "schema_version": float64(1),
- "feature_id": "feature-one",
- "source_plan_path": "source-plan.md",
- "spec_path": "spec.md",
- "blocking_questions": []any{},
- "acceptance_criteria": []any{
- map[string]any{"id": "AC-1", "text": "observable result"},
- },
- "tasks": []any{
- map[string]any{
- "id": "T-1", "title": "implement result", "depends_on": []any{},
- "acceptance_criteria": []any{"AC-1"},
- "validation": []any{map[string]any{
- "criteria": []any{"AC-1"},
- "run": "go test ./...", "origin": "AC-1",
- "oracle": "approved contract assertions", "independence": "contract-derived",
- }},
- },
- },
- }
-}
-
-func writeMarkdownPlan(t *testing.T, path string, plan map[string]any, marked bool) {
- t.Helper()
- value, err := MarshalJSON(plan)
- if err != nil {
- t.Fatal(err)
- }
- body := "# Structured plan\n\nHuman-readable summary covered by approval.\n\n"
- if marked {
- body += planMarkerStart + "\n"
- }
- body += "```json\n" + strings.TrimSpace(string(value)) + "\n```\n"
- if marked {
- body += planMarkerEnd + "\n"
- }
- if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func writeApprovalReceipt(t *testing.T, path, fingerprint string) {
- t.Helper()
- body := `# Plan approval
-
-
-` + "```json\n" + `{
- "schema_version": 1,
- "status": "APPROVED",
- "approved_by": "Test Human",
- "approved_at": "2026-07-16T12:00:00Z",
- "approval_fingerprint": "` + fingerprint + `"
-}
-` + "```\n" + `
-`
- if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func writePlanInputs(t *testing.T, root string, marked bool) (string, string, string) {
- t.Helper()
- sourcePlan := filepath.Join(root, "source-plan.md")
- spec := filepath.Join(root, "spec.md")
- planPath := filepath.Join(root, "plan.md")
- if err := os.WriteFile(sourcePlan, []byte("# Host Plan-mode proposal\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(spec, []byte("# Accepted spec\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- writeMarkdownPlan(t, planPath, validPlan(), marked)
- return sourcePlan, spec, planPath
-}
-
-func writeActivationConfig(t *testing.T, root string, humanApproval bool) {
- t.Helper()
- config := testConfig()
- config.Workflow.HumanPlanApproval = humanApproval
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(root, ".product-loop"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(root, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestMarkdownPlanActivationAndStaleness(t *testing.T) {
- root := t.TempDir()
- sourcePlan, _, planPath := writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- writeActivationConfig(t, root, true)
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record approved planning inputs")
- approval := filepath.Join(root, "approval.md")
- compiled := filepath.Join(root, "compiled")
- lock := filepath.Join(root, "plan.lock.json")
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- writeApprovalReceipt(t, approval, check.Fingerprint)
- options := ActivationOptions{Repo: root, PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lock, SourceCommit: "test"}
- if err := ActivatePlan(options); err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{filepath.Join(compiled, "tasks.json"), filepath.Join(compiled, "test-matrix.json"), filepath.Join(compiled, "evidence.md"), lock} {
- if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
- t.Fatalf("expected activated artifact %s", path)
- }
- }
- if err := os.WriteFile(sourcePlan, []byte("# Changed host plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := ActivatePlan(options); err == nil || !strings.Contains(err.Error(), "stale approval") {
- t.Fatalf("expected stale approval after source-plan change, got %v", err)
- }
- if err := os.WriteFile(sourcePlan, []byte("# Host Plan-mode proposal\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- value, _ := os.ReadFile(planPath)
- if err := os.WriteFile(planPath, append([]byte("Changed human summary.\n"), value...), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := ActivatePlan(options); err == nil || !strings.Contains(err.Error(), "stale approval") {
- t.Fatalf("expected stale approval after plan prose change, got %v", err)
- }
-}
-
-func TestPolicyActivationCreatesTypedLockWithoutApproval(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- writeActivationConfig(t, root, false)
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record policy-activated planning inputs")
- compiled := filepath.Join(root, "compiled")
- lockPath := filepath.Join(root, "plan.lock.json")
- options := ActivationOptions{Repo: root, PlanPath: planPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}
- if err := ActivatePlan(options); err != nil {
- t.Fatal(err)
- }
- lockValue, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- lock := map[string]any{}
- if err := json.Unmarshal(lockValue, &lock); err != nil {
- t.Fatal(err)
- }
- if intValue(lock["schema_version"]) != 2 || stringValue(lock["status"]) != "LOCKED" || stringValue(lock["authorization_mode"]) != "policy" || stringValue(lock["approved_by"]) != "" {
- t.Fatalf("unexpected policy lock: %#v", lock)
- }
- tasksValue, _ := os.ReadFile(filepath.Join(compiled, "tasks.json"))
- tasks := map[string]any{}
- _ = json.Unmarshal(tasksValue, &tasks)
- if stringValue(tasks["structured_plan_status"]) != "POLICY_ACTIVATED" {
- t.Fatalf("compiled task graph hid policy activation: %#v", tasks)
- }
-
- lock["schema_version"] = 1
- lock["status"] = "APPROVED"
- lock["approved_by"] = "Legacy Human"
- delete(lock, "authorization_mode")
- legacy, _ := MarshalJSON(lock)
- if err := os.WriteFile(lockPath, legacy, 0o644); err != nil {
- t.Fatal(err)
- }
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := CheckApprovalLock(ApprovalOptions{
- SourcePlanPath: check.SourcePlanPath, SpecPath: check.SpecPath, PlanPath: planPath,
- TasksPath: filepath.Join(compiled, "tasks.json"), OutputPath: lockPath, AuthorizationMode: "human",
- }); err != nil {
- t.Fatalf("legacy v1 human lock was rejected: %v", err)
- }
-}
-
-// activatePolicyPlan sets up a committed repo and activates a policy-mode plan,
-// returning the paths a caller needs to assert on the promoted artifacts. It is
-// the shared fixture for the atomic-activation and managed-undo tests.
-func activatePolicyPlan(t *testing.T) (root, planPath, compiled, lock, feature string) {
- t.Helper()
- root = t.TempDir()
- _, _, planPath = writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- writeActivationConfig(t, root, false)
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record policy-activated planning inputs")
- // Lay the managed artifacts out under the feature directory, as the real
- // workflow does, so the undo verb's domain guard can resolve the owning
- // feature from the promoted paths.
- feature = "feature-one"
- featureDir := filepath.Join(root, ".product-loop", "features", feature)
- compiled = filepath.Join(featureDir, "compiled")
- lock = filepath.Join(featureDir, "plan.lock.json")
- options := ActivationOptions{Repo: root, PlanPath: planPath, OutDir: compiled, OutputPath: lock, SourceCommit: "test"}
- if err := ActivatePlan(options); err != nil {
- t.Fatal(err)
- }
- return root, planPath, compiled, lock, feature
-}
-
-// TestActivationPromotesFiveArtifactsAtomically proves activation lands the
-// compiled artifact set and the plan lock as one transactional mutation: a single
-// receipt whose four recorded post-images match the bytes on disk. Because
-// ApplyMutation is all-or-nothing (proven at the primitive level), one receipt
-// covering all five files is the structural guarantee that no partial set can be
-// left behind.
-func TestActivationPromotesFiveArtifactsAtomically(t *testing.T) {
- root, _, compiled, lock, _ := activatePolicyPlan(t)
- artifacts := []string{
- filepath.Join(compiled, "tasks.json"),
- filepath.Join(compiled, "test-matrix.json"),
- filepath.Join(compiled, "evidence.md"),
- filepath.Join(compiled, "journey-oracles.json"),
- lock,
- }
- for _, path := range artifacts {
- if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
- t.Fatalf("expected activated artifact %s", path)
- }
- }
- receipts, err := ListMutationReceipts(root)
- if err != nil {
- t.Fatal(err)
- }
- var activation *MutationReceipt
- for i := range receipts {
- if receipts[i].Kind == "plan-activation" && receipts[i].Status == "APPLIED" {
- activation = &receipts[i]
- break
- }
- }
- if activation == nil {
- t.Fatalf("no APPLIED plan-activation receipt among %d receipts", len(receipts))
- }
- if len(activation.Changes) != 5 {
- t.Fatalf("expected one atomic mutation over five artifacts, got %d changes", len(activation.Changes))
- }
- for _, change := range activation.Changes {
- hash, err := SHA256File(filepath.Join(root, filepath.FromSlash(change.Path)))
- if err != nil {
- t.Fatalf("promoted artifact %s missing: %v", change.Path, err)
- }
- if change.AfterSHA256 != hash {
- t.Fatalf("receipt post-image for %s does not match disk", change.Path)
- }
- }
-}
-
-func TestCurrentCursorSingleJSONFencePlanIsAccepted(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, false)
- if _, err := CheckPlan(planPath); err != nil {
- t.Fatalf("current Cursor plan.md shape should be accepted: %v", err)
- }
-}
-
-func TestPlanJSONIsRejected(t *testing.T) {
- path := filepath.Join(t.TempDir(), "plan.json")
- value, _ := MarshalJSON(validPlan())
- if err := os.WriteFile(path, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := LoadPlan(path); err == nil || !strings.Contains(err.Error(), "Markdown") {
- t.Fatalf("expected clean-cut Markdown-only plan contract, got %v", err)
- }
-}
-
-func TestMarkdownPlanRejectsMissingMultipleMalformedAndOpenQuestions(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
-
- if err := os.WriteFile(planPath, []byte("# no structured block\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckPlan(planPath); err == nil {
- t.Fatal("expected missing block to fail")
- }
-
- value, _ := MarshalJSON(validPlan())
- multiple := "# ambiguous\n\n```json\n" + string(value) + "```\n\n```json\n" + string(value) + "```\n"
- if err := os.WriteFile(planPath, []byte(multiple), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "exactly one") {
- t.Fatalf("expected multiple blocks to fail, got %v", err)
- }
-
- malformed := planMarkerStart + "\n```json\n{bad}\n```\n" + planMarkerEnd + "\n"
- if err := os.WriteFile(planPath, []byte(malformed), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "invalid structured plan json") {
- t.Fatalf("expected malformed json to fail, got %v", err)
- }
-
- plan := validPlan()
- plan["blocking_questions"] = []any{"Q-4"}
- writeMarkdownPlan(t, planPath, plan, true)
- if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "Q-4") {
- t.Fatalf("expected open material question to block, got %v", err)
- }
-}
-
-func TestExternalWritePlanRequiresSafeExplicitSideEffects(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- plan := validPlan()
- task := plan["tasks"].([]any)[0].(map[string]any)
- task["title"] = "apply database schema migration"
- task["affected_paths"] = []any{"scripts/apply_schema.py"}
- task["rollback_boundary"] = "reset local DB"
- writeMarkdownPlan(t, planPath, plan, true)
- if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "destructive rollback") {
- t.Fatalf("ambiguous destructive rollback did not block planning: %v", err)
- }
- task["rollback_boundary"] = "stop and fix forward"
- writeMarkdownPlan(t, planPath, plan, true)
- if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "structured side_effects") {
- t.Fatalf("missing external side-effect declaration did not block: %v", err)
- }
- task["side_effects"] = []any{map[string]any{
- "kind": "database-schema-write", "target": "project-ref-7f31",
- "reversibility": "transactional", "failure_policy": "rollback-transaction", "destructive": false,
- }}
- writeMarkdownPlan(t, planPath, plan, true)
- if _, err := CheckPlan(planPath); err != nil {
- t.Fatalf("safe explicit external-write plan should pass: %v", err)
- }
- task["side_effects"].([]any)[0].(map[string]any)["target"] = "local database"
- writeMarkdownPlan(t, planPath, plan, true)
- if _, err := CheckPlan(planPath); err == nil || !strings.Contains(err.Error(), "immutable target identity") {
- t.Fatalf("ambiguous external target did not block: %v", err)
- }
-}
-
-func TestReadOnlyCheckAndFailedActivationWriteNothing(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- writeActivationConfig(t, root, true)
- before, _ := os.ReadDir(root)
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- after, _ := os.ReadDir(root)
- if len(after) != len(before) {
- t.Fatalf("check-plan wrote files: before=%d after=%d", len(before), len(after))
- }
- approval := filepath.Join(root, "approval.md")
- compiled := filepath.Join(root, "compiled")
- lock := filepath.Join(root, "plan.lock.json")
- activation := ActivationOptions{Repo: root, PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lock}
- err = ActivatePlan(activation)
- if err == nil {
- t.Fatal("expected missing approval receipt to block")
- }
- writeApprovalReceipt(t, approval, "wrong-"+check.Fingerprint)
- err = ActivatePlan(activation)
- if err == nil || !strings.Contains(err.Error(), "stale approval") {
- t.Fatalf("expected invalid receipt to block, got %v", err)
- }
- if _, err := os.Stat(compiled); !os.IsNotExist(err) {
- t.Fatal("failed activation created compiled output")
- }
- if _, err := os.Stat(lock); !os.IsNotExist(err) {
- t.Fatal("failed activation created a plan lock")
- }
-}
-
-func TestApprovalReceiptRequiresMarkersHumanTimestampAndFingerprint(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- approval := filepath.Join(root, "approval.md")
- cases := []struct {
- name string
- body string
- want string
- }{
- {name: "unmarked", body: "```json\n{}\n```\n", want: "markers"},
- {name: "missing human", body: `{"schema_version":1,"status":"APPROVED","approved_by":"","approved_at":"2026-07-16T12:00:00Z","approval_fingerprint":"` + check.Fingerprint + `"}`, want: "human approver"},
- {name: "bad timestamp", body: `{"schema_version":1,"status":"APPROVED","approved_by":"Test Human","approved_at":"today","approval_fingerprint":"` + check.Fingerprint + `"}`, want: "RFC3339"},
- }
- for _, test := range cases {
- t.Run(test.name, func(t *testing.T) {
- body := test.body
- if test.name != "unmarked" {
- body = approvalMarkerStart + "\n```json\n" + body + "\n```\n" + approvalMarkerEnd + "\n"
- }
- if err := os.WriteFile(approval, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckApprovalReceipt(approval, check); err == nil || !strings.Contains(err.Error(), test.want) {
- t.Fatalf("expected %q error, got %v", test.want, err)
- }
- })
- }
-}
-
-func TestSourcePlanPreflightBlocksMissingAndEmptyFiles(t *testing.T) {
- root := t.TempDir()
- missing := filepath.Join(root, "missing.md")
- if err := CheckSourcePlan(missing); err == nil {
- t.Fatal("expected missing source plan to block")
- }
- empty := filepath.Join(root, "empty.md")
- if err := os.WriteFile(empty, []byte(" \n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CheckSourcePlan(empty); err == nil {
- t.Fatal("expected empty source plan to block")
- }
- valid := filepath.Join(root, "valid.md")
- if err := os.WriteFile(valid, []byte("# Plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CheckSourcePlan(valid); err != nil {
- t.Fatal(err)
- }
-}
-
-// TestSourcePlanRequiresExplicitPlanAndNeverScansDirectories is the conformance
-// guard for the "no ambient plan context" contract: Boatstack must never scan a
-// directory for source plans. An empty --plan blocks even when plan-shaped files
-// exist in the historically scanned locations, and only an explicit path
-// resolves.
-func TestSourcePlanRequiresExplicitPlanAndNeverScansDirectories(t *testing.T) {
- repo := t.TempDir()
- // Seed plan-shaped files in every location discovery used to scan. None of
- // these may be picked up.
- for _, dir := range []string{
- ".product-loop/intake",
- ".cursor/plans",
- ".claude/plans",
- ".codex/plans",
- } {
- absolute := filepath.Join(repo, filepath.FromSlash(dir))
- if err := os.MkdirAll(absolute, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(absolute, "stale.md"), []byte("# Stale unshipped plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
-
- if _, err := DiscoverSourcePlan(repo, ""); err == nil {
- t.Fatal("expected empty --plan to block; discovery must never scan directories")
- } else if !strings.Contains(err.Error(), "--plan") {
- t.Fatalf("expected error to require --plan, got %v", err)
- }
-
- // An explicit path is the only way to supply a source plan.
- explicitFile := filepath.Join(repo, "docs", "chosen-plan.md")
- if err := os.MkdirAll(filepath.Dir(explicitFile), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(explicitFile, []byte("# Chosen plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- explicit, err := DiscoverSourcePlan(repo, "docs/chosen-plan.md")
- if err != nil {
- t.Fatal(err)
- }
- if explicit != "docs/chosen-plan.md" {
- t.Fatalf("unexpected explicit path: %s", explicit)
- }
-}
-
-// TestSourcePlanRejectsOutsideRepoPath is the durability guard: source_plan_path
-// is recorded and re-hashed through build, so a plan outside the repository
-// (whose absolute path does not travel and is never committed) must be rejected
-// up front rather than drifting at build time.
-func TestSourcePlanRejectsOutsideRepoPath(t *testing.T) {
- parent := t.TempDir()
- repo := filepath.Join(parent, "repo")
- if err := os.MkdirAll(repo, 0o755); err != nil {
- t.Fatal(err)
- }
- // A real, non-empty plan file that lives outside the repository. It passes
- // CheckSourcePlan (it exists and is non-empty) but is not durable relative
- // to the repo.
- outside := filepath.Join(parent, "ephemeral-plan.md")
- if err := os.WriteFile(outside, []byte("# Ephemeral scratch plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := DiscoverSourcePlan(repo, outside); err == nil {
- t.Fatal("expected an out-of-repo source plan to be rejected")
- } else if !strings.Contains(err.Error(), "outside the repository") {
- t.Fatalf("expected an out-of-repo error, got %v", err)
- }
- // The same holds for a relative path that escapes the repo.
- if _, err := DiscoverSourcePlan(repo, filepath.Join("..", "ephemeral-plan.md")); err == nil {
- t.Fatal("expected a repo-escaping relative source plan to be rejected")
- }
-}
-
-// TestNoIntakeStagingReferenceInProductionSource guards against the intake
-// staging concept returning: no production Go file may reference the removed
-// .product-loop/intake staging directory.
-func TestNoIntakeStagingReferenceInProductionSource(t *testing.T) {
- entries, err := os.ReadDir(".")
- if err != nil {
- t.Fatal(err)
- }
- for _, entry := range entries {
- name := entry.Name()
- if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
- continue
- }
- contents, readErr := os.ReadFile(name)
- if readErr != nil {
- t.Fatal(readErr)
- }
- if strings.Contains(string(contents), ".product-loop/intake") {
- t.Errorf("%s still references the removed .product-loop/intake staging directory", name)
- }
- }
-}
-
-func TestCompilerRequiresSourcePlanPath(t *testing.T) {
- plan := validPlan()
- delete(plan, "source_plan_path")
- _, _, _, err := CompilePlan(plan, nil)
- if err == nil || !strings.Contains(err.Error(), "source_plan_path") {
- t.Fatalf("expected missing source plan path failure, got %v", err)
- }
-}
-
-func TestCompilerRejectsValidationWithoutOracleProvenance(t *testing.T) {
- plan := validPlan()
- task := plan["tasks"].([]any)[0].(map[string]any)
- task["validation"] = []any{map[string]any{"criteria": []any{"AC-1"}, "run": "go test ./..."}}
- _, _, _, err := CompilePlan(plan, nil)
- if err == nil || !strings.Contains(err.Error(), "origin, oracle, and independence") {
- t.Fatalf("expected validation provenance failure, got %v", err)
- }
-}
-
-func TestValidationsOnlySupportTheirMappedCriteria(t *testing.T) {
- plan := validPlan()
- plan["acceptance_criteria"] = []any{
- map[string]any{"id": "AC-1", "text": "first result"},
- map[string]any{"id": "AC-2", "text": "second result"},
- }
- task := plan["tasks"].([]any)[0].(map[string]any)
- task["acceptance_criteria"] = []any{"AC-1", "AC-2"}
- task["validation"] = []any{
- map[string]any{
- "criteria": []any{"AC-1"}, "run": "check first",
- "origin": "AC-1", "oracle": "first oracle", "independence": "pre-existing",
- },
- map[string]any{
- "criteria": []any{"AC-2"}, "run": "check second",
- "origin": "AC-2", "oracle": "second oracle", "independence": "external",
- },
- }
- _, matrix, _, err := CompilePlan(plan, nil)
- if err != nil {
- t.Fatal(err)
- }
- rows := matrix["requirements"].([]any)
- for _, item := range rows {
- row := item.(map[string]any)
- validations := row["validations"].([]any)
- if len(validations) != 1 {
- t.Fatalf("criterion %s received unrelated validations: %v", row["criterion_id"], validations)
- }
- validation := validations[0].(map[string]any)
- expected := "check first"
- if row["criterion_id"] == "AC-2" {
- expected = "check second"
- }
- if validation["check"] != expected {
- t.Fatalf("criterion %s received %v, expected %s", row["criterion_id"], validation["check"], expected)
- }
- }
-}
-
-func TestCompilerBlocksUncoveredCriterion(t *testing.T) {
- plan := validPlan()
- criteria := plan["acceptance_criteria"].([]any)
- plan["acceptance_criteria"] = append(criteria, map[string]any{"id": "AC-2", "text": "uncovered"})
- _, _, _, err := CompilePlan(plan, nil)
- if err == nil || !strings.Contains(err.Error(), "uncovered acceptance criteria") {
- t.Fatalf("expected uncovered criterion failure, got %v", err)
- }
-}
-
-func TestCompiledTaskGraphPreservesTaskFields(t *testing.T) {
- tasks, _, _, err := CompilePlan(validPlan(), nil)
- if err != nil {
- t.Fatal(err)
- }
- value, _ := json.Marshal(tasks)
- if !strings.Contains(string(value), "implement result") {
- t.Fatal("compiler dropped an approved task field")
- }
-}
diff --git a/boatstack/plan_validation.go b/boatstack/plan_validation.go
deleted file mode 100644
index 0d75248..0000000
--- a/boatstack/plan_validation.go
+++ /dev/null
@@ -1,295 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
-)
-
-// surfaceSlugPattern names a product surface (web, ops, admin-console):
-// lowercase kebab, matching the project.commands["visual:"] key
-// convention shared by plan scenarios and capability-register --surface.
-var surfaceSlugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
-
-type ValidatePlanOptions struct {
- PlanPath string
- RepoRoot string
- RepoRevision string
-}
-
-// requireVisualSurfaceEvidence makes registered surface paths a shared,
-// deterministic visual-impact input during planning. The approved decision is
-// then reused by capture, review, and ship.
-func requireVisualSurfaceEvidence(plan map[string]any, opts *ValidatePlanOptions) error {
- if opts == nil || opts.RepoRoot == "" || opts.PlanPath == "" {
- return nil
- }
- config, _, err := LoadConfig(WorkspaceFor(opts.RepoRoot).ProjectConfigPath())
- if err != nil || len(config.Project.VisualSurfaces) == 0 {
- return nil
- }
- baseline, err := productBaseline(opts.RepoRoot, opts.PlanPath)
- if err != nil {
- return err
- }
- matched := visualSurfaceChangedPaths(baseline.ChangedPaths, config.Project.VisualSurfaces)
- if len(matched) == 0 {
- return nil
- }
- decision, _ := plan["pr_visual_evidence"].(map[string]any)
- if stringValue(decision["relevance"]) != "relevant" {
- return fmt.Errorf("changes under registered visual surfaces require relevant pr_visual_evidence scenarios: %s", strings.Join(matched, ", "))
- }
- return nil
-}
-
-func visualSurfaceChangedPaths(changed []string, surfaces []VisualSurface) []string {
- seen := map[string]bool{}
- for _, changedPath := range changed {
- path := filepath.ToSlash(filepath.Clean(changedPath))
- for _, surface := range surfaces {
- for _, pattern := range surface.Paths {
- pattern = filepath.ToSlash(strings.TrimSpace(pattern))
- matched, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(path))
- prefix := strings.TrimSuffix(strings.TrimSuffix(pattern, "/**"), "/")
- if matched || (prefix != pattern && (path == prefix || strings.HasPrefix(path, prefix+"/"))) {
- seen[path] = true
- }
- }
- }
- }
- result := make([]string, 0, len(seen))
- for path := range seen {
- result = append(result, path)
- }
- sort.Strings(result)
- return result
-}
-
-func validateArchitectureGrounding(plan map[string]any, opts *ValidatePlanOptions) error {
- var ledger map[string]EvidenceRecord
- if opts != nil && opts.PlanPath != "" && opts.RepoRoot != "" {
- ledgerPath := filepath.Join(filepath.Dir(opts.PlanPath), "evidence.json")
- var err error
- ledger, err = LoadEvidenceLedger(ledgerPath)
- if err != nil {
- return err
- }
- } else {
- // In some tests or missing context cases, we initialize an empty ledger.
- ledger = make(map[string]EvidenceRecord)
- }
-
- facts, _ := objectSlice(plan["architecture_facts"])
- factIDs := make(map[string]bool)
- for _, fact := range facts {
- id := stringValue(fact["id"])
- if id == "" || factIDs[id] {
- return fmt.Errorf("architecture fact ID must be present and unique")
- }
- factIDs[id] = true
-
- kind := stringValue(fact["kind"])
- validKinds := map[string]bool{
- "route_exists": true, "route_absent": true, "symbol_exists": true,
- "component_exists": true, "test_target_exists": true, "data_access_pattern": true,
- "external_service_usage": true,
- }
- if !validKinds[kind] {
- return fmt.Errorf("unsupported architecture fact kind: %s", kind)
- }
-
- var evidence []EvidenceRecord
- evidenceLevel := EvidenceVerified
- premiseStatus := PremiseValid
- evidenceIDs, ok := stringSlice(fact["evidence_ids"])
-
- if !ok || len(evidenceIDs) == 0 {
- evidenceLevel = EvidenceAbsent
- } else {
- // Validation of evidence against ledger
- for _, evID := range evidenceIDs {
- record, exists := ledger[evID]
- if !exists {
- evidenceLevel = EvidenceAbsent
- break
- }
- evidence = append(evidence, record)
- if opts != nil && opts.RepoRevision != "" && record.RepositoryRevision != opts.RepoRevision {
- evidenceLevel = EvidenceSupported
- }
-
- // Basic operation check
- if kind == "route_absent" && record.Operation != "repository_search" && record.Operation != "route_lookup" {
- premiseStatus = PremiseInvalid
- }
-
- if opts != nil && opts.RepoRoot != "" && record.Path != "" {
- if err := ValidateEvidencePath(opts.RepoRoot, record.Path); err != nil {
- premiseStatus = PremiseInvalid
- }
- if len(record.Anchors) > 0 {
- targetPath := filepath.Join(opts.RepoRoot, filepath.Clean(record.Path))
- if err := CheckFileAnchors(targetPath, record.Anchors); err != nil {
- premiseStatus = PremiseInvalid
- }
- }
- }
- }
- }
-
- resolution := ResolvePlanDecision(PlanDecisionInput{
- DecisionKind: "architecture_fact",
- IsMaterial: false,
- RepositoryEvidence: evidence,
- EvidenceLevel: evidenceLevel,
- PremiseStatus: premiseStatus,
- })
-
- if resolution.Operator != OperatorInfer {
- return fmt.Errorf("architecture fact %s requires %s (%s): %s", id, resolution.Operator, resolution.RuleID, resolution.Reason)
- }
- }
-
- unknowns, _ := objectSlice(plan["architecture_unknowns"])
- unknownIDs := make(map[string]bool)
- for _, unk := range unknowns {
- id := stringValue(unk["id"])
- if id == "" || unknownIDs[id] {
- return fmt.Errorf("architecture unknown ID must be present and unique")
- }
- unknownIDs[id] = true
- }
-
- tasks, _ := objectSlice(plan["tasks"])
- for _, task := range tasks {
- id := stringValue(task["id"])
- reqFacts, _ := stringSlice(task["requires_facts"])
- for _, req := range reqFacts {
- if !factIDs[req] {
- return fmt.Errorf("task %s references unknown architecture fact %s", id, req)
- }
- }
-
- // check blocked by unknowns
- for _, unk := range unknowns {
- blocks, _ := stringSlice(unk["blocks"])
- for _, b := range blocks {
- if b == id {
- // unless it's a discovery task (no acceptance criteria? or named discovery)
- // Let's just say blocked by unknown means blocked.
- return fmt.Errorf("task %s is blocked by unresolved architecture unknown %s", id, stringValue(unk["id"]))
- }
- }
- }
- }
-
- return nil
-}
-
-func validatePlanAutonomy(plan map[string]any, opts *ValidatePlanOptions) error {
- if _, present := plan["autonomy_decisions"]; !present {
- return nil
- }
- var evidence map[string]EvidenceRecord
- if opts != nil && opts.PlanPath != "" {
- var err error
- evidence, err = LoadEvidenceLedger(filepath.Join(filepath.Dir(opts.PlanPath), "evidence.json"))
- if err != nil {
- return err
- }
- }
- _, err := validateAutonomyDecisions(plan, evidence)
- return err
-}
-
-func validateSystemicBoundaries(plan map[string]any) error {
- boundaries, ok := objectSlice(plan["systemic_boundaries"])
- if !ok || len(boundaries) == 0 {
- return nil
- }
-
- slices, ok := objectSlice(plan["delivery_slices"])
- if !ok || len(slices) < 2 {
- return fmt.Errorf("Programmatic enforcement requires a minimum of 2 delivery_slices (Boundary -> Feature)")
- }
-
- for _, boundary := range boundaries {
- id := stringValue(boundary["id"])
- if id == "" {
- return fmt.Errorf("systemic boundary requires an id")
- }
- if stringValue(boundary["verification_oracle"]) == "" {
- return fmt.Errorf("systemic boundary %s requires a verification_oracle", id)
- }
- }
- return nil
-}
-
-func validatePRVisualEvidence(plan map[string]any) error {
- value, present := plan["pr_visual_evidence"]
- if !present {
- return nil
- }
- decision, ok := value.(map[string]any)
- if !ok {
- return fmt.Errorf("pr_visual_evidence must be an object")
- }
- relevance := stringValue(decision["relevance"])
- if relevance != "relevant" && relevance != "not_relevant" {
- return fmt.Errorf("pr_visual_evidence.relevance must be relevant or not_relevant")
- }
- scenarios, ok := objectSlice(decision["scenarios"])
- if !ok && decision["scenarios"] != nil {
- return fmt.Errorf("pr_visual_evidence.scenarios must be a list")
- }
- if relevance == "not_relevant" {
- if strings.TrimSpace(stringValue(decision["reason"])) == "" {
- return fmt.Errorf("not-relevant pr_visual_evidence requires a reason")
- }
- if len(scenarios) != 0 {
- return fmt.Errorf("not-relevant pr_visual_evidence must not define scenarios")
- }
- return nil
- }
- if len(scenarios) == 0 || len(scenarios) > 3 {
- return fmt.Errorf("relevant pr_visual_evidence requires one to three scenarios")
- }
- seen := map[string]bool{}
- for _, scenario := range scenarios {
- id := stringValue(scenario["id"])
- if id == "" || seen[id] {
- return fmt.Errorf("pr_visual_evidence scenario ids must be present and unique")
- }
- seen[id] = true
- for _, field := range []string{"entry", "state", "viewport"} {
- if stringValue(scenario[field]) == "" {
- return fmt.Errorf("pr_visual_evidence scenario %s requires %s", id, field)
- }
- }
- if surface := stringValue(scenario["surface"]); surface != "" && !surfaceSlugPattern.MatchString(surface) {
- return fmt.Errorf("pr_visual_evidence scenario %s surface must be a lowercase kebab slug", id)
- }
- expected, ok := stringSlice(scenario["expected"])
- if !ok || len(expected) == 0 {
- return fmt.Errorf("pr_visual_evidence scenario %s requires expected visible outcomes", id)
- }
- }
- return nil
-}
-
-func requireConfiguredPRVisualEvidenceDecision(plan map[string]any, opts *ValidatePlanOptions) error {
- if opts == nil || opts.RepoRoot == "" {
- return nil
- }
- config, _, err := LoadConfig(WorkspaceFor(opts.RepoRoot).ProjectConfigPath())
- if err != nil || normalizedPRVisualEvidencePolicy(config.Workflow.PRVisualEvidence) == "off" {
- return nil
- }
- if _, present := plan["pr_visual_evidence"]; !present {
- return fmt.Errorf("configured workflow.pr_visual_evidence requires a structural plan decision")
- }
- return nil
-}
diff --git a/boatstack/plan_validation_test.go b/boatstack/plan_validation_test.go
deleted file mode 100644
index a2201c5..0000000
--- a/boatstack/plan_validation_test.go
+++ /dev/null
@@ -1,257 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-func validV2Plan() map[string]any {
- return map[string]any{
- "schema_version": float64(2),
- "feature_id": "feat-123",
- "source_plan_path": "source-plan.md",
- "blocking_questions": []any{},
- "acceptance_criteria": []any{
- map[string]any{"id": "AC-1", "text": "Something works"},
- },
- "architecture_facts": []any{
- map[string]any{
- "id": "fact_1",
- "kind": "route_absent",
- "subject": "/api/clients",
- "evidence_ids": []any{"ev_1"},
- },
- },
- "architecture_unknowns": []any{},
- "tasks": []any{
- map[string]any{
- "id": "T-1",
- "title": "Do work",
- "depends_on": []any{},
- "requires_facts": []any{"fact_1"},
- "acceptance_criteria": []any{"AC-1"},
- "validation": []any{
- map[string]any{
- "criteria": []any{"AC-1"},
- "run": "test",
- "origin": "origin",
- "oracle": "oracle",
- "independence": "independence",
- },
- },
- "rollback_boundary": "revert",
- },
- },
- "delivery_slices": []any{
- map[string]any{
- "id": "slice-1",
- "task_ids": []any{"T-1"},
- },
- },
- }
-}
-
-func TestValidatePlanV2FactMissingEvidenceID(t *testing.T) {
- plan := validV2Plan()
- facts := plan["architecture_facts"].([]any)
- facts[0].(map[string]any)["evidence_ids"] = []any{"missing_ev"}
-
- opts := &ValidatePlanOptions{PlanPath: "plan.md", RepoRoot: ""}
-
- err := validateArchitectureGrounding(plan, opts)
- if err == nil || !strings.Contains(err.Error(), "requires escalate") || !strings.Contains(err.Error(), "unresolved uncertainty requires escalation") {
- t.Fatalf("expected escalate error due to absent evidence, got: %v", err)
- }
-}
-
-func TestValidatePlanV2BlocksUnresolvedUnknowns(t *testing.T) {
- plan := validV2Plan()
- plan["architecture_facts"] = []any{}
- plan["tasks"].([]any)[0].(map[string]any)["requires_facts"] = []any{}
- plan["architecture_unknowns"] = []any{
- map[string]any{
- "id": "unk_1",
- "question": "How?",
- "blocks": []any{"T-1"},
- },
- }
-
- opts := &ValidatePlanOptions{PlanPath: "plan.md", RepoRoot: ""}
- err := validateArchitectureGrounding(plan, opts)
- t.Logf("DEBUG plan architecture_facts: %#v", plan["architecture_facts"])
- if err == nil || !strings.Contains(err.Error(), "blocked by unresolved architecture unknown") {
- t.Fatalf("expected blocked by unknown error, got: %v", err)
- }
-}
-
-func TestValidatePlanV2EvidencePathSafety(t *testing.T) {
- // We can't easily mock load ledger without changing the signature, but validateArchitectureGrounding
- // currently uses LoadEvidenceLedger which reads from disk.
- // For testing, we might need a way to inject the ledger or we can test the ValidateEvidencePath directly.
- err := ValidateEvidencePath("/repo", "../../../outside.txt")
- if err == nil || !strings.Contains(err.Error(), "outside repository") {
- t.Fatalf("expected path traversal error, got %v", err)
- }
-
- absPath := "/etc/passwd"
- if runtime.GOOS == "windows" {
- absPath = "C:\\Windows\\System32\\cmd.exe"
- }
- err = ValidateEvidencePath("/repo", absPath)
- if err == nil || !strings.Contains(err.Error(), "absolute") {
- t.Fatalf("expected absolute path error, got %v", err)
- }
-}
-
-func TestPLAN_INVALIDExitsRepair(t *testing.T) {
- // A simple test ensuring classification plan_invalid routes to AUTO_PLAN and PLAN_INVALID mode
- opts := ChangeObservationOptions{
- Feature: "feat",
- Message: "message",
- SourceStage: "BUILD",
- Classification: "plan_invalid",
- }
- // just testing the map in RecordChangeObservation
- classification := strings.ToLower(strings.TrimSpace(opts.Classification))
- resume := map[string]string{
- "plan_invalid": "AUTO_PLAN",
- }[classification]
- if resume != "AUTO_PLAN" {
- t.Fatalf("expected plan_invalid to map to AUTO_PLAN, got %v", resume)
- }
-}
-
-func TestValidateSystemicBoundaries(t *testing.T) {
- plan := validV2Plan()
-
- // Valid configuration
- plan["systemic_boundaries"] = []any{
- map[string]any{
- "id": "bnd_1",
- "verification_oracle": "Negative test confirming boundary blocks invalid input",
- },
- }
- plan["delivery_slices"] = []any{
- map[string]any{"id": "slice_1", "task_ids": []any{"task_1"}},
- map[string]any{"id": "slice_2", "task_ids": []any{"task_2"}},
- }
- if err := validateSystemicBoundaries(plan); err != nil {
- t.Fatalf("expected valid systemic boundaries, got: %v", err)
- }
-
- // Missing oracle
- plan["systemic_boundaries"] = []any{
- map[string]any{
- "id": "bnd_1",
- },
- }
- if err := validateSystemicBoundaries(plan); err == nil || !strings.Contains(err.Error(), "requires a verification_oracle") {
- t.Fatalf("expected error for missing oracle, got: %v", err)
- }
-
- // Missing ID
- plan["systemic_boundaries"] = []any{
- map[string]any{
- "verification_oracle": "Negative test",
- },
- }
- if err := validateSystemicBoundaries(plan); err == nil || !strings.Contains(err.Error(), "requires an id") {
- t.Fatalf("expected error for missing id, got: %v", err)
- }
-
- // Missing multiple slices
- plan["systemic_boundaries"] = []any{
- map[string]any{
- "id": "bnd_1",
- "verification_oracle": "Negative test",
- },
- }
- plan["delivery_slices"] = []any{
- map[string]any{"id": "slice_1", "task_ids": []any{"task_1"}},
- }
- if err := validateSystemicBoundaries(plan); err == nil || !strings.Contains(err.Error(), "requires a minimum of 2 delivery_slices") {
- t.Fatalf("expected error for insufficient slices, got: %v", err)
- }
-}
-
-func TestValidatePRVisualEvidence(t *testing.T) {
- plan := validV2Plan()
- plan["pr_visual_evidence"] = map[string]any{
- "relevance": "relevant",
- "scenarios": []any{map[string]any{
- "id": "warning", "entry": "/onboarding", "state": "picker open", "viewport": "1440x900",
- "expected": []any{"warning visible"},
- }},
- }
- if err := validatePRVisualEvidence(plan); err != nil {
- t.Fatal(err)
- }
- plan["pr_visual_evidence"].(map[string]any)["scenarios"] = []any{}
- if err := validatePRVisualEvidence(plan); err == nil || !strings.Contains(err.Error(), "one to three") {
- t.Fatalf("empty relevant scenarios were not rejected: %v", err)
- }
- plan["pr_visual_evidence"] = map[string]any{
- "relevance": "relevant",
- "scenarios": []any{map[string]any{
- "id": "warning", "entry": "/onboarding", "state": "picker open", "viewport": "1440x900",
- "expected": []any{"warning visible"}, "surface": "admin-console",
- }},
- }
- if err := validatePRVisualEvidence(plan); err != nil {
- t.Fatalf("valid surface slug was rejected: %v", err)
- }
- plan["pr_visual_evidence"].(map[string]any)["scenarios"].([]any)[0].(map[string]any)["surface"] = "Web Ops"
- if err := validatePRVisualEvidence(plan); err == nil || !strings.Contains(err.Error(), "surface") {
- t.Fatalf("invalid surface slug was not rejected: %v", err)
- }
- plan["pr_visual_evidence"] = map[string]any{"relevance": "not_relevant", "scenarios": []any{}}
- if err := validatePRVisualEvidence(plan); err == nil || !strings.Contains(err.Error(), "reason") {
- t.Fatalf("missing not-relevant reason was not rejected: %v", err)
- }
- plan["pr_visual_evidence"] = map[string]any{"relevance": "not_relevant", "reason": " ", "scenarios": []any{}}
- if err := validatePRVisualEvidence(plan); err == nil || !strings.Contains(err.Error(), "reason") {
- t.Fatalf("whitespace-only not-relevant reason was not rejected: %v", err)
- }
-}
-
-func TestConfiguredPRVisualEvidenceRequiresPlanDecision(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- plan := validV2Plan()
- if err := requireConfiguredPRVisualEvidenceDecision(plan, &ValidatePlanOptions{RepoRoot: repo}); err == nil || !strings.Contains(err.Error(), "structural plan decision") {
- t.Fatalf("configured policy did not require a decision: %v", err)
- }
- plan["pr_visual_evidence"] = map[string]any{"relevance": "not_relevant", "reason": "backend-only", "scenarios": []any{}}
- if err := requireConfiguredPRVisualEvidenceDecision(plan, &ValidatePlanOptions{RepoRoot: repo}); err != nil {
- t.Fatal(err)
- }
-}
-
-// control-law: registered-visual-surfaces-share-one-impact-decision
-func TestRegisteredVisualSurfaceChangeRequiresRelevantDecision(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- config.Project.VisualSurfaces = []VisualSurface{{ID: "web", Paths: []string{"apps/web/**"}}}
- })
- path := filepath.Join(repo, "apps", "web", "page.tsx")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("export default function Page() {}\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- plan := validV2Plan()
- plan["pr_visual_evidence"] = map[string]any{"relevance": "not_relevant", "reason": "no visual change", "scenarios": []any{}}
- opts := &ValidatePlanOptions{RepoRoot: repo, PlanPath: filepath.Join(repo, ".product-loop", "features", "demo", "plan.md")}
- if err := requireVisualSurfaceEvidence(plan, opts); err == nil || !strings.Contains(err.Error(), "apps/web/page.tsx") {
- t.Fatalf("registered surface change did not trigger evidence: %v", err)
- }
- plan["pr_visual_evidence"] = map[string]any{"relevance": "relevant", "scenarios": []any{map[string]any{"id": "web", "entry": "/", "state": "ready", "viewport": "1440x900", "expected": []any{"page visible"}}}}
- if err := requireVisualSurfaceEvidence(plan, opts); err != nil {
- t.Fatal(err)
- }
-}
diff --git a/boatstack/planning.go b/boatstack/planning.go
deleted file mode 100644
index e4eac4c..0000000
--- a/boatstack/planning.go
+++ /dev/null
@@ -1,623 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
- "time"
- "unicode/utf8"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-var featureSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
-
-var planningArtifacts = map[string]bool{
- "source-plan.md": true,
- "feature-spec.md": true,
- "questions.md": true,
- "gaps.md": true,
- "test-plan.md": true,
- "plan.md": true,
-}
-
-// planningArtifactNames returns the accepted artifact tokens, sorted, for error
-// messages. Discoverability: a rejection that gates on a closed domain must name
-// the accepted set at the point of failure. The tokens are filenames carrying a
-// .md suffix, so the natural guess ("plan") is always wrong — listing them (and the
-// suffix) is what turns a dead-end rejection into an actionable one.
-func planningArtifactNames() []string {
- names := make([]string, 0, len(planningArtifacts))
- for name := range planningArtifacts {
- names = append(names, name)
- }
- sort.Strings(names)
- return names
-}
-
-type PlanningWriteOptions struct {
- Repo string
- Feature string
- Artifact string
- Content []byte
- SourcePlan string
- SourcePlanSHA256 string
- ExpectedLifecycleSHA256 string
- ExpectedPlanLockSHA256 string
- ExpectedObservation string
-}
-
-type ApprovalRecordOptions struct {
- Repo string
- PlanPath string
- OutputPath string
- ApprovedBy string
- ApprovedAt string
- Fingerprint string
- BaselineDiffSHA256 string
- ExpectedLifecycleSHA256 string
- ExpectedPlanLockSHA256 string
- ExpectedObservation string
-}
-
-type PlanningBaseline struct {
- DiffSHA256 string
- ChangedPaths []string
-}
-
-// Tests may replace this seam. Nil selects the production pure installation
-// health check immediately before the first feature artifact can be written.
-var planningInstallationHealth func(string) error
-
-func relativeBaselineExclusions(repo string, paths ...string) map[string]bool {
- excluded := map[string]bool{}
- for _, path := range paths {
- if strings.TrimSpace(path) == "" {
- continue
- }
- absolute := path
- if !filepath.IsAbs(absolute) {
- absolute = filepath.Join(repo, absolute)
- }
- if canonicalParent, err := filepath.EvalSymlinks(filepath.Dir(absolute)); err == nil {
- absolute = filepath.Join(canonicalParent, filepath.Base(absolute))
- }
- if relative, err := filepath.Rel(repo, filepath.Clean(absolute)); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
- excluded[filepath.ToSlash(relative)] = true
- }
- }
- return excluded
-}
-
-func productBaseline(repo string, artifactPaths ...string) (PlanningBaseline, error) {
- excluded := relativeBaselineExclusions(repo, artifactPaths...)
- baselineRef := "HEAD"
- if err := exec.Command("git", "-C", repo, "rev-parse", "--verify", "HEAD").Run(); err != nil {
- emptyTree := exec.Command("git", "-C", repo, "hash-object", "-t", "tree", "--stdin")
- emptyTree.Stdin = strings.NewReader("")
- value, hashErr := emptyTree.Output()
- if hashErr != nil {
- return PlanningBaseline{}, fmt.Errorf("resolve empty repository baseline: %w", hashErr)
- }
- baselineRef = strings.TrimSpace(string(value))
- }
- ignored := func(path string) bool {
- path = filepath.ToSlash(path)
- if path == ".product-loop" || strings.HasPrefix(path, ".product-loop/") || excluded[path] {
- return true
- }
- for prefix := range excluded {
- if strings.HasPrefix(path, strings.TrimSuffix(prefix, "/")+"/") {
- return true
- }
- }
- return false
- }
- changedCommand := exec.Command("git", "-C", repo, "diff", "--name-only", "-z", baselineRef, "--")
- changedValue, err := changedCommand.Output()
- if err != nil {
- return PlanningBaseline{}, fmt.Errorf("inspect tracked planning baseline: %w", err)
- }
- paths := []string{}
- for _, raw := range bytes.Split(changedValue, []byte{0}) {
- path := filepath.ToSlash(string(raw))
- if path != "" && !ignored(path) {
- paths = append(paths, path)
- }
- }
- untrackedCommand := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard", "-z", "--")
- untrackedValue, err := untrackedCommand.Output()
- if err != nil {
- return PlanningBaseline{}, fmt.Errorf("inspect untracked planning baseline: %w", err)
- }
- untracked := []string{}
- for _, raw := range bytes.Split(untrackedValue, []byte{0}) {
- path := filepath.ToSlash(string(raw))
- if path != "" && !ignored(path) {
- paths = append(paths, path)
- untracked = append(untracked, path)
- }
- }
- sort.Strings(paths)
- if len(paths) == 0 {
- return PlanningBaseline{ChangedPaths: []string{}}, nil
- }
- args := []string{"-C", repo, "diff", "--binary", "--no-ext-diff", baselineRef, "--", ".", ":(exclude).product-loop/**"}
- for path := range excluded {
- args = append(args, ":(exclude)"+path)
- }
- diffValue, err := exec.Command("git", args...).Output()
- if err != nil {
- return PlanningBaseline{}, fmt.Errorf("render tracked planning baseline: %w", err)
- }
- var canonical bytes.Buffer
- canonical.Write(diffValue)
- sort.Strings(untracked)
- for _, path := range untracked {
- absolute := filepath.Join(repo, filepath.FromSlash(path))
- info, statErr := os.Lstat(absolute)
- if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
- return PlanningBaseline{}, fmt.Errorf("untracked planning baseline path is not a regular non-symlink file: %s", path)
- }
- value, readErr := os.ReadFile(absolute)
- if readErr != nil {
- return PlanningBaseline{}, fmt.Errorf("read untracked planning baseline path %s: %w", path, readErr)
- }
- fmt.Fprintf(&canonical, "\nuntracked %s %s\n", path, SHA256Bytes(value))
- }
- return PlanningBaseline{DiffSHA256: SHA256Bytes(canonical.Bytes()), ChangedPaths: paths}, nil
-}
-
-func PlanningBaselineForPlan(planPath string) (PlanningBaseline, error) {
- repo, err := ResolveControllerRepository(filepath.Dir(planPath))
- if err != nil {
- return PlanningBaseline{}, err
- }
- return PlanningBaselineForRepository(repo, planPath)
-}
-
-func PlanningBaselineForRepository(repoPath, planPath string) (PlanningBaseline, error) {
- repo, err := ResolveControllerRepositoryFor(repoPath, filepath.Dir(planPath))
- if err != nil {
- return PlanningBaseline{}, err
- }
- check, err := CheckPlanForRepository(repo, planPath)
- if err != nil {
- return PlanningBaseline{}, err
- }
- return productBaseline(repo, planPath, check.SourcePlanPath, check.SpecPath, filepath.Join(filepath.Dir(planPath), "approval.md"))
-}
-
-func rejectSymlinkComponents(root, target string) error {
- relative, err := filepath.Rel(root, target)
- if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
- return fmt.Errorf("path escapes repository boundary: %s", target)
- }
- current := root
- for _, component := range strings.Split(relative, string(filepath.Separator)) {
- current = filepath.Join(current, component)
- info, statErr := os.Lstat(current)
- if os.IsNotExist(statErr) {
- continue
- }
- if statErr != nil {
- return statErr
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("refusing symlinked path: %s", current)
- }
- }
- return nil
-}
-
-func atomicWrite(path string, content []byte) error {
- directory := filepath.Dir(path)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- return err
- }
- temporary, err := os.CreateTemp(directory, ".boatstack-planning-*")
- if err != nil {
- return err
- }
- temporaryPath := temporary.Name()
- defer os.Remove(temporaryPath)
- if err := temporary.Chmod(0o644); err != nil {
- temporary.Close()
- return err
- }
- if _, err := temporary.Write(content); err != nil {
- temporary.Close()
- return err
- }
- if err := temporary.Sync(); err != nil {
- temporary.Close()
- return err
- }
- if err := temporary.Close(); err != nil {
- return err
- }
- return replaceFile(temporaryPath, path)
-}
-
-func WritePlanningArtifact(options PlanningWriteOptions) (string, error) {
- if !featureSlugPattern.MatchString(options.Feature) {
- return "", fmt.Errorf("feature must be a lowercase kebab-case slug")
- }
- if !planningArtifacts[options.Artifact] {
- return "", fmt.Errorf("unsupported planning artifact %q; use one of: %s (note the .md suffix)", options.Artifact, strings.Join(planningArtifactNames(), ", "))
- }
- // Windows PowerShell 5.1 may prepend the UTF-8 byte-order mark and serialize
- // line endings as CRLF when a here-string is piped to a native command even
- // when $OutputEncoding uses a no-BOM encoder. Treat those transport signatures
- // as encoding metadata, not Markdown content, so every supported shell
- // produces the same artifact.
- content := normalizePlanningTransportBytes(options.Content)
- if !utf8.Valid(content) {
- return "", fmt.Errorf("planning artifact must be valid UTF-8 Markdown")
- }
- if strings.TrimSpace(string(content)) == "" {
- return "", fmt.Errorf("planning artifact must not be empty")
- }
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return "", err
- }
- healthCheck := CheckInstallationHealth
- if planningInstallationHealth != nil {
- healthCheck = planningInstallationHealth
- }
- if err := healthCheck(repo); err != nil {
- return "", fmt.Errorf("planning write requires a healthy Boatstack installation: %w", DoctorRepairHint(err))
- }
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return "", err
- }
- statePath, err := deliveryStatePath(repo, options.Feature)
- if err != nil {
- return "", err
- }
- hasManagedDelivery := fileExists(statePath)
- hasLifecycleEvidence := strings.TrimSpace(options.ExpectedLifecycleSHA256) != "" ||
- strings.TrimSpace(options.ExpectedPlanLockSHA256) != "" ||
- strings.TrimSpace(options.ExpectedObservation) != ""
- if hasManagedDelivery {
- if strings.TrimSpace(options.ExpectedLifecycleSHA256) == "" || strings.TrimSpace(options.ExpectedPlanLockSHA256) == "" {
- return "", fmt.Errorf("an active delivery planning write requires a lifecycle-bound flow bootstrap prescription")
- }
- snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, options.Feature)
- if snapshotErr != nil {
- return "", snapshotErr
- }
- if !amendmentLifecycleState(snapshot.State) {
- return "", fmt.Errorf("active delivery %s is not in an amendment planning state", options.Feature)
- }
- if snapshot.Fingerprint != strings.TrimSpace(options.ExpectedLifecycleSHA256) ||
- snapshot.PlanLockSHA256 != strings.TrimSpace(options.ExpectedPlanLockSHA256) ||
- snapshot.ObservationID != strings.TrimSpace(options.ExpectedObservation) {
- return "", fmt.Errorf("active delivery lifecycle changed after bootstrap; resolve a fresh flow bootstrap prescription")
- }
- } else if hasLifecycleEvidence {
- return "", fmt.Errorf("lifecycle-bound planning evidence does not match an active managed delivery")
- }
- featureDirectory := ctx.FeatureDir(options.Feature)
- _, featureErr := os.Lstat(featureDirectory)
- firstWrite := os.IsNotExist(featureErr)
- if featureErr != nil && !firstWrite {
- return "", featureErr
- }
- hasSourceEvidence := strings.TrimSpace(options.SourcePlan) != "" || strings.TrimSpace(options.SourcePlanSHA256) != ""
- if firstWrite && !hasSourceEvidence {
- return "", fmt.Errorf("a new feature requires a flow bootstrap prescription with current source-plan evidence")
- }
- if hasSourceEvidence {
- if strings.TrimSpace(options.SourcePlan) == "" || strings.TrimSpace(options.SourcePlanSHA256) == "" {
- return "", fmt.Errorf("source-plan path and SHA-256 must be supplied together")
- }
- sourcePlan, discoverErr := DiscoverSourcePlan(repo, options.SourcePlan)
- if discoverErr != nil {
- return "", discoverErr
- }
- sourceAbsolute := filepath.Join(repo, filepath.FromSlash(sourcePlan))
- if rejectErr := rejectSymlinkComponents(repo, sourceAbsolute); rejectErr != nil {
- return "", fmt.Errorf("source plan must be a regular in-repository file without symlink indirection: %w", rejectErr)
- }
- currentSHA, hashErr := SHA256File(sourceAbsolute)
- if hashErr != nil {
- return "", hashErr
- }
- if currentSHA != strings.TrimSpace(options.SourcePlanSHA256) {
- return "", fmt.Errorf("source plan changed after bootstrap; resolve a fresh flow bootstrap prescription")
- }
- }
- destination := filepath.Join(ctx.FeatureDir(options.Feature), options.Artifact)
- if err := rejectSymlinkComponents(ctx.ExportRoot(), destination); err != nil {
- return "", err
- }
- if err := atomicWrite(destination, content); err != nil {
- return "", err
- }
- relative, err := filepath.Rel(ctx.ExportRoot(), destination)
- if err != nil {
- return "", err
- }
- return filepath.ToSlash(relative), nil
-}
-
-func normalizePlanningTransportBytes(content []byte) []byte {
- content = bytes.TrimPrefix(content, []byte{0xef, 0xbb, 0xbf})
- return bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
-}
-
-func RecordApproval(options ApprovalRecordOptions) error {
- if strings.TrimSpace(options.ApprovedBy) == "" {
- return fmt.Errorf("approval requires a named human")
- }
- approvedAt, err := time.Parse(time.RFC3339, options.ApprovedAt)
- if err != nil {
- return fmt.Errorf("approval timestamp must be RFC3339")
- }
- if strings.TrimSpace(options.Repo) == "" {
- return fmt.Errorf("approval requires the invoking repository context")
- }
- repo, err := ResolveControllerRepositoryFor(options.Repo, filepath.Dir(options.PlanPath))
- if err != nil {
- return err
- }
- check, err := CheckPlanForRepository(repo, options.PlanPath)
- if err != nil {
- return err
- }
- if options.Fingerprint != check.Fingerprint {
- return fmt.Errorf("approval fingerprint does not match the current plan; the plan now fingerprints as %s — re-approve against that value (run check-plan to confirm)", check.Fingerprint)
- }
- feature := strings.TrimSpace(stringValue(check.Plan["feature_id"]))
- statePath, statePathErr := deliveryStatePath(repo, feature)
- if statePathErr != nil {
- return statePathErr
- }
- hasLifecycleEvidence := strings.TrimSpace(options.ExpectedLifecycleSHA256) != "" ||
- strings.TrimSpace(options.ExpectedPlanLockSHA256) != "" ||
- strings.TrimSpace(options.ExpectedObservation) != ""
- if fileExists(statePath) {
- snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, feature)
- if snapshotErr != nil {
- return snapshotErr
- }
- if snapshot.State != deliverycontrol.StateAmendmentDrafted {
- return fmt.Errorf("active delivery approval requires a drafted amendment, got %s", snapshot.State)
- }
- if strings.TrimSpace(options.ExpectedLifecycleSHA256) == "" || strings.TrimSpace(options.ExpectedPlanLockSHA256) == "" {
- return fmt.Errorf("active delivery approval requires current lifecycle and plan-lock fingerprints")
- }
- if snapshot.Fingerprint != strings.TrimSpace(options.ExpectedLifecycleSHA256) ||
- snapshot.PlanLockSHA256 != strings.TrimSpace(options.ExpectedPlanLockSHA256) ||
- snapshot.ObservationID != strings.TrimSpace(options.ExpectedObservation) {
- return fmt.Errorf("active delivery lifecycle changed before approval; resolve flow next again")
- }
- } else if hasLifecycleEvidence {
- return fmt.Errorf("lifecycle-bound approval evidence does not match an active managed delivery")
- }
- expectedOutput := filepath.Join(filepath.Dir(options.PlanPath), "approval.md")
- output := options.OutputPath
- if output == "" {
- output = expectedOutput
- }
- expectedAbsolute, err := filepath.Abs(expectedOutput)
- if err != nil {
- return err
- }
- outputAbsolute, err := filepath.Abs(output)
- if err != nil {
- return err
- }
- if filepath.Clean(outputAbsolute) != filepath.Clean(expectedAbsolute) {
- return fmt.Errorf("approval receipt must be written beside plan.md as approval.md")
- }
- planDirectory, err := filepath.Abs(filepath.Dir(options.PlanPath))
- if err != nil {
- return err
- }
- if err := rejectSymlinkComponents(planDirectory, outputAbsolute); err != nil {
- return err
- }
- baseline, err := productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, outputAbsolute)
- if err != nil {
- return err
- }
- if baseline.DiffSHA256 != strings.TrimSpace(options.BaselineDiffSHA256) {
- if baseline.DiffSHA256 != "" && strings.TrimSpace(options.BaselineDiffSHA256) == "" {
- return fmt.Errorf("approval requires the displayed baseline-diff fingerprint because product edits existed when planning began")
- }
- return fmt.Errorf("baseline product diff drifted after it was displayed")
- }
- schemaVersion := 2
- payloadValue := map[string]any{
- "schema_version": schemaVersion,
- "status": "APPROVED",
- "approved_by": strings.TrimSpace(options.ApprovedBy),
- "approved_at": approvedAt.Format(time.RFC3339),
- "approval_fingerprint": check.Fingerprint,
- "baseline_diff_sha256": baseline.DiffSHA256,
- "baseline_changed_paths": baseline.ChangedPaths,
- }
- if hasLifecycleEvidence {
- payloadValue["lifecycle_sha256"] = strings.TrimSpace(options.ExpectedLifecycleSHA256)
- payloadValue["plan_lock_sha256"] = strings.TrimSpace(options.ExpectedPlanLockSHA256)
- payloadValue["observation_id"] = strings.TrimSpace(options.ExpectedObservation)
- }
- if version, _ := check.Plan["schema_version"].(float64); version >= 3 {
- readiness, readinessErr := checkPlanReadiness(repo, options.PlanPath)
- if readinessErr != nil {
- return readinessErr
- }
- payloadValue["schema_version"] = 3
- payloadValue["readiness_fingerprint"] = readiness.Fingerprint
- payloadValue["base_branch"] = readiness.BaseBranch
- payloadValue["head_branch"] = readiness.HeadBranch
- payloadValue["base_commit"] = readiness.BaseCommit
- payloadValue["head_commit"] = readiness.HeadCommit
- payloadValue["upstream"] = readiness.Upstream
- payloadValue["upstream_relation"] = readiness.Relation
- payloadValue["journey_manifest_sha256"] = readiness.JourneyManifestSHA256
- }
- payload, err := MarshalJSON(payloadValue)
- if err != nil {
- return err
- }
- body := "# Plan approval\n\n" + approvalMarkerStart + "\n```json\n" + strings.TrimSpace(string(payload)) + "\n```\n" + approvalMarkerEnd + "\n"
- return atomicWrite(outputAbsolute, []byte(body))
-}
-
-type installLock struct {
- BoatstackVersion string `json:"boatstack_version"`
- SourceCommit string `json:"source_commit"`
- Platform string `json:"platform"`
- BinaryPath string `json:"binary_path"`
- BinarySHA256 string `json:"binary_sha256"`
- Integrations map[string]IntegrationState `json:"integrations,omitempty"`
-}
-
-// CheckInstallationHealth validates installed and generated state without
-// changing repository, runtime, or bookkeeping state.
-func CheckInstallationHealth(repoPath string) error {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return err
- }
- topology, err := RequireManagedConfiguration(repo)
- if err != nil {
- return err
- }
- if topology.Shape == ConfigShapeHybrid {
- repositoryConfig, repositoryRaw, loadErr := LoadConfig(topology.RepositorySourcePath)
- if loadErr != nil {
- return fmt.Errorf("invalid repository Boatstack configuration: %w", loadErr)
- }
- repositoryBundle, buildErr := BuildExportBundle(topology.RepositorySourcePath, repositoryConfig, embeddedConfigBytes(repositoryRaw), "boatstack")
- if buildErr != nil {
- return buildErr
- }
- if checkErr := CheckExport(topology.RepositoryBundleRoot, repositoryBundle.Files); checkErr != nil {
- return fmt.Errorf("repository Boatstack package is stale: %w", checkErr)
- }
- }
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return err
- }
- configPath := ctx.SourceConfigPath()
- config, raw, err := LoadConfig(configPath)
- if err != nil {
- return fmt.Errorf("invalid or missing .boatstack-project.json: %w", err)
- }
- configBytes := raw
- if ctx.Mode == SupervisionEmbedded {
- configBytes = embeddedConfigBytes(raw)
- }
- bundle, err := BuildExportBundle(configPath, config, configBytes, "boatstack")
- if err != nil {
- return err
- }
- if err := CheckExport(ctx.ExportRoot(), bundle.Files); err != nil {
- return err
- }
- // Embedded installations own merged host settings in the repository and can
- // verify them here. Detached controller state owns generated engagement probes;
- // developer-level host activation is a separate, operator-visible boundary.
- // CheckExport above verifies those fragments without misreading them as merged
- // .cursor/.claude/.codex/.gemini configurations.
- if ctx.Mode == SupervisionEmbedded {
- if err := CheckHostHooks(ctx.HostActivationRoot(), config.Adapters); err != nil {
- return err
- }
- }
- hostAdapters := normalizedAdapters(config.Adapters)
- if contains(hostAdapters, "claude") {
- if _, err := lookPath("bash"); err != nil {
- return fmt.Errorf("Claude Code engagement probes require Bash; install Git Bash or Bash, then rerun doctor")
- }
- }
- if err := verifyGeneratedRuntime(ctx.ExportRoot()); err != nil {
- return err
- }
- if _, _, err := loadSharedRuntime(repo); err != nil {
- return err
- }
- for _, host := range []string{"cursor", "claude", "codex"} {
- if !contains(hostAdapters, host) {
- continue
- }
- inputs := [][]byte{}
- if host == "cursor" {
- inputs = append(inputs,
- []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`),
- []byte(`{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__status__read","tool_input":"{\"scope\":\"local\"}","command":"status-server"}`),
- )
- } else {
- inputs = append(inputs, []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`))
- }
- for _, input := range inputs {
- if _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: input}); denied {
- return fmt.Errorf("%s engagement probe denied its read-only smoke event", host)
- }
- }
- _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(`{"malformed":true}`)})
- engaged := ResolveEngagement(repo, EngagementRequest{}).Mode == EngagementActive
- if engaged != denied {
- return fmt.Errorf("%s engagement probe contract drifted: active=%t denied=%t", host, engaged, denied)
- }
- }
- return verifyLocalRuntime(ctx.ExportRoot())
-}
-
-func Doctor(repoPath string) error {
- if err := CheckInstallationHealth(repoPath); err != nil {
- return err
- }
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return err
- }
- // Doctor keeps its legacy best-effort hygiene, but the preflight health
- // boundary above remains pure.
- pruneLegacyOperationLedger(repo)
- return nil
-}
-
-func DoctorHookHosts(repoPath string) ([]string, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return nil, err
- }
- config, _, err := LoadConfig(WorkspaceFor(repo).SourceConfigPath())
- if err != nil {
- return nil, err
- }
- hosts := []string{}
- for _, host := range []string{"cursor", "claude", "codex"} {
- if contains(normalizedAdapters(config.Adapters), host) {
- hosts = append(hosts, host)
- }
- }
- return hosts, nil
-}
-
-func DoctorRepairHint(err error) error {
- if err == nil {
- return nil
- }
- errStr := err.Error()
- if strings.Contains(errStr, "config schema is behind") {
- return fmt.Errorf("%s; remediation: run /boatstack-update to migrate project configuration", errStr)
- }
- if strings.Contains(errStr, "config was written by a newer Boatstack") {
- return fmt.Errorf("%s; remediation: update your Boatstack installation to load this configuration", errStr)
- }
- return fmt.Errorf("%w; repair: rerun the verified Boatstack installer once from any checkout in this Git clone, then reload the coding host", err)
-}
diff --git a/boatstack/planning_first_write_conformance_test.go b/boatstack/planning_first_write_conformance_test.go
deleted file mode 100644
index 9790f24..0000000
--- a/boatstack/planning_first_write_conformance_test.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package boatstack
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// control-law: first-planning-write-uses-the-owned-channel
-//
-// No host-raw byte ever lands in .product-loop/features/ — before the first
-// plan candidate exists or after. Previously the guard latched only once a
-// (possibly malformed) draft registered, so the very first raw Write/cp of a
-// planning artifact was allowed and the agent discovered planning-write only
-// by failing into INVALID_STATE and a quarantine loop. The invariant these
-// tests hold: the managed planning tree is authored exclusively through the
-// owned channel at every stage; the deny is path-scoped (ordinary product
-// writes stay unlatched at zero candidates); and the denial names the verb
-// the guard itself admits at that stage (Coreachability).
-
-// Positive: the owned channel clears the cause the denial reports — the
-// helper verbs pass the guard at zero candidates, and planning-write actually
-// creates the artifact.
-func TestFirstPlanningWriteOwnedChannelStaysOpen(t *testing.T) {
- repo := safetyTestRepo(t)
- previousHealth := planningInstallationHealth
- planningInstallationHealth = func(string) error { return nil }
- t.Cleanup(func() { planningInstallationHealth = previousHealth })
- sourceSHA, err := SHA256File(filepath.Join(repo, "README.md"))
- if err != nil {
- t.Fatal(err)
- }
-
- for _, command := range []string{
- ".product-loop/boatstack planning-write --repo . --feature checkout --artifact plan.md --source-plan README.md --source-plan-sha256 " + sourceSHA + " <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n",
- "boatstack-helper check-source-plan --repo . --plan docs/plan.md",
- } {
- if findings := ClassifyCommand(repo, command); len(findings) > 0 {
- t.Fatalf("owned channel denied at zero candidates: %q -> %#v", command, findings)
- }
- }
-
- written, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{
- Repo: repo, Feature: "checkout", Artifact: "source-plan.md",
- Content: []byte("# Source plan\n"),
- }))
- if err != nil {
- t.Fatalf("planning-write must author the first artifact: %v", err)
- }
- if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(written))); err != nil {
- t.Fatalf("owned write did not land: %v", err)
- }
-}
-
-// Negative: the first raw write is denied with the exact finding — category,
-// source, stage, prescribed verb, and the slug parsed from the path — at any
-// depth and for any name under the planning tree.
-func TestFirstRawPlanningWriteIsDenied(t *testing.T) {
- repo := safetyTestRepo(t)
-
- finding := func(path string) SafetyFinding {
- findings := ClassifyTool(repo, "Write", map[string]any{
- "file_path": filepath.Join(repo, filepath.FromSlash(path)),
- "content": "# Draft\n",
- })
- if len(findings) == 0 {
- t.Fatalf("raw write of %q must be denied at zero candidates", path)
- }
- return findings[0]
- }
-
- got := finding(".product-loop/features/checkout/plan.md")
- if got.Category != "workflow-phase-bypass" || got.Source != "planning-state" {
- t.Fatalf("wrong finding identity: %#v", got)
- }
- if got.WorkflowStage != "NOT_STARTED" || got.NextOperation != "planning-write" {
- t.Fatalf("finding must carry the real stage and the owned verb: %#v", got)
- }
- if got.BlockingFeature != "checkout" {
- t.Fatalf("slug must be parsed from the path for a copy-pasteable denial: %#v", got)
- }
-
- // Non-allowlisted names and nested depths are still inside the latch.
- for _, path := range []string{
- ".product-loop/features/checkout/notes/scratch.md",
- ".product-loop/features/checkout/random.txt",
- } {
- if got := finding(path); got.NextOperation != "planning-write" {
- t.Fatalf("latch must cover %q: %#v", path, got)
- }
- }
-}
-
-// Relation: the same law reaches both guard entry paths — a Write tool and a
-// raw shell write yield the same finding, and the rendered denial names the
-// owned channel in full.
-func TestFirstWriteLatchCoversToolAndCommandPaths(t *testing.T) {
- repo := safetyTestRepo(t)
-
- toolFindings := ClassifyTool(repo, "Write", map[string]any{
- "file_path": filepath.Join(repo, ".product-loop", "features", "checkout", "plan.md"),
- "content": "# Draft\n",
- })
- commandFindings := ClassifyCommand(repo, "cp draft.md .product-loop/features/checkout/plan.md")
- if len(toolFindings) == 0 || len(commandFindings) == 0 {
- t.Fatalf("both entry paths must deny: tool=%#v command=%#v", toolFindings, commandFindings)
- }
- for _, pair := range []struct {
- label string
- finding SafetyFinding
- }{{"tool", toolFindings[0]}, {"command", commandFindings[0]}} {
- if pair.finding.Category != "workflow-phase-bypass" || pair.finding.NextOperation != "planning-write" || pair.finding.WorkflowStage != "NOT_STARTED" {
- t.Fatalf("%s path finding drifted: %#v", pair.label, pair.finding)
- }
- }
-
- rendered := denialFor("claude", toolFindings[0]).Render(RenderPlain)
- if !strings.Contains(rendered, "flow bootstrap --feature checkout") {
- t.Fatalf("denial must name the owned channel: %q", rendered)
- }
- if !strings.Contains(rendered, "NOT_STARTED") {
- t.Fatalf("denial must name the real stage: %q", rendered)
- }
-}
-
-// Bypass: shell write idioms and mutation-capable MCP tools cannot slip the
-// latch, while product writes outside the planning tree stay unlatched — the
-// deny is path-scoped, never a blanket zero-candidate interlock.
-func TestFirstWriteLatchBypassAndScope(t *testing.T) {
- repo := safetyTestRepo(t)
-
- for _, command := range []string{
- `tee .product-loop/features/checkout/plan.md < draft.md`,
- `printf '# Plan' > .product-loop/features/checkout/plan.md`,
- `mv draft.md .product-loop/features/checkout/source-plan.md`,
- `mkdir -p .product-loop/features/checkout && cp draft.md .product-loop/features/checkout/plan.md`,
- } {
- if findings := ClassifyCommand(repo, command); len(findings) == 0 {
- t.Fatalf("shell write bypass allowed: %q", command)
- }
- }
-
- if findings := ClassifyTool(repo, "mcp__files__update", map[string]any{
- "path": ".product-loop/features/checkout/plan.md", "content": "# Draft\n",
- }); len(findings) == 0 {
- t.Fatal("mutation-capable MCP tool must not bypass the latch")
- }
-
- // Scope: ordinary product writes remain free at zero candidates.
- for _, path := range []string{"src/app.ts", "product.go"} {
- if findings := ClassifyTool(repo, "Write", map[string]any{
- "file_path": filepath.Join(repo, path), "content": "package main\n",
- }); len(findings) > 0 {
- t.Fatalf("path-scoped latch leaked onto product write %q: %#v", path, findings)
- }
- }
-}
-
-// Failure-state: a denial is decided before any effect — the working tree is
-// byte-identical afterward and the planning tree still does not exist.
-func TestFirstWriteDenialLeavesTreeUntouched(t *testing.T) {
- repo := safetyTestRepo(t)
- before, err := exec.Command("git", "-C", repo, "status", "--short").CombinedOutput()
- if err != nil {
- t.Fatal(err)
- }
-
- findings := ClassifyTool(repo, "Write", map[string]any{
- "file_path": filepath.Join(repo, ".product-loop", "features", "checkout", "plan.md"),
- "content": "# Draft\n",
- })
- if len(findings) == 0 {
- t.Fatal("expected denial")
- }
-
- after, err := exec.Command("git", "-C", repo, "status", "--short").CombinedOutput()
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatalf("classification mutated the tree: before=%q after=%q", before, after)
- }
- if _, err := os.Stat(filepath.Join(repo, ".product-loop", "features")); !os.IsNotExist(err) {
- t.Fatalf("planning tree must not exist after a denial: %v", err)
- }
-}
diff --git a/boatstack/planning_test.go b/boatstack/planning_test.go
deleted file mode 100644
index 8a125ec..0000000
--- a/boatstack/planning_test.go
+++ /dev/null
@@ -1,367 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-func planningRepo(t *testing.T) string {
- t.Helper()
- previousHealth := planningInstallationHealth
- planningInstallationHealth = func(string) error { return nil }
- t.Cleanup(func() { planningInstallationHealth = previousHealth })
- repo := t.TempDir()
- if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil {
- t.Fatalf("git init: %v: %s", err, output)
- }
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- return repo
-}
-
-func withPlanningSourceEvidence(t *testing.T, options PlanningWriteOptions) PlanningWriteOptions {
- t.Helper()
- if options.SourcePlan != "" || options.SourcePlanSHA256 != "" {
- return options
- }
- path := filepath.Join(options.Repo, "bootstrap-source.md")
- if !fileExists(path) {
- if err := os.WriteFile(path, []byte("# Bootstrap source\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- hash, err := SHA256File(path)
- if err != nil {
- t.Fatal(err)
- }
- options.SourcePlan = "bootstrap-source.md"
- options.SourcePlanSHA256 = hash
- return options
-}
-
-func TestPlanningWriteBlocksBeforeArtifactWhenInstallationIsUnhealthy(t *testing.T) {
- repo := planningRepo(t)
- planningInstallationHealth = func(string) error { return fmt.Errorf("generated state drift") }
- _, err := WritePlanningArtifact(PlanningWriteOptions{Repo: repo, Feature: "blocked-plan", Artifact: "plan.md", Content: []byte("# Plan\n")})
- if err == nil || !strings.Contains(err.Error(), "generated state drift") {
- t.Fatalf("unhealthy installation did not block precisely: %v", err)
- }
- if _, statErr := os.Stat(filepath.Join(repo, ".product-loop", "features", "blocked-plan")); !os.IsNotExist(statErr) {
- t.Fatalf("planning artifact directory exists after failed health check: %v", statErr)
- }
-}
-
-func TestPlanningWriteIsBoundedMarkdownOnly(t *testing.T) {
- repo := planningRepo(t)
- path, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{
- Repo: repo, Feature: "account-recovery", Artifact: "questions.md",
- Content: []byte("# Questions\n\nQ-1 remains open.\n"),
- }))
- if err != nil {
- t.Fatal(err)
- }
- if path != ".product-loop/features/account-recovery/questions.md" {
- t.Fatalf("unexpected planning path: %s", path)
- }
- value, _ := os.ReadFile(filepath.Join(repo, filepath.FromSlash(path)))
- if !strings.Contains(string(value), "Q-1") {
- t.Fatal("planning write lost content")
- }
-
- cases := []PlanningWriteOptions{
- {Repo: repo, Feature: "../escape", Artifact: "plan.md", Content: []byte("# bad\n")},
- {Repo: repo, Feature: "account-recovery", Artifact: "plan.json", Content: []byte("{}")},
- {Repo: repo, Feature: "account-recovery", Artifact: "../README.md", Content: []byte("# bad\n")},
- {Repo: repo, Feature: "account-recovery", Artifact: "plan.md", Content: []byte(" \n")},
- {Repo: repo, Feature: "account-recovery", Artifact: "plan.md", Content: []byte{0xff, 0xfe}},
- }
- for _, options := range cases {
- if _, err := WritePlanningArtifact(options); err == nil {
- t.Fatalf("expected bounded writer to reject %#v", options)
- }
- }
-}
-
-func TestPlanningWriteNormalizesPowerShellTransportEncoding(t *testing.T) {
- repo := planningRepo(t)
- path, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{
- Repo: repo, Feature: "powershell-transport", Artifact: "plan.md",
- Content: append([]byte{0xef, 0xbb, 0xbf}, []byte("# Plan\r\n")...),
- }))
- if err != nil {
- t.Fatal(err)
- }
- written, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(path)))
- if err != nil {
- t.Fatal(err)
- }
- if string(written) != "# Plan\n" {
- t.Fatalf("PowerShell transport encoding reached the Markdown artifact: %q", written)
- }
- if _, err := WritePlanningArtifact(PlanningWriteOptions{
- Repo: repo, Feature: "powershell-transport", Artifact: "questions.md",
- Content: []byte{0xef, 0xbb, 0xbf},
- }); err == nil || !strings.Contains(err.Error(), "must not be empty") {
- t.Fatalf("a BOM without Markdown must remain empty input: %v", err)
- }
-}
-
-// Discoverability: the natural guess for an artifact token is "plan" — but the
-// tokens carry a .md suffix, so it is always wrong. The rejection must name the
-// accepted set and the transform at the point of failure, so the caller is not
-// forced to discover them out of band (the failure that burned a planning session).
-func TestPlanningWriteUnsupportedArtifactErrorIsDiscoverable(t *testing.T) {
- repo := planningRepo(t)
- _, err := WritePlanningArtifact(PlanningWriteOptions{
- Repo: repo, Feature: "account-recovery", Artifact: "plan", Content: []byte("# Plan\n"),
- })
- if err == nil {
- t.Fatal("expected the bare token to be rejected")
- }
- for _, want := range []string{"plan.md", "source-plan.md", "test-plan.md", ".md suffix"} {
- if !strings.Contains(err.Error(), want) {
- t.Fatalf("unsupported-artifact error omitted %q: %v", want, err)
- }
- }
-}
-
-func TestPlanningWriteRejectsSymlinksAndPreservesExistingContentOnFailure(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink creation needs elevated Windows permissions")
- }
- repo := planningRepo(t)
- outside := t.TempDir()
- productLoop := filepath.Join(repo, ".product-loop")
- if err := os.Symlink(outside, productLoop); err != nil {
- t.Fatal(err)
- }
- if _, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{
- Repo: repo, Feature: "feature", Artifact: "plan.md", Content: []byte("# plan\n"),
- })); err == nil || !strings.Contains(err.Error(), "symlink") {
- t.Fatalf("expected symlink rejection, got %v", err)
- }
- if _, err := os.Stat(filepath.Join(outside, "features", "feature", "plan.md")); !os.IsNotExist(err) {
- t.Fatal("bounded writer followed a symlink")
- }
-
- if err := os.Remove(productLoop); err != nil {
- t.Fatal(err)
- }
- destination, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{
- Repo: repo, Feature: "feature", Artifact: "plan.md", Content: []byte("# known good\n"),
- }))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := WritePlanningArtifact(PlanningWriteOptions{
- Repo: repo, Feature: "feature", Artifact: "plan.md", Content: []byte("\n"),
- }); err == nil {
- t.Fatal("expected invalid replacement to fail")
- }
- value, _ := os.ReadFile(filepath.Join(repo, filepath.FromSlash(destination)))
- if string(value) != "# known good\n" {
- t.Fatal("failed planning write damaged the previous artifact")
- }
-}
-
-func TestRecordApprovalChecksFingerprintAndWritesOnlyReceipt(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record planning inputs")
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- approval := filepath.Join(root, "approval.md")
- if err := RecordApproval(ApprovalRecordOptions{
- Repo: root,
- PlanPath: planPath, OutputPath: approval, ApprovedBy: "Test Human",
- ApprovedAt: "2026-07-16T12:00:00Z", Fingerprint: "wrong",
- }); err == nil {
- t.Fatal("expected stale fingerprint to block approval")
- }
- if _, err := os.Stat(approval); !os.IsNotExist(err) {
- t.Fatal("failed approval created a receipt")
- }
- if err := RecordApproval(ApprovalRecordOptions{
- Repo: root,
- PlanPath: planPath, ApprovedBy: "Test Human",
- ApprovedAt: "2026-07-16T12:00:00Z", Fingerprint: check.Fingerprint,
- }); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckApprovalReceipt(approval, check); err != nil {
- t.Fatal(err)
- }
- entries, _ := os.ReadDir(root)
- for _, entry := range entries {
- if strings.HasSuffix(entry.Name(), ".json") {
- t.Fatalf("approval wrote machine state before build: %s", entry.Name())
- }
- }
-}
-
-func TestApprovalBindsAndPreservesExistingProductBaseline(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, true)
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(root, "app.ts"), []byte("before\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record planning baseline")
- if err := os.WriteFile(filepath.Join(root, "app.ts"), []byte("pre-existing operator edit\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- baseline, err := PlanningBaselineForPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- if baseline.DiffSHA256 == "" || len(baseline.ChangedPaths) != 1 || baseline.ChangedPaths[0] != "app.ts" {
- t.Fatalf("product baseline was not exposed for approval: %+v", baseline)
- }
- approval := filepath.Join(root, "approval.md")
- if err := RecordApproval(ApprovalRecordOptions{
- Repo: root,
- PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-07-16T12:00:00Z",
- Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256,
- }); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckApprovalReceipt(approval, check); err != nil {
- t.Fatalf("unchanged product baseline invalidated approval: %v", err)
- }
- writeActivationConfig(t, root, true)
- compiled := filepath.Join(root, ".product-loop", "features", "feature-one", "compiled")
- lockPath := filepath.Join(root, ".product-loop", "features", "feature-one", "plan.lock.json")
- if err := ActivatePlan(ActivationOptions{Repo: root, PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil {
- t.Fatalf("unchanged pre-existing product diff blocked activation: %v", err)
- }
- content, err := os.ReadFile(filepath.Join(root, "app.ts"))
- if err != nil || string(content) != "pre-existing operator edit\n" {
- t.Fatalf("activation rewrote the pre-existing product diff: %q %v", content, err)
- }
- lockValue, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- lock := map[string]any{}
- if err := json.Unmarshal(lockValue, &lock); err != nil {
- t.Fatal(err)
- }
- if stringValue(lock["baseline_diff_sha256"]) != baseline.DiffSHA256 {
- t.Fatalf("plan lock lost baseline provenance: %#v", lock)
- }
- if err := os.WriteFile(filepath.Join(root, "app.ts"), []byte("drift after approval\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckApprovalReceipt(approval, check); err == nil || !strings.Contains(err.Error(), "baseline product diff changed") {
- t.Fatalf("product drift did not invalidate approval: %v", err)
- }
-}
-
-func TestDoctorDetectsMissingConfigAdapterAndVersionDrift(t *testing.T) {
- repo := planningRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true}); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err != nil {
- t.Fatal(err)
- }
- command := filepath.Join(repo, ".cursor", "commands", "ship-gate.md")
- if err := os.Remove(command); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err == nil || !strings.Contains(err.Error(), "missing .cursor/commands/ship-gate.md") {
- t.Fatalf("expected missing adapter diagnosis, got %v", err)
- }
- config, raw, _ := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- bundle, _ := BuildExportBundle(filepath.Join(repo, ".boatstack-project.json"), config, raw, "boatstack")
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- claudeSkill := filepath.Join(repo, ".claude", "skills", "auto-plan", "SKILL.md")
- if err := os.Remove(claudeSkill); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err == nil || !strings.Contains(err.Error(), "missing .claude/skills/auto-plan/SKILL.md") {
- t.Fatalf("expected missing Claude skill diagnosis, got %v", err)
- }
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- lockValue, _ := os.ReadFile(lockPath)
- var lock map[string]any
- if err := json.Unmarshal(lockValue, &lock); err != nil {
- t.Fatal(err)
- }
- lock["boatstack_version"] = "v0.0.0"
- updated, _ := MarshalJSON(lock)
- if err := os.WriteFile(lockPath, updated, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err == nil || !strings.Contains(err.Error(), "version drift") {
- t.Fatalf("expected helper version diagnosis, got %v", err)
- }
- if err := os.Remove(filepath.Join(repo, ".boatstack-project.json")); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err == nil || !strings.Contains(err.Error(), ".boatstack-project.json") {
- t.Fatalf("expected missing config diagnosis, got %v", err)
- }
-}
-
-func TestDoctorRejectsUnsafeHelperPaths(t *testing.T) {
- repo := planningRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true}); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- lockValue, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- var lock map[string]any
- if err := json.Unmarshal(lockValue, &lock); err != nil {
- t.Fatal(err)
- }
- for _, unsafe := range []string{
- filepath.Join(repo, ".product-loop", "bin", "boatstack-helper"),
- "../outside/boatstack-helper",
- } {
- lock["binary_path"] = unsafe
- updated, err := MarshalJSON(lock)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(lockPath, updated, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err == nil || !strings.Contains(err.Error(), "invalid Boatstack helper path") {
- t.Fatalf("expected unsafe helper path %q to be rejected, got %v", unsafe, err)
- }
- }
-}
diff --git a/boatstack/planning_transport.go b/boatstack/planning_transport.go
deleted file mode 100644
index 0e65c47..0000000
--- a/boatstack/planning_transport.go
+++ /dev/null
@@ -1,447 +0,0 @@
-package boatstack
-
-import (
- "path"
- "path/filepath"
- "regexp"
- "strings"
- "unicode"
- "unicode/utf8"
-)
-
-// Planning Markdown crosses a shell hook as literal data, not as executable
-// command text. The guard admits exactly two envelopes:
-//
-// - a POSIX single-quoted heredoc; and
-// - a PowerShell single-quoted here-string inside a local UTF-8 output scope.
-//
-// The parser removes only a structurally complete body from effect scanning.
-// Any truncation, delimiter collision, expansion-capable delimiter, malformed
-// helper command, or trailing command stays closed before the shell runs.
-// control-law: planning-document-body-is-literal-data
-
-const powerShellPlanningEncodingLine = `$OutputEncoding = [System.Text.UTF8Encoding]::new($false)`
-const powerShellPlanningExitLine = `if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`
-
-var posixPlanningHeader = regexp.MustCompile(`^(.*\S)[ \t]+<<'([A-Za-z_][A-Za-z0-9_]{0,63})'[ \t]*$`)
-var powerShellPlanningClose = regexp.MustCompile(`^'@[ \t]+\|[ \t]+&[ \t]+(.+)$`)
-var planningWriteMention = regexp.MustCompile(`(?i)\bboatstack(?:\.ps1)?['"]?[ \t]+planning-write(?:[ \t]|$)`)
-var planningSHA256 = regexp.MustCompile(`^[a-f0-9]{64}$`)
-
-type planningTransportInspection struct {
- Matched bool
- Header string
- Content []byte
- Feature string
- Repository string
- Executable string
- InvalidReason string
-}
-
-type planningWriteInvocation struct {
- Executable string
- Repository string
- Feature string
-}
-
-func structuralLine(value string) string {
- return strings.TrimSuffix(value, "\r")
-}
-
-func nextLine(value string, start int) (line string, next int, hasNewline bool) {
- if start > len(value) {
- return "", len(value), false
- }
- if relative := strings.IndexByte(value[start:], '\n'); relative >= 0 {
- end := start + relative
- return value[start:end], end + 1, true
- }
- return value[start:], len(value), false
-}
-
-// literalCommandWords is a deliberately smaller grammar than either shell. It
-// accepts ordinary quoted argv (including paths with spaces) but no expansion,
-// compound syntax, glob, grouping, redirection, or pipeline syntax. The partial
-// words returned on failure let the guard recognize a malformed planning-write
-// attempt and deny it with the transport-specific recovery instead of executing
-// a prefix with shell side effects.
-func literalCommandWords(value string) (words []string, complete bool) {
- runes := []rune(strings.TrimSpace(value))
- var current strings.Builder
- var quote rune
- inWord := false
- flush := func() {
- if inWord {
- words = append(words, current.String())
- current.Reset()
- inWord = false
- }
- }
- for index := 0; index < len(runes); index++ {
- char := runes[index]
- if quote != 0 {
- if char == quote {
- quote = 0
- inWord = true
- continue
- }
- if char == 0 || char == '\n' || char == '\r' || (quote == '"' && (char == '$' || char == '`')) {
- flush()
- return words, false
- }
- current.WriteRune(char)
- inWord = true
- continue
- }
- if unicode.IsSpace(char) {
- flush()
- continue
- }
- switch char {
- case '\'', '"':
- quote = char
- inWord = true
- case '\\':
- inWord = true
- if index+1 < len(runes) {
- next := runes[index+1]
- if unicode.IsSpace(next) || next == '\\' || next == '\'' || next == '"' {
- current.WriteRune(next)
- index++
- continue
- }
- }
- // Preserve Windows separators and ordinary POSIX backslashes.
- current.WriteRune(char)
- case ';', '&', '|', '<', '>', '$', '`', '(', ')', '{', '}', '*', '?', '[', ']', '#', 0:
- flush()
- return words, false
- default:
- current.WriteRune(char)
- inWord = true
- }
- }
- if quote != 0 {
- flush()
- return words, false
- }
- flush()
- return words, true
-}
-
-func portableExecutableBase(value string) string {
- base := strings.ToLower(path.Base(strings.ReplaceAll(value, "\\", "/")))
- base = strings.TrimSuffix(base, ".exe")
- return strings.TrimSuffix(base, ".ps1")
-}
-
-func planningExecutable(value string) bool {
- base := portableExecutableBase(value)
- return base == "boatstack" || base == "boatstack-helper"
-}
-
-func planningWriteAttempt(value string) bool {
- words, _ := literalCommandWords(value)
- return len(words) >= 2 && planningExecutable(words[0]) && words[1] == "planning-write"
-}
-
-func planningBootstrapAttempt(value string) bool {
- words, _ := literalCommandWords(value)
- return len(words) >= 3 && planningExecutable(words[0]) && words[1] == "flow" && words[2] == "bootstrap"
-}
-
-func planningEnvelopeAttempt(value string) bool {
- return planningWriteAttempt(value) || planningBootstrapAttempt(value)
-}
-
-func planningVerbInvocationAttempt(value string) bool {
- words, _ := literalCommandWords(value)
- return len(words) >= 2 && words[1] == "planning-write"
-}
-
-func powerShellPlanningAttempt(command string) bool {
- for position := 0; position <= len(command); {
- line, next, hasNewline := nextLine(command, position)
- if match := powerShellPlanningClose.FindStringSubmatch(structuralLine(line)); match != nil && planningEnvelopeAttempt(strings.TrimSpace(match[1])) {
- return true
- }
- if !hasNewline {
- return false
- }
- position = next
- }
- return false
-}
-
-func planningWriteHeader(value string) (planningWriteInvocation, bool) {
- words, complete := literalCommandWords(value)
- if !complete || len(words) < 2 || !planningExecutable(words[0]) {
- return planningWriteInvocation{}, false
- }
- start := 2
- bootstrap := false
- if words[1] == "flow" {
- if len(words) < 3 || words[2] != "bootstrap" {
- return planningWriteInvocation{}, false
- }
- start = 3
- bootstrap = true
- } else if words[1] != "planning-write" {
- return planningWriteInvocation{}, false
- }
- values := map[string]string{}
- for index := start; index < len(words); index++ {
- flag := words[index]
- value := ""
- if bootstrap && flag == "--json" {
- if values[flag] != "" {
- return planningWriteInvocation{}, false
- }
- values[flag] = "true"
- continue
- }
- if split := strings.IndexByte(flag, '='); split >= 0 {
- value = flag[split+1:]
- flag = flag[:split]
- } else {
- if index+1 >= len(words) {
- return planningWriteInvocation{}, false
- }
- index++
- value = words[index]
- }
- allowed := flag == "--repo" || flag == "--feature" || flag == "--artifact" || flag == "--source-plan"
- if bootstrap {
- allowed = allowed || flag == "--shell" || flag == "--json"
- } else {
- allowed = allowed || flag == "--source-plan-sha256" || flag == "--expected-lifecycle-sha256" ||
- flag == "--expected-plan-lock-sha256" || flag == "--expected-observation"
- }
- if !allowed {
- return planningWriteInvocation{}, false
- }
- if value == "" || values[flag] != "" {
- return planningWriteInvocation{}, false
- }
- values[flag] = value
- }
- if !featureSlugPattern.MatchString(values["--feature"]) || !planningArtifacts[values["--artifact"]] {
- return planningWriteInvocation{}, false
- }
- if bootstrap {
- if values["--source-plan"] == "" || (values["--shell"] != string(BootstrapShellPOSIX) && values["--shell"] != string(BootstrapShellPowerShell)) {
- return planningWriteInvocation{}, false
- }
- } else {
- sourcePlan := values["--source-plan"]
- sourceSHA := values["--source-plan-sha256"]
- if (sourcePlan == "") != (sourceSHA == "") || (sourceSHA != "" && !planningSHA256.MatchString(sourceSHA)) {
- return planningWriteInvocation{}, false
- }
- lifecycleSHA := values["--expected-lifecycle-sha256"]
- planLockSHA := values["--expected-plan-lock-sha256"]
- observation := values["--expected-observation"]
- hasLifecycle := lifecycleSHA != "" || planLockSHA != "" || observation != ""
- if hasLifecycle && (!planningSHA256.MatchString(lifecycleSHA) || !planningSHA256.MatchString(planLockSHA) || observation == "") {
- return planningWriteInvocation{}, false
- }
- }
- repository := values["--repo"]
- if repository == "" {
- repository = "."
- }
- return planningWriteInvocation{Executable: words[0], Repository: repository, Feature: values["--feature"]}, true
-}
-
-func planningTransportBinding(repo string, transport planningTransportInspection) string {
- root, err := filepath.Abs(repo)
- if err != nil {
- return "repository-mismatch"
- }
- if canonical, canonicalErr := filepath.EvalSymlinks(root); canonicalErr == nil {
- root = canonical
- }
- normalizedRepo := filepath.FromSlash(strings.ReplaceAll(transport.Repository, "\\", "/"))
- targetRepo := normalizedRepo
- if !filepath.IsAbs(targetRepo) {
- targetRepo = filepath.Join(root, targetRepo)
- }
- targetRepo, err = filepath.Abs(targetRepo)
- if err != nil {
- return "repository-mismatch"
- }
- if canonical, canonicalErr := filepath.EvalSymlinks(targetRepo); canonicalErr == nil {
- targetRepo = canonical
- }
- if filepath.Clean(targetRepo) != filepath.Clean(root) {
- return "repository-mismatch"
- }
-
- normalizedExecutable := filepath.FromSlash(strings.ReplaceAll(transport.Executable, "\\", "/"))
- executable := normalizedExecutable
- if !filepath.IsAbs(executable) {
- executable = filepath.Join(root, executable)
- }
- executable, err = filepath.Abs(executable)
- if err != nil {
- return "helper-path-mismatch"
- }
- if canonical, canonicalErr := filepath.EvalSymlinks(executable); canonicalErr == nil {
- executable = canonical
- }
- base := strings.ToLower(filepath.Base(executable))
- workspace, workspaceErr := ResolveWorkspaceContext(root)
- if workspaceErr != nil {
- return "workspace-binding-unverified"
- }
- expected := workspace.HelperPath()
- if workspace.Mode == SupervisionEmbedded {
- if base != "boatstack" && base != "boatstack.ps1" {
- return "helper-path-mismatch"
- }
- expected = workspace.LauncherPath(base == "boatstack.ps1")
- } else if portableExecutableBase(base) != "boatstack-helper" {
- return "helper-path-mismatch"
- }
- if canonical, canonicalErr := filepath.EvalSymlinks(expected); canonicalErr == nil {
- expected = canonical
- }
- if filepath.Clean(executable) != filepath.Clean(expected) {
- return "helper-path-mismatch"
- }
- return ""
-}
-
-func validPlanningBody(value string) string {
- if !utf8.ValidString(value) || strings.IndexByte(value, 0) >= 0 {
- return "invalid-content"
- }
- if strings.TrimSpace(value) == "" {
- return "empty-content"
- }
- return ""
-}
-
-func inspectPosixPlanningTransport(command string, first string, bodyStart int) planningTransportInspection {
- match := posixPlanningHeader.FindStringSubmatch(first)
- if match == nil {
- if planningEnvelopeAttempt(first) {
- return planningTransportInspection{Matched: true, InvalidReason: "single-quoted-delimiter-required"}
- }
- return planningTransportInspection{}
- }
- header := strings.TrimSpace(match[1])
- invocation, validHeader := planningWriteHeader(header)
- if !validHeader {
- if planningEnvelopeAttempt(header) || planningVerbInvocationAttempt(header) {
- return planningTransportInspection{Matched: true, Header: header, InvalidReason: "invalid-command-shape"}
- }
- return planningTransportInspection{}
- }
- delimiter := match[2]
- for position := bodyStart; position <= len(command); {
- line, next, hasNewline := nextLine(command, position)
- if structuralLine(line) == delimiter {
- if next != len(command) {
- return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "delimiter-collision-or-trailing-command"}
- }
- content := normalizePlanningTransportBytes([]byte(command[bodyStart:position]))
- if reason := validPlanningBody(string(content)); reason != "" {
- return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: reason}
- }
- return planningTransportInspection{Matched: true, Header: header, Content: content, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable}
- }
- if !hasNewline {
- break
- }
- position = next
- }
- return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "missing-terminator"}
-}
-
-func inspectPowerShellPlanningTransport(command string) planningTransportInspection {
- position := 0
- line, next, ok := nextLine(command, position)
- if !ok || strings.TrimSpace(structuralLine(line)) != "& {" {
- return planningTransportInspection{}
- }
- position = next
- line, next, ok = nextLine(command, position)
- if !ok || strings.TrimSpace(structuralLine(line)) != powerShellPlanningEncodingLine {
- if powerShellPlanningAttempt(command) {
- return planningTransportInspection{Matched: true, InvalidReason: "powershell-utf8-scope-required"}
- }
- return planningTransportInspection{}
- }
- position = next
- line, next, ok = nextLine(command, position)
- if !ok || strings.TrimSpace(structuralLine(line)) != "@'" {
- return planningTransportInspection{Matched: true, InvalidReason: "single-quoted-here-string-required"}
- }
- bodyStart := next
- for position = bodyStart; position <= len(command); {
- line, next, hasNewline := nextLine(command, position)
- structural := structuralLine(line)
- if strings.HasPrefix(structural, "'@") {
- match := powerShellPlanningClose.FindStringSubmatch(structural)
- if match == nil {
- return planningTransportInspection{Matched: true, InvalidReason: "delimiter-collision-or-trailing-command"}
- }
- header := strings.TrimSpace(match[1])
- invocation, validHeader := planningWriteHeader(header)
- if !validHeader {
- return planningTransportInspection{Matched: true, Header: header, InvalidReason: "invalid-command-shape"}
- }
- if !hasNewline {
- return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "powershell-scope-not-closed"}
- }
- exitLine, afterExit, hasExitNewline := nextLine(command, next)
- if !hasExitNewline || strings.TrimSpace(structuralLine(exitLine)) != powerShellPlanningExitLine {
- return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "powershell-exit-status-required"}
- }
- closing, afterClosing, _ := nextLine(command, afterExit)
- if strings.TrimSpace(structuralLine(closing)) != "}" || afterClosing != len(command) {
- return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "delimiter-collision-or-trailing-command"}
- }
- content := normalizePlanningTransportBytes([]byte(command[bodyStart:position]))
- if reason := validPlanningBody(string(content)); reason != "" {
- return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: reason}
- }
- return planningTransportInspection{Matched: true, Header: header, Content: content, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable}
- }
- if !hasNewline {
- break
- }
- position = next
- }
- return planningTransportInspection{Matched: true, InvalidReason: "missing-terminator"}
-}
-
-func inspectPlanningWriteTransport(command string) planningTransportInspection {
- first, next, hasNewline := nextLine(command, 0)
- first = structuralLine(first)
- if strings.TrimSpace(first) == "& {" {
- if transport := inspectPowerShellPlanningTransport(command); transport.Matched {
- return transport
- }
- }
- if (strings.TrimSpace(first) == "@'" || strings.HasPrefix(strings.TrimSpace(first), "$OutputEncoding")) && powerShellPlanningAttempt(command) {
- return planningTransportInspection{Matched: true, InvalidReason: "powershell-utf8-scope-required"}
- }
- if hasNewline {
- if transport := inspectPosixPlanningTransport(command, first, next); transport.Matched {
- return transport
- }
- }
- if planningWriteAttempt(first) {
- return planningTransportInspection{Matched: true, Header: strings.TrimSpace(first), InvalidReason: "missing-literal-input"}
- }
- // A planning-write occurrence that did not match either complete envelope is
- // still owned by this boundary. This closes leading commands, compound shell
- // wrappers, and other alternate paths that could otherwise avoid binding and
- // body classification merely by moving the helper away from column zero.
- if planningWriteMention.MatchString(command) {
- return planningTransportInspection{Matched: true, InvalidReason: "complete-literal-envelope-required"}
- }
- return planningTransportInspection{}
-}
diff --git a/boatstack/planning_transport_conformance_test.go b/boatstack/planning_transport_conformance_test.go
deleted file mode 100644
index fa56fe5..0000000
--- a/boatstack/planning_transport_conformance_test.go
+++ /dev/null
@@ -1,463 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-const planningTransportDelimiter = "BOATSTACK_PLAN_EOF"
-
-func quotedLiteral(t *testing.T, value string) string {
- t.Helper()
- if strings.Contains(value, "'") {
- t.Fatalf("test path cannot be represented by the bounded literal fixture: %q", value)
- }
- return "'" + value + "'"
-}
-
-func planningHeader(t *testing.T, helper, repo, feature, artifact string) string {
- t.Helper()
- sourcePlan := "README.md"
- sourceSHA, err := SHA256File(filepath.Join(repo, sourcePlan))
- if err != nil {
- return quotedLiteral(t, helper) + " planning-write --repo " + quotedLiteral(t, repo) + " --feature " + feature + " --artifact " + artifact
- }
- return quotedLiteral(t, helper) + " planning-write --repo " + quotedLiteral(t, repo) + " --feature " + feature + " --artifact " + artifact + " --source-plan " + sourcePlan + " --source-plan-sha256 " + sourceSHA
-}
-
-func posixPlanningEnvelope(t *testing.T, helper, repo, feature, artifact, body string) string {
- t.Helper()
- if !strings.HasSuffix(body, "\n") {
- body += "\n"
- }
- return planningHeader(t, helper, repo, feature, artifact) + " <<'" + planningTransportDelimiter + "'\n" + body + planningTransportDelimiter + "\n"
-}
-
-func powerShellPlanningEnvelope(t *testing.T, helper, repo, feature, artifact, body string) string {
- t.Helper()
- if !strings.HasSuffix(body, "\n") {
- body += "\n"
- }
- return "& {\n" + powerShellPlanningEncodingLine + "\n@'\n" + body + "'@ | & " + planningHeader(t, helper, repo, feature, artifact) + "\n" + powerShellPlanningExitLine + "\n}\n"
-}
-
-func planningHookInput(t *testing.T, host, command string) []byte {
- t.Helper()
- var event map[string]any
- switch host {
- case "cursor":
- event = map[string]any{"hook_event_name": "beforeShellExecution", "command": command}
- case "claude", "codex":
- event = map[string]any{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": map[string]any{"command": command}}
- case "gemini":
- event = map[string]any{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": map[string]any{"command": command}}
- default:
- t.Fatalf("unsupported test host %q", host)
- }
- value, err := json.Marshal(event)
- if err != nil {
- t.Fatal(err)
- }
- return value
-}
-
-func buildPlanningHelperAt(t *testing.T, binary string) string {
- t.Helper()
- _, source, _, ok := runtime.Caller(0)
- if !ok {
- t.Fatal("resolve package source")
- }
- if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil {
- t.Fatal(err)
- }
- command := exec.Command("go", "build", "-o", binary, "./cmd/boatstack-helper")
- command.Dir = filepath.Dir(source)
- if output, err := command.CombinedOutput(); err != nil {
- t.Fatalf("build helper: %v: %s", err, output)
- }
- return binary
-}
-
-func buildPlanningHelper(t *testing.T, repo string) string {
- t.Helper()
- return buildPlanningHelperAt(t, filepath.Join(repo, ".product-loop", "bin", helperName()))
-}
-
-// installPlanningTransportFixture gives execution tests the same healthy,
-// generated state that production planning-write requires. Classifier-only
-// tests intentionally keep using the smaller uninstalled repository fixture.
-func installPlanningTransportFixture(t *testing.T, repo string) string {
- t.Helper()
- if !fileExists(filepath.Join(repo, "go.mod")) {
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module planning-transport\n\ngo 1.22\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "go.mod")
- runGit(t, repo, "commit", "-m", "add test command")
- }
- source := buildPlanningHelperAt(t, filepath.Join(t.TempDir(), helperName()))
- if err := RunInit(InitOptions{
- Repo: repo, BinaryPath: source, IntegrationChoice: "core", Yes: true, Output: io.Discard,
- }); err != nil {
- t.Fatalf("install healthy planning transport fixture: %v", err)
- }
- if runtime.GOOS == "windows" {
- return filepath.Join(repo, ".product-loop", "boatstack.ps1")
- }
- return filepath.Join(repo, ".product-loop", "boatstack")
-}
-
-func executePlanningEnvelope(t *testing.T, repo, command string) {
- t.Helper()
- var execution *exec.Cmd
- if runtime.GOOS == "windows" {
- powershell, err := exec.LookPath("powershell")
- if err != nil {
- t.Skip("Windows PowerShell is unavailable")
- }
- execution = exec.Command(powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command)
- } else {
- bash, err := exec.LookPath("bash")
- if err != nil {
- t.Skip("bash is unavailable")
- }
- execution = exec.Command(bash, "-c", command)
- }
- execution.Dir = repo
- if output, err := execution.CombinedOutput(); err != nil {
- t.Fatalf("execute admitted planning transport: %v: %s", err, output)
- }
-}
-
-// control-law: planning-document-body-is-literal-data
-//
-// Relation conformance: the real host event admits the exact command that the
-// real shell executes, and the real helper receives a non-empty document. This
-// closes the old split test (bare command classification plus a direct library
-// write), which never exercised the transport between the hook and stdin.
-func TestPlanningTransportRunsHookShellHelperAndSavedArtifact(t *testing.T) {
- repo := safetyTestRepo(t)
- helper := installPlanningTransportFixture(t, repo)
- body := "# Literal transport\n\nUnicode survives: ü 船\n`rm -rf /` and $(git reset --hard HEAD~1) are documentation.\n"
-
- var command string
- if runtime.GOOS == "windows" {
- command = powerShellPlanningEnvelope(t, helper, repo, "literal-transport", "source-plan.md", body)
- } else {
- command = posixPlanningEnvelope(t, helper, repo, "literal-transport", "source-plan.md", body)
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)})
- if denied {
- t.Fatalf("%s denied the complete literal envelope: %s", host, output)
- }
- }
-
- executePlanningEnvelope(t, repo, command)
- written, err := os.ReadFile(filepath.Join(repo, ".product-loop", "features", "literal-transport", "source-plan.md"))
- if err != nil {
- t.Fatal(err)
- }
- if string(written) != body {
- t.Fatalf("saved Markdown differs from transported body:\nwant %q\n got %q", body, written)
- }
-}
-
-func TestPlanningTransportRunsThroughDetachedWorkspaceBinding(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/planning-transport.git")
- source := buildPlanningHelperAt(t, filepath.Join(t.TempDir(), helperName()))
- result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: source})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach detached workspace: %+v %v", result, err)
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil || workspace.Mode != SupervisionDetached {
- t.Fatalf("resolve detached workspace: %+v %v", workspace, err)
- }
- helper := workspace.HelperPath()
- body := "# Detached literal transport\n\nThe controller remains outside the product repository.\n"
- command := posixPlanningEnvelope(t, helper, repo, "detached-transport", "plan.md", body)
- if runtime.GOOS == "windows" {
- command = powerShellPlanningEnvelope(t, helper, repo, "detached-transport", "plan.md", body)
- }
- output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: planningHookInput(t, "codex", command)})
- if denied {
- t.Fatalf("detached literal envelope was denied: %s", output)
- }
- executePlanningEnvelope(t, repo, command)
- written, err := os.ReadFile(filepath.Join(workspace.FeatureDir("detached-transport"), "plan.md"))
- if err != nil || strings.ReplaceAll(string(written), "\r\n", "\n") != body {
- t.Fatalf("detached planning artifact mismatch: %v %q", err, written)
- }
- if _, err := os.Stat(filepath.Join(repo, productLoopDirName)); !os.IsNotExist(err) {
- t.Fatal("detached planning transport leaked controller state into the product repository")
- }
-}
-
-func TestPlanningTransportRejectsUnverifiedDetachedBinding(t *testing.T) {
- repo := detachedTestRepo(t, "https://github.com/acme/planning-binding.git")
- result, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach detached workspace: %+v %v", result, err)
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil {
- t.Fatal(err)
- }
- command := posixPlanningEnvelope(t, workspace.HelperPath(), repo, "detached-binding", "plan.md", "# Plan\n")
- identity, err := repoIdentity(repo)
- if err != nil {
- t.Fatal(err)
- }
- root := filepath.Join(os.Getenv(stateRootEnv), "boatstack")
- if err := os.WriteFile(bindingPath(root, identity.RepoID), []byte("{not json"), 0o644); err != nil {
- t.Fatal(err)
- }
- invalidateWorkspaceCache()
- findings := ClassifyCommand(repo, command)
- if len(findings) != 1 || findings[0].Category != "planning-transport-invalid" || findings[0].Reason != "workspace-binding-unverified" {
- t.Fatalf("unverified detached binding did not fail closed: %#v", findings)
- }
-}
-
-func TestPlanningTransportTreatsDocumentTextAsInertAcrossHosts(t *testing.T) {
- repo := safetyTestRepo(t)
- body := strings.Join([]string{
- "# Threat-model examples",
- "`rm -rf /`",
- "$(git reset --hard HEAD~1)",
- "DROP TABLE accounts;",
- ".git/boatstack/deliveries/demo/state.json > /tmp/example",
- "terraform destroy | Remove-Item -Recurse -Force $HOME",
- "secret-marker-that-must-never-be-rendered",
- }, "\n") + "\n"
- posix := posixPlanningEnvelope(t, ".product-loop/boatstack", repo, "threat-model", "questions.md", body)
- commands := []struct {
- command, expected string
- hostConformanceRun bool
- }{
- {posix, body, true},
- {powerShellPlanningEnvelope(t, `.product-loop\boatstack.ps1`, repo, "threat-model", "questions.md", body), body, runtime.GOOS == "windows"},
- {strings.ReplaceAll(posix, "\n", "\r\n"), body, true},
- }
- for _, test := range commands {
- inspection := inspectPlanningWriteTransport(test.command)
- if !inspection.Matched || inspection.InvalidReason != "" || string(inspection.Content) != test.expected {
- t.Fatalf("literal envelope was not recovered exactly: %#v", inspection)
- }
- if !test.hostConformanceRun {
- continue
- }
- for _, host := range []string{"cursor", "claude", "codex", "gemini"} {
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, test.command)})
- if denied {
- t.Fatalf("%s treated literal Markdown as an effect: %s", host, output)
- }
- }
- }
-}
-
-func TestPlanningTransportFailureClassesFailClosedWithoutExecuting(t *testing.T) {
- repo := safetyTestRepo(t)
- otherRepo := t.TempDir()
- header := planningHeader(t, ".product-loop/boatstack", repo, "transport-failures", "plan.md")
- valid := posixPlanningEnvelope(t, ".product-loop/boatstack", repo, "transport-failures", "plan.md", "# Plan\n")
- powerShellValid := powerShellPlanningEnvelope(t, `.product-loop\boatstack.ps1`, repo, "transport-failures", "plan.md", "# Plan\n")
- cases := map[string]string{
- "bare command": header,
- "leading command": "touch sentinel\n" + valid,
- "compound prefix": "cd . && " + valid,
- "environment wrapper": "env BOATSTACK_TEST=1 " + valid,
- "unquoted delimiter": header + " < 0 {
- return "", fmt.Errorf("the visual capture harness modified the working tree (%s); the capture contract allows writing only the file named by BOATSTACK_CAPTURE_OUTPUT — revert these paths and fix the registered command", strings.Join(introduced, ", "))
- }
- }
- if captureErr != nil {
- return boundedCaptureDetail(captureErr.Error()), nil
- }
- return "", nil
-}
-
-func currentVisualEvidenceIdentity(scenarios []PRVisualScenario, config ProjectConfig) (string, string, error) {
- scenarioHash, err := visualScenarioDefinitionHash(scenarios)
- if err != nil {
- return "", "", err
- }
- commands, err := resolveScenarioCaptureCommands("visual", scenarios, config)
- if err != nil {
- return scenarioHash, "", err
- }
- commandRaw, err := MarshalJSON(commands)
- if err != nil {
- return "", "", err
- }
- return scenarioHash, SHA256Bytes(commandRaw), nil
-}
-
-func visualScenarioDefinitionHash(scenarios []PRVisualScenario) (string, error) {
- scenarioRaw, err := MarshalJSON(scenarios)
- if err != nil {
- return "", err
- }
- return SHA256Bytes(scenarioRaw), nil
-}
-
-// boundedCaptureDetail folds a harness error into a single bounded line so a
-// flaky harness cannot flood context JSON or denial text.
-func boundedCaptureDetail(detail string) string {
- detail = strings.Join(strings.Fields(detail), " ")
- if len(detail) > 300 {
- detail = detail[:300] + "…"
- }
- return detail
-}
-
-func resolvePRVisualEvidence(repo string, config ProjectConfig, mode, feature, head, diffHash string) (string, string, int, string, string, string, string, *PRVisualEvidenceManifest, error) {
- policy := normalizedPRVisualEvidencePolicy(config.Workflow.PRVisualEvidence)
- relevance, source := "unresolved", "agent-proposed"
- var scenarios []PRVisualScenario
- if mode == "managed" {
- var err error
- relevance, source, scenarios, err = planVisualDecision(repo, feature)
- if err != nil {
- return "", "", 0, "", "", "", "", nil, err
- }
- }
- // The effective policy is what everything downstream (coercion, preview
- // frontmatter, the publication block) reads. Escalation keys on the
- // plan's own decision, before any manifest overrides it.
- policySource := "configured"
- if visualEscalationApplies(policy, relevance, len(scenarios)) {
- policy = "require"
- policySource = "plan-escalated"
- }
- key, err := visualEvidenceKey(mode, feature, head)
- if err != nil {
- return "", "", 0, "", "", "", "", nil, err
- }
- status := "NOT_APPLICABLE"
- var manifest *PRVisualEvidenceManifest
- plannedScenarios := scenarios
- if policy != "off" && relevance != "not_relevant" {
- loaded, loadErr := LoadPRVisualEvidence(repo, key)
- if loadErr == nil {
- manifest = &loaded
- relevance, source, scenarios = loaded.Relevance, loaded.RelevanceSource, loaded.Scenarios
- // Trust is keyed to product identity only: the preview pr.md is
- // excluded from the product diff yet must be committed before
- // publication, so a head-commit equality would invalidate every
- // PASS manifest on that mandatory commit. SourceCommit stays
- // recorded for provenance and the evidence comment.
- identityCurrent := true
- if mode == "managed" && relevance == "relevant" {
- scenarioHash, commandHash, identityErr := currentVisualEvidenceIdentity(plannedScenarios, config)
- identityCurrent = loaded.ScenarioDefinitionSHA256 == scenarioHash && loaded.CaptureCommandSHA256 != ""
- if identityErr == nil {
- identityCurrent = identityCurrent && loaded.CaptureCommandSHA256 == commandHash
- }
- }
- if loaded.ProductDiffSHA256 == diffHash && identityCurrent {
- status = loaded.Status
- } else {
- status = "NOT_VERIFIED"
- manifest = nil
- }
- } else {
- status = "NOT_VERIFIED"
- }
- if policy == "require" && status != "PASS" {
- status = "BLOCKED"
- }
- }
- payload := map[string]any{
- "schema_version": visualEvidenceSchemaVersion, "policy": policy, "status": status,
- "relevance": relevance, "relevance_source": source, "scenarios": scenarios,
- }
- count := 0
- if manifest != nil {
- payload["manifest_fingerprint"] = manifest.Fingerprint
- payload["items"] = manifest.Items
- count = len(manifest.Items)
- }
- raw, err := MarshalJSON(payload)
- if err != nil {
- return "", "", 0, "", "", "", "", nil, err
- }
- return policy, status, count, SHA256Bytes(raw), relevance, source, policySource, manifest, nil
-}
-
-type PRPublishOptions struct {
- Repo string
- PreviewPath string
- ExpectedFingerprint string
- Action string
- AutonomyPath string
- VisualPublisher PRVisualEvidencePublisher
-}
-
-// PRVisualEvidencePublisher is implemented by a host that can upload exact
-// machine-local PNG bytes to one Boatstack-owned pull-request comment. ExistingCommentURL
-// is empty on first publication and lets later updates reuse the same comment.
-type PRVisualEvidencePublisher interface {
- PublishVisualEvidence(repo, prURL, existingCommentURL string, manifest PRVisualEvidenceManifest) (commentURL string, err error)
-}
-
-func publishPRVisualEvidence(repo, prURL string, context PRContext, publisher PRVisualEvidencePublisher) error {
- if context.PRVisualEvidenceStatus == "NOT_APPLICABLE" || context.PRVisualEvidence == nil {
- return nil
- }
- manifest, err := LoadPRVisualEvidence(repo, context.PRVisualEvidence.Key)
- if err != nil || manifest.Fingerprint != context.PRVisualEvidence.Fingerprint {
- return fmt.Errorf("PR opened but visual evidence became stale; preserve the PR and recapture before updating it")
- }
- if manifest.Publication.State == "published" && manifest.Publication.PRURL == prURL && strings.TrimSpace(manifest.Publication.CommentURL) != "" {
- return nil
- }
- return attachVisualEvidence(repo, prURL, manifest, publisher, context.PRVisualEvidencePolicy)
-}
-
-// attachVisualEvidence performs the one publisher call and records the
-// observed outcome: visual_pending without a publisher or on a publisher
-// failure (PR preserved, fix forward), published on an observable comment URL.
-// Shared by first publication (publishPRVisualEvidence) and the
-// attach-evidence retry, so both paths record identical states.
-func attachVisualEvidence(repo, prURL string, manifest PRVisualEvidenceManifest, publisher PRVisualEvidencePublisher, policy string) error {
- now := time.Now().UTC().Truncate(time.Second).Format(time.RFC3339)
- if publisher == nil {
- _, recordErr := recordPRVisualPublication(repo, manifest, PRVisualPublication{
- State: "visual_pending", PRURL: prURL, CommentURL: manifest.Publication.CommentURL, UpdatedAt: now,
- Detail: "external-host publication is unavailable; retry the same evidence fingerprint and comment",
- })
- if recordErr != nil {
- return fmt.Errorf("PR opened but pending visual-evidence state could not be recorded: %w", recordErr)
- }
- return fmt.Errorf("PR opened at %s but external visual evidence is pending; retry the same PR after host access is available", prURL)
- }
- commentURL, publishErr := publisher.PublishVisualEvidence(repo, prURL, manifest.Publication.CommentURL, manifest)
- if publishErr != nil {
- _, _ = recordPRVisualPublication(repo, manifest, PRVisualPublication{
- State: "visual_pending", PRURL: prURL, CommentURL: manifest.Publication.CommentURL,
- UpdatedAt: now, Detail: publishErr.Error(),
- })
- return fmt.Errorf("PR opened at %s but visual evidence publication failed; preserve the PR and fix forward: %w", prURL, publishErr)
- }
- if strings.TrimSpace(commentURL) == "" {
- _, _ = recordPRVisualPublication(repo, manifest, PRVisualPublication{
- State: "visual_pending", PRURL: prURL, CommentURL: manifest.Publication.CommentURL,
- UpdatedAt: now, Detail: "visual evidence publisher returned no observable comment URL",
- })
- return fmt.Errorf("visual evidence publisher returned no observable comment URL")
- }
- _, err := recordPRVisualPublication(repo, manifest, PRVisualPublication{
- State: "published", PRURL: prURL, CommentURL: strings.TrimSpace(commentURL), UpdatedAt: now,
- })
- return err
-}
-
-// RetryVisualAttachment retries the owed evidence comment of an already
-// published feature PR — the exact fingerprinted package the operator
-// confirmed at publication; publication authority is never re-asked. It is
-// idempotent: an already published attachment is a no-op.
-func RetryVisualAttachment(repo, feature string, publisher PRVisualEvidencePublisher) (PRVisualEvidenceManifest, error) {
- resolved, err := ResolveRepository(repo)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- key, err := visualEvidenceKey("managed", feature, "")
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- manifest, err := LoadPRVisualEvidence(resolved, key)
- if err != nil {
- return PRVisualEvidenceManifest{}, fmt.Errorf("no recorded visual evidence for feature %q: %w", feature, err)
- }
- state := manifest.Publication.State
- if state == "published" && strings.TrimSpace(manifest.Publication.CommentURL) != "" {
- return manifest, nil
- }
- if state != "visual_pending" && state != "manual_required" {
- return PRVisualEvidenceManifest{}, fmt.Errorf("visual evidence for %q owes no attachment retry (publication state %q); first publication is owned by publish-pr", feature, state)
- }
- prURL := strings.TrimSpace(manifest.Publication.PRURL)
- if prURL == "" {
- return PRVisualEvidenceManifest{}, fmt.Errorf("visual evidence for %q records no pull request; publish-pr owns first publication", feature)
- }
- if publisher == nil {
- return PRVisualEvidenceManifest{}, fmt.Errorf("no external visual publisher is available; retry the same evidence fingerprint and comment when host access is available")
- }
- if err := attachVisualEvidence(resolved, prURL, manifest, publisher, ""); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- return LoadPRVisualEvidence(resolved, key)
-}
-
-func gitCommand(repo string, arguments ...string) (string, error) {
- return commandOutput(repo, "git", append([]string{"-C", repo}, arguments...)...)
-}
-
-func defaultPRBase(repo string) string {
- configPath := WorkspaceFor(repo).ProjectConfigPath()
- if config, _, err := LoadConfig(configPath); err == nil && strings.TrimSpace(config.Project.DefaultBranch) != "" {
- if base, err := canonicalPRBaseName(config.Project.DefaultBranch); err == nil {
- return base
- }
- }
- if branch := strings.TrimPrefix(gitOutput(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"), "origin/"); branch != "" {
- return branch
- }
- return "main"
-}
-
-// canonicalPRBaseName reduces every accepted spelling to the short branch name
-// GitHub expects. Other remote/ref namespaces are rejected instead of being
-// accidentally embedded below refs/remotes/origin.
-// control-law: pr-base-is-one-fetched-origin-branch
-func canonicalPRBaseName(value string) (string, error) {
- base := strings.TrimSpace(value)
- for _, prefix := range []string{"refs/remotes/origin/", "refs/heads/", "origin/"} {
- if strings.HasPrefix(base, prefix) {
- base = strings.TrimPrefix(base, prefix)
- break
- }
- }
- if base == "" || base == "HEAD" || strings.HasPrefix(base, "refs/") || strings.HasPrefix(base, "remotes/") {
- return "", fmt.Errorf("PR base %q is not an origin branch", value)
- }
- return base, nil
-}
-
-func canonicalPRBase(repo, value string) (string, error) {
- base, err := canonicalPRBaseName(value)
- if err != nil {
- return "", err
- }
- if _, err := gitCommand(repo, "check-ref-format", "--branch", base); err != nil {
- return "", fmt.Errorf("PR base %q is not a valid branch name", value)
- }
- return base, nil
-}
-
-func resolveBaseCommit(repo, base string) (string, error) {
- canonical, err := canonicalPRBase(repo, base)
- if err != nil {
- return "", err
- }
- for _, candidate := range []string{"refs/remotes/origin/" + canonical, "refs/heads/" + canonical} {
- if commit, err := gitCommand(repo, "rev-parse", "--verify", candidate+"^{commit}"); err == nil {
- return commit, nil
- }
- }
- return "", fmt.Errorf("base branch %q is not available locally; fetch it and try again", base)
-}
-
-func resolveFetchedOriginBaseCommit(repo, base string) (string, error) {
- canonical, err := canonicalPRBase(repo, base)
- if err != nil {
- return "", err
- }
- candidate := "refs/remotes/origin/" + canonical
- if commit, err := gitCommand(repo, "rev-parse", "--verify", candidate+"^{commit}"); err == nil {
- return commit, nil
- }
- return "", fmt.Errorf("base branch %q is not available at origin/%s; fetch it and try again", base, canonical)
-}
-
-func previewSlug(branch string) string {
- value := strings.ToLower(branch)
- var result strings.Builder
- lastDash := false
- for _, character := range value {
- if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
- result.WriteRune(character)
- lastDash = false
- } else if !lastDash && result.Len() > 0 {
- result.WriteByte('-')
- lastDash = true
- }
- }
- return strings.Trim(result.String(), "-")
-}
-
-func expectedPRPreviewPath(mode, feature, head string) (string, error) {
- switch mode {
- case "managed":
- if !featureSlugPattern.MatchString(feature) {
- return "", fmt.Errorf("managed PR context requires a lowercase kebab-case feature")
- }
- return filepath.ToSlash(filepath.Join(".product-loop", "features", feature, "pr.md")), nil
- case "ad-hoc":
- slug := previewSlug(head)
- if slug == "" {
- return "", fmt.Errorf("current branch cannot be converted into a PR brief slug")
- }
- return filepath.ToSlash(filepath.Join(".product-loop", "pr-briefs", slug, "pr.md")), nil
- default:
- return "", fmt.Errorf("unsupported PR context mode: %s", mode)
- }
-}
-
-func dirtyPaths(repo string) ([]string, error) {
- command := exec.Command("git", "-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all")
- value, err := command.Output()
- if err != nil {
- return nil, err
- }
- if len(value) == 0 {
- return nil, nil
- }
- paths := []string{}
- records := bytes.Split(value, []byte{0})
- for index := 0; index < len(records); index++ {
- record := records[index]
- if len(record) < 4 {
- continue
- }
- status := string(record[:2])
- paths = append(paths, string(record[3:]))
- if (strings.Contains(status, "R") || strings.Contains(status, "C")) && index+1 < len(records) && len(records[index+1]) > 0 {
- paths = append(paths, string(records[index+1]))
- index++
- }
- }
- return paths, nil
-}
-
-func productDiff(repo, baseCommit, previewPath string) ([]byte, []string, error) {
- pathspec := []string{"--", ".", ":(exclude).product-loop/features/*/pr.md", ":(exclude).product-loop/pr-briefs/*/pr.md"}
- arguments := append([]string{"diff", "--binary", "--no-ext-diff", baseCommit, "HEAD"}, pathspec...)
- diff, err := exec.Command("git", append([]string{"-C", repo}, arguments...)...).Output()
- if err != nil {
- return nil, nil, fmt.Errorf("cannot read product diff: %w", err)
- }
- nameArguments := append([]string{"diff", "--name-only", baseCommit, "HEAD"}, pathspec...)
- names, err := gitCommand(repo, nameArguments...)
- if err != nil {
- return nil, nil, fmt.Errorf("cannot list changed files: %w", err)
- }
- changed := []string{}
- if names != "" {
- changed = strings.Split(names, "\n")
- }
- dirty, err := dirtyPaths(repo)
- if err != nil {
- return nil, nil, err
- }
- unexpected := []string{}
- for _, path := range dirty {
- if filepath.ToSlash(path) != filepath.ToSlash(previewPath) {
- unexpected = append(unexpected, path)
- }
- }
- if len(unexpected) > 0 {
- sort.Strings(unexpected)
- return nil, nil, fmt.Errorf("commit or remove non-preview working-tree changes before preparing the PR: %s", strings.Join(unexpected, ", "))
- }
- return diff, changed, nil
-}
-
-func productDiffStat(repo, baseCommit string) (string, error) {
- pathspec := []string{"--", ".", ":(exclude).product-loop/features/*/pr.md", ":(exclude).product-loop/pr-briefs/*/pr.md"}
- arguments := append([]string{"diff", "--stat", baseCommit, "HEAD"}, pathspec...)
- return gitCommand(repo, arguments...)
-}
-
-func highRiskChangedFiles(changed, patterns []string) []string {
- result := []string{}
- for _, path := range changed {
- matched := false
- for _, pattern := range patterns {
- pattern = strings.TrimSpace(filepath.ToSlash(pattern))
- if pattern == "" {
- continue
- }
- prefix := strings.TrimSuffix(pattern, "/")
- globMatch, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(path))
- if globMatch || path == prefix || strings.HasPrefix(path, prefix+"/") {
- matched = true
- break
- }
- }
- if matched {
- result = append(result, path)
- }
- }
- sort.Strings(result)
- return result
-}
-
-func evidenceGateStatus(value, gate string) string {
- quoted := regexp.QuoteMeta(gate)
- patterns := []*regexp.Regexp{
- regexp.MustCompile(`(?mi)^\s*-\s*` + quoted + `\s+gate\s*:\s*` + "`?" + `([A-Z_]+)` + "`?" + `\s*$`),
- regexp.MustCompile(`(?mi)^\s*\|\s*` + quoted + `\s+gate\s*\|\s*` + "`?" + `([A-Z_]+)` + "`?"),
- }
- for _, pattern := range patterns {
- if match := pattern.FindStringSubmatch(value); len(match) == 2 {
- return strings.ToUpper(match[1])
- }
- }
- return ""
-}
-
-func relativeSource(repo, path, kind string) (PRSource, error) {
- hash, err := SHA256File(path)
- if err != nil {
- return PRSource{}, err
- }
- relative, err := repositoryRelativePath(repo, path)
- if err != nil {
- return PRSource{}, err
- }
- return PRSource{Kind: kind, Path: relative, SHA256: hash}, nil
-}
-
-// featureArtifactPath resolves a feature artifact that may live in either the
-// newer compiled/ subdirectory or the older feature-root layout. Candidates are
-// tried in priority order (each artifact's canonical location first); the first
-// that exists wins. When none exist the last candidate is returned so the
-// downstream check reports a clear, canonical error path rather than a guessed
-// one. This keeps the task graph and evidence resolution on one shared rule so
-// the two layouts can never silently diverge.
-func featureArtifactPath(directory string, candidates ...string) string {
- var last string
- for _, name := range candidates {
- path := filepath.Join(directory, name)
- last = path
- if fileExists(path) {
- return path
- }
- }
- return last
-}
-
-// featureEvidencePath resolves a feature's evidence ledger through the one shared
-// dual-layout rule: the feature-root copy (canonical for legacy features) first,
-// the compiled/ copy as the current-layout fallback. The delivery-gate recorder
-// and pr-context MUST both call this so they can never resolve different evidence
-// files for the same feature — the compiled-artifact analogue of the per-slice
-// addressability resolver that closed the published-slice split-brain. Hand-joining
-// a single fixed path (as the recorder once did) is exactly the drift this prevents.
-func featureEvidencePath(featureDir string) string {
- return featureArtifactPath(featureDir, "evidence.md", filepath.Join("compiled", "evidence.md"))
-}
-
-func managedPRSources(repo, feature string) ([]PRSource, map[string]string, error) {
- directory := WorkspaceFor(repo).FeatureDir(feature)
- planPath := filepath.Join(directory, "plan.md")
- approvalPath := filepath.Join(directory, "approval.md")
- lockPath := filepath.Join(directory, "plan.lock.json")
- check, err := CheckPlan(planPath)
- if err != nil {
- return nil, nil, fmt.Errorf("managed PR requires a current plan: %w", err)
- }
- config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if configErr != nil {
- return nil, nil, fmt.Errorf("managed PR requires a valid Boatstack project configuration: %w", configErr)
- }
- authorizationMode := "policy"
- autonomyPath := filepath.Join(directory, "autonomy.md")
- autonomy := AutonomyReceipt{}
- if config.Workflow.HumanPlanApproval {
- authorizationMode = "human"
- }
- if fileExists(autonomyPath) {
- autonomy, err = CheckAutonomyReceipt(autonomyPath, check, repo, RunTargetVerified, "")
- if err != nil {
- return nil, nil, fmt.Errorf("managed PR autonomy receipt is stale: %w", err)
- }
- authorizationMode = "policy"
- }
- tasksPath := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json")
- if err := CheckApprovalLock(ApprovalOptions{
- Repo: repo,
- SourcePlanPath: check.SourcePlanPath,
- SpecPath: check.SpecPath,
- PlanPath: planPath,
- TasksPath: tasksPath,
- AuthorizationMode: authorizationMode,
- OutputPath: lockPath,
- AutonomyPath: autonomyPathIf(fileExists(autonomyPath), autonomyPath),
- AutonomyFingerprint: autonomy.Fingerprint,
- RunTarget: autonomy.Target,
- }); err != nil {
- return nil, nil, fmt.Errorf("managed PR requires a current build lock: %w", err)
- }
- // After activation the immutable lock, rather than the mutable pre-activation
- // working-tree representation, is the authorization oracle. This preserves
- // authority when approved dirty content is merely committed, while plan or
- // compiled graph drift still fails the hash checks above.
- evidencePath := featureEvidencePath(directory)
- if err := checkNonEmptyFile(evidencePath, "feature evidence"); err != nil {
- return nil, nil, err
- }
- evidence, err := os.ReadFile(evidencePath)
- if err != nil {
- return nil, nil, err
- }
- deliveryState, err := LoadDeliveryState(repo, feature)
- if err != nil {
- return nil, nil, err
- }
- activeSlice, err := activeDeliverySlice(deliveryState)
- if err != nil {
- return nil, nil, err
- }
- explicitSlices := len(deliveryState.Slices) > 1 || deliveryState.Slices[0].ID != "delivery"
- gateStatus := map[string]string{
- "test": deliveryEvidenceGateStatus(string(evidence), "Test", activeSlice.ID, explicitSlices),
- "review": deliveryEvidenceGateStatus(string(evidence), "Review", activeSlice.ID, explicitSlices),
- }
- for _, gate := range []string{"test", "review"} {
- status := gateStatus[gate]
- if status != "PASS" && status != "PASS_WITH_GAPS" {
- return nil, nil, fmt.Errorf("managed PR requires %s-gate evidence marked PASS or PASS_WITH_GAPS; found %q", gate, status)
- }
- if status == "PASS_WITH_GAPS" && !config.Workflow.AllowPassWithGaps {
- return nil, nil, fmt.Errorf("managed PR %s gate violates workflow.allow_pass_with_gaps=false", gate)
- }
- }
- paths := []struct{ kind, path string }{
- {"source_plan", check.SourcePlanPath},
- {"feature_spec", check.SpecPath},
- {"plan", planPath},
- {"plan_lock", lockPath},
- {"evidence", evidencePath},
- }
- if authorizationMode == "human" {
- paths = append(paths, struct{ kind, path string }{"approval", approvalPath})
- }
- if fileExists(autonomyPath) {
- paths = append(paths, struct{ kind, path string }{"autonomy", autonomyPath})
- }
- for _, optional := range []struct{ kind, name string }{
- {"questions", "questions.md"}, {"gaps", "gaps.md"}, {"test_plan", "test-plan.md"},
- } {
- path := filepath.Join(directory, optional.name)
- if fileExists(path) {
- paths = append(paths, struct{ kind, path string }{optional.kind, path})
- }
- }
- sources := make([]PRSource, 0, len(paths))
- for _, item := range paths {
- source, err := relativeSource(repo, item.path, item.kind)
- if err != nil {
- return nil, nil, err
- }
- sources = append(sources, source)
- }
- sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
- return sources, gateStatus, nil
-}
-
-func PreparePRContext(options PRContextOptions) (PRContext, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return PRContext{}, err
- }
- ctx, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return PRContext{}, err
- }
- if strings.TrimSpace(options.Feature) == "" {
- active, activeErr := ActiveManagedDeliveries(repo)
- if activeErr != nil {
- return PRContext{}, activeErr
- }
- if len(active) > 0 {
- return PRContext{}, fmt.Errorf("ad-hoc PR preparation is disabled while managed delivery is active: %s", strings.Join(active, ", "))
- }
- }
- head, err := gitCommand(repo, "branch", "--show-current")
- if err != nil || head == "" {
- return PRContext{}, fmt.Errorf("PR preparation requires a named branch")
- }
- configPath := ctx.ProjectConfigPath()
- config, _, err := LoadConfig(configPath)
- if err != nil {
- return PRContext{}, fmt.Errorf("PR preparation requires a valid Boatstack project configuration: %w", err)
- }
- base := strings.TrimSpace(options.Base)
- if base == "" {
- base = strings.TrimSpace(config.Project.DefaultBranch)
- if base == "" {
- base = defaultPRBase(repo)
- }
- }
- base, err = canonicalPRBase(repo, base)
- if err != nil {
- return PRContext{}, err
- }
- if head == base {
- return PRContext{}, fmt.Errorf("current branch %q is the configured base branch", head)
- }
- baseCommit, err := resolveFetchedOriginBaseCommit(repo, base)
- if err != nil {
- return PRContext{}, err
- }
- mergeBaseCommit, err := gitCommand(repo, "merge-base", baseCommit, "HEAD")
- if err != nil || mergeBaseCommit == "" {
- return PRContext{}, fmt.Errorf("cannot determine the merge base between %s and %s", base, head)
- }
- headCommit, err := gitCommand(repo, "rev-parse", "HEAD")
- if err != nil {
- return PRContext{}, err
- }
- mode := "ad-hoc"
- if strings.TrimSpace(options.Feature) != "" {
- mode = "managed"
- }
- previewPath, err := expectedPRPreviewPath(mode, options.Feature, head)
- if err != nil {
- return PRContext{}, err
- }
- diff, changed, err := productDiff(repo, mergeBaseCommit, previewPath)
- if err != nil {
- return PRContext{}, err
- }
- if len(changed) == 0 {
- return PRContext{}, fmt.Errorf("branch has no committed product changes relative to %s", base)
- }
- changelogBase, err := changelogComparisonBase(repo, options.Feature, options.SliceID, mergeBaseCommit)
- if err != nil {
- return PRContext{}, err
- }
- if err := validateChangelogChange(repo, changelogBase, config); err != nil {
- return PRContext{}, err
- }
- diffStat, err := productDiffStat(repo, mergeBaseCommit)
- if err != nil {
- return PRContext{}, err
- }
- log, err := gitCommand(repo, "log", "--format=%h %s", mergeBaseCommit+"..HEAD")
- if err != nil {
- return PRContext{}, err
- }
- commits := []string{}
- if log != "" {
- commits = strings.Split(log, "\n")
- }
- configSource, err := relativeSource(repo, configPath, "project_config")
- if err != nil {
- return PRContext{}, err
- }
- sources := []PRSource{configSource}
- gateStatus := map[string]string{}
- sliceID := ""
- sliceIndex := 0
- totalSlices := 0
- safety, err := CheckRepositorySafety(repo)
- if err != nil {
- return PRContext{}, fmt.Errorf("cannot establish operational safety evidence: %w", err)
- }
- if mode == "managed" && safety.Status != "PASS" {
- return PRContext{}, fmt.Errorf("managed PR is blocked by executable irreversible capability: %s", safety.Findings[0].Category)
- }
- if mode == "managed" {
- managedSources, statuses, sourceErr := managedPRSources(repo, options.Feature)
- if sourceErr != nil {
- return PRContext{}, sourceErr
- }
- sources = append(sources, managedSources...)
- gateStatus = statuses
- state, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, options.SliceID, base, head, SHA256Bytes(diff), changed)
- if deliveryErr != nil {
- return PRContext{}, deliveryErr
- }
- // The addressable slice may be the active slice or an earlier published-open
- // slice being corrected in place; report its own 1-based position, not the
- // active pointer's.
- sliceID = slice.ID
- sliceIndex = 1
- for i, s := range state.Slices {
- if s.ID == slice.ID {
- sliceIndex = i + 1
- break
- }
- }
- totalSlices = len(state.Slices)
- sources = append(sources, gateSources...)
- }
- sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
- fingerprintPayload, err := MarshalJSON(map[string]any{
- "schema_version": prPreviewSchemaVersion,
- "mode": mode,
- "feature": options.Feature,
- "slice_id": sliceID,
- "base_branch": base,
- "head_branch": head,
- "base_commit": baseCommit,
- "merge_base_commit": mergeBaseCommit,
- "product_diff_sha256": SHA256Bytes(diff),
- "gate_status": gateStatus,
- "safety_status": safety.Status,
- "safety_findings": safety.Findings,
- "sources": sources,
- })
- if err != nil {
- return PRContext{}, err
- }
- captureDetail, err := ensureCurrentPRVisualEvidence(repo, config, mode, options.Feature, base, SHA256Bytes(diff), options.CaptureRunner)
- if err != nil {
- return PRContext{}, err
- }
- visualPolicy, visualStatus, visualCount, visualFingerprint, visualRelevance, visualSource, visualPolicySource, visualManifest, err := resolvePRVisualEvidence(
- repo, config, mode, options.Feature, head, SHA256Bytes(diff),
- )
- if err != nil {
- return PRContext{}, err
- }
- privacyStatus, privacyFingerprint, err := ResolvePRVisualPrivacyStatus(repo, visualManifest)
- if err != nil {
- return PRContext{}, err
- }
- fingerprintPayload, err = MarshalJSON(map[string]any{
- "base": json.RawMessage(fingerprintPayload),
- "pr_visual_evidence_policy": visualPolicy, "pr_visual_evidence_status": visualStatus,
- "pr_visual_evidence_count": visualCount, "pr_visual_evidence_fingerprint": visualFingerprint,
- "pr_visual_privacy_status": privacyStatus, "pr_visual_privacy_receipt_fingerprint": privacyFingerprint,
- })
- if err != nil {
- return PRContext{}, err
- }
- return PRContext{
- SchemaVersion: prPreviewSchemaVersion, Mode: mode, Feature: options.Feature, SliceID: sliceID,
- SliceIndex: sliceIndex, TotalSlices: totalSlices,
- BaseBranch: base, HeadBranch: head, BaseCommit: baseCommit, MergeBaseCommit: mergeBaseCommit, HeadCommit: headCommit,
- ProductDiffSHA256: SHA256Bytes(diff), ContextFingerprint: SHA256Bytes(fingerprintPayload),
- ChangedFiles: changed, Commits: commits, DiffStat: diffStat,
- ContextPaths: config.Project.Context, ProjectCommands: config.Project.Commands,
- HighRiskFiles: highRiskChangedFiles(changed, config.Project.HighRiskPaths),
- GateStatus: gateStatus, Sources: sources,
- SafetyStatus: safety.Status, SafetyFindings: safety.Findings,
- PRVisualEvidencePolicy: visualPolicy, PRVisualEvidenceStatus: visualStatus,
- PRVisualEvidenceCount: visualCount, PRVisualEvidenceFingerprint: visualFingerprint,
- PRVisualPrivacyStatus: privacyStatus, PRVisualPrivacyFingerprint: privacyFingerprint,
- PRVisualEvidenceRelevance: visualRelevance, PRVisualEvidenceSource: visualSource,
- PRVisualEvidencePolicySource: visualPolicySource,
- PRVisualEvidenceCaptureDetail: captureDetail,
- PRVisualEvidence: visualManifest,
- PreviewPath: previewPath,
- }, nil
-}
-
-func parsePRFrontmatter(value string) (map[string]string, string, error) {
- if !strings.HasPrefix(value, "---\n") {
- return nil, "", fmt.Errorf("PR preview must start with YAML frontmatter")
- }
- end := strings.Index(value[4:], "\n---\n")
- if end < 0 {
- return nil, "", fmt.Errorf("PR preview frontmatter is missing its closing delimiter")
- }
- frontmatter := value[4 : 4+end]
- body := strings.TrimSpace(value[4+end+len("\n---\n"):])
- fields := map[string]string{}
- allowed := map[string]bool{
- "boatstack_pr_version": true, "title": true, "mode": true, "feature": true,
- "slice": true, "base": true, "head": true, "context_fingerprint": true,
- "pr_visual_evidence_policy": true, "pr_visual_evidence_status": true,
- "pr_visual_evidence_count": true, "pr_visual_evidence_fingerprint": true,
- "pr_visual_privacy_status": true, "pr_visual_privacy_receipt_fingerprint": true,
- }
- for _, line := range strings.Split(frontmatter, "\n") {
- key, raw, found := strings.Cut(line, ":")
- if !found {
- return nil, "", fmt.Errorf("invalid PR frontmatter line: %s", line)
- }
- key = strings.TrimSpace(key)
- raw = strings.TrimSpace(raw)
- if !allowed[key] {
- return nil, "", fmt.Errorf("unsupported PR frontmatter field: %s", key)
- }
- if _, exists := fields[key]; exists {
- return nil, "", fmt.Errorf("duplicate PR frontmatter field: %s", key)
- }
- if key == "boatstack_pr_version" || key == "pr_visual_evidence_count" {
- fields[key] = raw
- continue
- }
- var decoded string
- if err := DecodeJSON("parse PR frontmatter", "field "+key, []byte(raw), &decoded); err != nil {
- return nil, "", fmt.Errorf("%w; value must be a JSON-quoted string", err)
- }
- fields[key] = decoded
- }
- for _, key := range []string{"boatstack_pr_version", "title", "mode", "feature", "base", "head", "context_fingerprint", "pr_visual_evidence_policy", "pr_visual_evidence_status", "pr_visual_evidence_count", "pr_visual_evidence_fingerprint", "pr_visual_privacy_status", "pr_visual_privacy_receipt_fingerprint"} {
- if _, exists := fields[key]; !exists {
- return nil, "", fmt.Errorf("PR frontmatter is missing %s", key)
- }
- }
- return fields, body, nil
-}
-
-func validateVisualEvidenceSection(body, status string, count int) error {
- if status == "NOT_APPLICABLE" {
- return nil
- }
- visual := section(body, "## Visual evidence")
- if visual == "" {
- return fmt.Errorf("PR body requires a non-empty Visual evidence section")
- }
- rows := 0
- for _, line := range strings.Split(visual, "\n") {
- trimmed := strings.TrimSpace(line)
- if !strings.HasPrefix(trimmed, "|") || strings.Contains(strings.ToLower(trimmed), "| scenario ") || strings.Contains(trimmed, "---") {
- continue
- }
- cells := strings.Split(strings.Trim(trimmed, "|"), "|")
- if len(cells) != 5 {
- return fmt.Errorf("Visual evidence rows require Scenario, Viewport, Commit, Result, and Publication columns")
- }
- rows++
- }
- if rows != count && status == "PASS" {
- return fmt.Errorf("Visual evidence row count %d does not match pr_visual_evidence_count %d", rows, count)
- }
- if rows == 0 {
- return fmt.Errorf("Visual evidence requires at least one structured row")
- }
- return nil
-}
-
-func section(value, heading string) string {
- start := strings.Index(value, heading)
- if start < 0 {
- return ""
- }
- remainder := value[start+len(heading):]
- if next := strings.Index(remainder, "\n## "); next >= 0 {
- remainder = remainder[:next]
- }
- return strings.TrimSpace(remainder)
-}
-
-func validateEvidenceTable(body string, mode string) error {
- evidence := section(body, "## Evidence")
- if evidence == "" {
- return fmt.Errorf("PR body requires a non-empty Evidence section")
- }
- lines := strings.Split(evidence, "\n")
- rows := 0
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if !strings.HasPrefix(trimmed, "|") || strings.Contains(strings.ToLower(trimmed), "| claim ") || strings.Contains(trimmed, "---") {
- continue
- }
- cells := strings.Split(strings.Trim(trimmed, "|"), "|")
- if len(cells) != 4 {
- return fmt.Errorf("Evidence rows require Claim, Evidence, Result, and Source columns")
- }
- status := strings.ToUpper(strings.Trim(strings.TrimSpace(cells[2]), "`"))
- if !prStatusPattern.MatchString(status) {
- return fmt.Errorf("unsupported evidence result %q", status)
- }
- if strings.TrimSpace(cells[0]) == "" || strings.TrimSpace(cells[1]) == "" || strings.TrimSpace(cells[3]) == "" {
- return fmt.Errorf("Evidence rows must include claim, evidence, and source")
- }
- if mode == "managed" && (status == "NOT_VERIFIED" || status == "BLOCKED") {
- return fmt.Errorf("managed PR evidence cannot contain %s results", status)
- }
- rows++
- }
- if rows == 0 {
- return fmt.Errorf("PR body requires at least one structured evidence row")
- }
- return nil
-}
-
-func validateManagedEvidenceSources(body string, sources []PRSource) error {
- evidencePaths := []string{}
- for _, source := range sources {
- if source.Kind == "evidence" {
- evidencePaths = append(evidencePaths, source.Path)
- }
- }
- if len(evidencePaths) == 0 {
- return fmt.Errorf("managed PR context has no current evidence source")
- }
- evidence := section(body, "## Evidence")
- for _, line := range strings.Split(evidence, "\n") {
- trimmed := strings.TrimSpace(line)
- if !strings.HasPrefix(trimmed, "|") || strings.Contains(strings.ToLower(trimmed), "| claim ") || strings.Contains(trimmed, "---") {
- continue
- }
- cells := strings.Split(strings.Trim(trimmed, "|"), "|")
- if len(cells) != 4 {
- continue
- }
- sourceCell := strings.TrimSpace(cells[3])
- matched := false
- for _, path := range evidencePaths {
- if strings.Contains(sourceCell, path) {
- matched = true
- break
- }
- }
- if !matched {
- return fmt.Errorf("managed PR evidence rows must link the current evidence ledger: %s", strings.Join(evidencePaths, " or "))
- }
- }
- return nil
-}
-
-func ParsePRPreview(path string) (PRPreview, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- return PRPreview{}, err
- }
- fields, body, err := parsePRFrontmatter(string(value))
- if err != nil {
- return PRPreview{}, err
- }
- version, err := strconv.Atoi(fields["boatstack_pr_version"])
- if err != nil || version != prPreviewSchemaVersion {
- return PRPreview{}, fmt.Errorf("boatstack_pr_version must be %d", prPreviewSchemaVersion)
- }
- preview := PRPreview{
- SchemaVersion: version, Title: strings.TrimSpace(fields["title"]), Mode: fields["mode"],
- Feature: fields["feature"], SliceID: fields["slice"], BaseBranch: fields["base"], HeadBranch: fields["head"],
- ContextFingerprint: fields["context_fingerprint"], Body: body, Path: path,
- PRVisualEvidencePolicy: fields["pr_visual_evidence_policy"], PRVisualEvidenceStatus: fields["pr_visual_evidence_status"],
- PRVisualEvidenceFingerprint: fields["pr_visual_evidence_fingerprint"],
- PRVisualPrivacyStatus: fields["pr_visual_privacy_status"], PRVisualPrivacyFingerprint: fields["pr_visual_privacy_receipt_fingerprint"],
- Fingerprint: SHA256Bytes(value),
- }
- preview.PRVisualEvidenceCount, err = strconv.Atoi(fields["pr_visual_evidence_count"])
- if err != nil || preview.PRVisualEvidenceCount < 0 || preview.PRVisualEvidenceCount > 3 {
- return PRPreview{}, fmt.Errorf("pr_visual_evidence_count must be between 0 and 3")
- }
- if preview.Title == "" || strings.Contains(preview.Title, "\n") || len([]rune(preview.Title)) > 120 {
- return PRPreview{}, fmt.Errorf("PR title must be one non-empty line of at most 120 characters")
- }
- if preview.Mode != "managed" && preview.Mode != "ad-hoc" {
- return PRPreview{}, fmt.Errorf("PR mode must be managed or ad-hoc")
- }
- if preview.Mode == "managed" && !featureSlugPattern.MatchString(preview.Feature) {
- return PRPreview{}, fmt.Errorf("managed PR preview requires a lowercase kebab-case feature")
- }
- if preview.Mode == "managed" && !featureSlugPattern.MatchString(preview.SliceID) {
- return PRPreview{}, fmt.Errorf("managed PR preview requires a lowercase kebab-case delivery slice")
- }
- if preview.Mode == "ad-hoc" && preview.Feature != "" {
- return PRPreview{}, fmt.Errorf("ad-hoc PR preview must not claim a managed feature")
- }
- if preview.Mode == "ad-hoc" && preview.SliceID != "" {
- return PRPreview{}, fmt.Errorf("ad-hoc PR preview must not claim a managed delivery slice")
- }
- if strings.TrimSpace(preview.BaseBranch) == "" || strings.TrimSpace(preview.HeadBranch) == "" {
- return PRPreview{}, fmt.Errorf("PR preview requires base and head branches")
- }
- if len(preview.ContextFingerprint) != 64 {
- return PRPreview{}, fmt.Errorf("PR preview requires a valid context fingerprint")
- }
- if preview.PRVisualEvidencePolicy != "off" && preview.PRVisualEvidencePolicy != "suggest" && preview.PRVisualEvidencePolicy != "require" {
- return PRPreview{}, fmt.Errorf("unsupported pr_visual_evidence_policy")
- }
- if !map[string]bool{"PASS": true, "PASS_WITH_GAPS": true, "NOT_VERIFIED": true, "NOT_APPLICABLE": true, "BLOCKED": true}[preview.PRVisualEvidenceStatus] {
- return PRPreview{}, fmt.Errorf("unsupported pr_visual_evidence_status")
- }
- if len(preview.PRVisualEvidenceFingerprint) != 64 {
- return PRPreview{}, fmt.Errorf("PR preview requires a valid pr_visual_evidence_fingerprint")
- }
- if !map[string]bool{"PASS": true, "REVIEW_REQUIRED": true, "NOT_APPLICABLE": true}[preview.PRVisualPrivacyStatus] {
- return PRPreview{}, fmt.Errorf("unsupported pr_visual_privacy_status")
- }
- if preview.PRVisualPrivacyStatus == "PASS" && len(preview.PRVisualPrivacyFingerprint) != 64 {
- return PRPreview{}, fmt.Errorf("PASS visual privacy review requires a valid receipt fingerprint")
- }
- if preview.PRVisualPrivacyStatus != "PASS" && preview.PRVisualPrivacyFingerprint != "" {
- return PRPreview{}, fmt.Errorf("visual privacy receipt fingerprint is allowed only for PASS")
- }
- for _, heading := range []string{
- "## Why this change", "## What changed", "## Review order", "## Evidence",
- "## Operational safety", "## Known gaps and risks", "## Rollout and rollback",
- } {
- if section(body, heading) == "" {
- return PRPreview{}, fmt.Errorf("PR body requires a non-empty %s section", strings.TrimPrefix(heading, "## "))
- }
- }
- if !strings.Contains(body, "") || !strings.Contains(body, "Boatstack provenance
") || !strings.Contains(body, " ") {
- return PRPreview{}, fmt.Errorf("PR body requires collapsed Boatstack provenance")
- }
- if err := validateEvidenceTable(body, preview.Mode); err != nil {
- return PRPreview{}, err
- }
- if err := validateVisualEvidenceSection(body, preview.PRVisualEvidenceStatus, preview.PRVisualEvidenceCount); err != nil {
- return PRPreview{}, err
- }
- return preview, nil
-}
-
-func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return PRPreview{}, PRContext{}, err
- }
- if _, err := ResolveWorkspaceContext(repo); err != nil {
- return PRPreview{}, PRContext{}, err
- }
- if !filepath.IsAbs(previewPath) {
- previewPath = filepath.Join(repo, filepath.FromSlash(previewPath))
- }
- if resolved, resolveErr := filepath.EvalSymlinks(repo); resolveErr == nil {
- repo = resolved
- }
- if resolved, resolveErr := filepath.EvalSymlinks(previewPath); resolveErr == nil {
- previewPath = resolved
- }
- if err := rejectSymlinkComponents(repo, previewPath); err != nil {
- return PRPreview{}, PRContext{}, err
- }
- preview, err := ParsePRPreview(previewPath)
- if err != nil {
- return PRPreview{}, PRContext{}, err
- }
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: preview.Feature, SliceID: preview.SliceID, Base: preview.BaseBranch})
- if err != nil {
- return PRPreview{}, PRContext{}, err
- }
- expectedPath, err := resolveRepositoryRelativePath(repo, context.PreviewPath)
- if err != nil {
- return PRPreview{}, PRContext{}, err
- }
- actualPath, err := filepath.Abs(previewPath)
- if err != nil {
- return PRPreview{}, PRContext{}, err
- }
- if resolved, resolveErr := filepath.EvalSymlinks(expectedPath); resolveErr == nil {
- expectedPath = resolved
- }
- if resolved, resolveErr := filepath.EvalSymlinks(actualPath); resolveErr == nil {
- actualPath = resolved
- }
- if filepath.Clean(expectedPath) != filepath.Clean(actualPath) {
- return PRPreview{}, PRContext{}, fmt.Errorf("PR preview must be stored at %s", context.PreviewPath)
- }
- if preview.Mode != context.Mode || preview.SliceID != context.SliceID || preview.BaseBranch != context.BaseBranch || preview.HeadBranch != context.HeadBranch || preview.ContextFingerprint != context.ContextFingerprint ||
- preview.PRVisualEvidencePolicy != context.PRVisualEvidencePolicy || preview.PRVisualEvidenceStatus != context.PRVisualEvidenceStatus ||
- preview.PRVisualEvidenceCount != context.PRVisualEvidenceCount || preview.PRVisualEvidenceFingerprint != context.PRVisualEvidenceFingerprint {
- return PRPreview{}, PRContext{}, fmt.Errorf("PR preview is stale or does not match the current branch context; regenerate it")
- }
- if preview.PRVisualPrivacyStatus != context.PRVisualPrivacyStatus || preview.PRVisualPrivacyFingerprint != context.PRVisualPrivacyFingerprint {
- return PRPreview{}, PRContext{}, fmt.Errorf("PR preview visual privacy review is stale; regenerate it")
- }
- if context.PRVisualPrivacyStatus == "REVIEW_REQUIRED" {
- return PRPreview{}, PRContext{}, fmt.Errorf("PR visual evidence requires human privacy review before check or publication")
- }
- if context.Mode == "managed" {
- if err := validateManagedEvidenceSources(preview.Body, context.Sources); err != nil {
- return PRPreview{}, PRContext{}, err
- }
- }
- return preview, context, nil
-}
-
-func ghAvailable(repo string) error {
- if _, err := exec.LookPath("gh"); err != nil {
- return fmt.Errorf("GitHub CLI is unavailable; the validated preview remains available for manual publication")
- }
- if _, err := commandOutput(repo, "gh", "auth", "status", "-h", "github.com"); err != nil {
- return fmt.Errorf("GitHub CLI is not authenticated; the validated preview remains available for manual publication")
- }
- return nil
-}
-
-func existingPRURL(repo string) (string, bool, error) {
- if err := ghAvailable(repo); err != nil {
- return "", false, err
- }
- value, err := commandOutput(repo, "gh", "pr", "view", "--json", "url", "--jq", ".url")
- if err != nil {
- message := strings.ToLower(err.Error())
- for _, expected := range []string{
- "no pull requests found", "no open pull requests", "could not resolve to a pullrequest",
- } {
- if strings.Contains(message, expected) {
- return "", false, nil
- }
- }
- return "", false, fmt.Errorf("cannot determine whether this branch already has a PR: %w", err)
- }
- if strings.TrimSpace(value) == "" {
- return "", false, nil
- }
- return strings.TrimSpace(value), true, nil
-}
-
-func RecommendedPRAction(repo string) (string, string, error) {
- repository, err := ResolveRepository(repo)
- if err != nil {
- return "", "", err
- }
- url, exists, err := existingPRURL(repository)
- if err != nil {
- return "manual", "", err
- }
- if exists {
- return "update", url, nil
- }
- return "open", "", nil
-}
-
-func revalidatePRVisualPrivacy(repo string, context PRContext) error {
- if context.PRVisualPrivacyStatus == "NOT_APPLICABLE" {
- return nil
- }
- if context.PRVisualEvidence == nil {
- return fmt.Errorf("PR visual privacy status has no current evidence manifest")
- }
- manifest, err := LoadPRVisualEvidence(repo, context.PRVisualEvidence.Key)
- if err != nil {
- return err
- }
- status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &manifest)
- if err != nil {
- return err
- }
- if status != "PASS" || status != context.PRVisualPrivacyStatus || fingerprint != context.PRVisualPrivacyFingerprint {
- return fmt.Errorf("PR visual privacy review is missing or stale; review the exact current PNGs before GitHub mutation")
- }
- return nil
-}
-
-func PublishPR(options PRPublishOptions) (string, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return "", err
- }
- preview, context, err := CheckPRPreview(repo, options.PreviewPath)
- if err != nil {
- return "", err
- }
- if strings.TrimSpace(options.ExpectedFingerprint) == "" || options.ExpectedFingerprint != preview.Fingerprint {
- return "", fmt.Errorf("publication fingerprint does not match the exact preview confirmed by the human")
- }
- if options.Action != "open" && options.Action != "update" {
- return "", fmt.Errorf("publication action must be open or update")
- }
- if strings.TrimSpace(options.AutonomyPath) != "" {
- if context.Feature == "" {
- return "", fmt.Errorf("autonomous publication requires a managed feature")
- }
- planPath := filepath.Join(WorkspaceFor(repo).FeatureDir(context.Feature), "plan.md")
- check, checkErr := CheckPlan(planPath)
- if checkErr != nil {
- return "", checkErr
- }
- if _, checkErr = CheckAutonomyReceipt(options.AutonomyPath, check, repo, RunTargetPR, options.Action); checkErr != nil {
- return "", checkErr
- }
- }
- if context.PRVisualEvidencePolicy == "require" && context.PRVisualEvidenceStatus != "PASS" {
- finding := SafetyFinding{
- Category: "workflow-visual-evidence-missing",
- Source: "publication",
- BlockingFeature: context.Feature,
- Reason: context.PRVisualEvidenceCaptureDetail,
- PolicySource: context.PRVisualEvidencePolicySource,
- }
- return "", fmt.Errorf("%s", denialWithOptions(repo, "", finding).Render(RenderPlain))
- }
- dirty, err := dirtyPaths(repo)
- if err != nil {
- return "", err
- }
- if len(dirty) > 0 {
- return "", fmt.Errorf("commit the exact reviewed pr.md before publication; working tree is not clean")
- }
- if err := ghAvailable(repo); err != nil {
- return "", err
- }
- if _, err := gitCommand(repo, "remote", "get-url", "origin"); err != nil {
- return "", fmt.Errorf("GitHub publication requires an origin remote")
- }
- existingURL, exists, err := existingPRURL(repo)
- if err != nil {
- return "", err
- }
- operationTarget := "github-pr:" + options.Action + ":" + context.HeadBranch
- operationFingerprint := preview.Fingerprint
- operationID := operationID("publish-pr", operationTarget, operationFingerprint)
- prior, priorErr := loadOperation(repo, operationID)
- partialResume := priorErr == nil && (prior.State == OperationReconcileRequired || prior.State == OperationRetryable)
- if options.Action == "open" && exists && !partialResume {
- return "", fmt.Errorf("a PR already exists for %s; regenerate the preview for update", context.HeadBranch)
- }
- if options.Action == "update" && !exists {
- return "", fmt.Errorf("no PR exists for %s; regenerate the preview for opening", context.HeadBranch)
- }
- receipt, err := PrepareOperation(OperationPrepareOptions{
- Repo: repo, Kind: "publish-pr",
- Scope: OperationScope{Feature: context.Feature, Slice: context.SliceID, Worktree: filepath.Base(repo), HeadBranch: context.HeadBranch},
- Target: operationTarget, PackageFingerprint: operationFingerprint, AuthorizationFingerprint: options.ExpectedFingerprint,
- RetryClass: "RECONCILE_FIRST", MaxAttempts: 3,
- ExpectedPostcondition: "origin contains the exact head commit and one pull request reflects the fingerprinted title, body, and visual-evidence package",
- })
- if err != nil {
- return "", err
- }
- if receipt.State == OperationSucceeded {
- if receipt.Observation.Evidence != "" {
- return receipt.Observation.Evidence, nil
- }
- if exists {
- return existingURL, nil
- }
- return "", fmt.Errorf("published operation is terminal but its PR URL is unavailable")
- }
- if receipt.State == OperationReconcileRequired {
- result := "OBSERVED_ABSENT"
- detail := "no pull request exists for the exact head branch"
- evidence := context.HeadBranch
- if exists {
- result = "OBSERVED_PARTIAL"
- detail = "the pull request exists; resume remaining idempotent publication steps"
- evidence = existingURL
- }
- receipt, err = RecordOperationReconciliation(repo, receipt.OperationID, result, detail, evidence)
- if err != nil {
- return "", err
- }
- }
- attemptKey := SHA256Bytes([]byte("publish-pr\x00" + options.Action + "\x00" + preview.Fingerprint))
- begin, err := BeginOperation(repo, receipt.OperationID, attemptKey, "boatstack-helper publish-pr")
- if err != nil {
- if errors.Is(err, ErrOperationInFlight) {
- return "", fmt.Errorf("the identical PR publication is already executing; inspect operation-status instead of repeating it")
- }
- return "", err
- }
- if begin.Receipt.State == OperationSucceeded {
- return begin.Receipt.Observation.Evidence, nil
- }
- completeUnknown := func(cause error, observedURL string) (string, error) {
- _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "UNKNOWN", "publication ended without a verifiable complete postcondition", observedURL)
- return "", cause
- }
- if err := revalidatePRVisualPrivacy(repo, context); err != nil {
- return completeUnknown(err, existingURL)
- }
- if _, err := gitCommand(repo, "push", "--set-upstream", "origin", context.HeadBranch); err != nil {
- return completeUnknown(fmt.Errorf("cannot push %s without rewriting history: %w", context.HeadBranch, err), existingURL)
- }
- temporary, err := os.CreateTemp("", "boatstack-pr-body-*.md")
- if err != nil {
- return completeUnknown(err, existingURL)
- }
- temporaryPath := temporary.Name()
- defer os.Remove(temporaryPath)
- if _, err := temporary.WriteString(preview.Body + "\n"); err != nil {
- temporary.Close()
- return completeUnknown(err, existingURL)
- }
- if err := temporary.Close(); err != nil {
- return completeUnknown(err, existingURL)
- }
- if err := revalidatePRVisualPrivacy(repo, context); err != nil {
- return completeUnknown(err, existingURL)
- }
- if options.Action == "open" {
- url := existingURL
- if !exists {
- url, err = commandOutput(repo, "gh", "pr", "create", "--base", context.BaseBranch, "--head", context.HeadBranch, "--title", preview.Title, "--body-file", temporaryPath)
- if err != nil {
- return completeUnknown(err, "")
- }
- }
- url = strings.TrimSpace(url)
- if err := publishPRVisualEvidence(repo, url, context, options.VisualPublisher); err != nil {
- return completeUnknown(err, url)
- }
- if context.Mode == "managed" {
- if err := MarkDeliveryPublished(repo, context.Feature, context.SliceID, url); err != nil {
- return completeUnknown(fmt.Errorf("PR opened but delivery state could not advance: %w", err), url)
- }
- if err := extractSystemicBoundaries(repo, context.Feature); err != nil {
- fmt.Fprintf(os.Stderr, "WARNING: could not extract systemic boundaries: %v\n", err)
- }
- }
- if _, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "pull request publication postcondition observed", url); err != nil {
- return "", err
- }
- return url, nil
- }
- if _, err := commandOutput(repo, "gh", "pr", "edit", existingURL, "--title", preview.Title, "--body-file", temporaryPath); err != nil {
- return completeUnknown(err, existingURL)
- }
- if err := publishPRVisualEvidence(repo, existingURL, context, options.VisualPublisher); err != nil {
- return completeUnknown(err, existingURL)
- }
- if context.Mode == "managed" {
- if err := MarkDeliveryPublished(repo, context.Feature, context.SliceID, existingURL); err != nil {
- return completeUnknown(fmt.Errorf("PR updated but delivery state could not advance: %w", err), existingURL)
- }
- if err := extractSystemicBoundaries(repo, context.Feature); err != nil {
- fmt.Fprintf(os.Stderr, "WARNING: could not extract systemic boundaries: %v\n", err)
- }
- }
- if _, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "pull request update postcondition observed", existingURL); err != nil {
- return "", err
- }
- return existingURL, nil
-}
-
-func extractSystemicBoundaries(repo, feature string) error {
- lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.lock.json")
- value, err := os.ReadFile(lockPath)
- if err != nil {
- return nil // if it doesn't exist, ignore
- }
- var lock map[string]any
- if err := json.Unmarshal(value, &lock); err != nil {
- return err
- }
- boundaries, ok := lock["systemic_boundaries"].([]any)
- if !ok || len(boundaries) == 0 {
- return nil
- }
- outPath := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "verified-boundaries.md")
- f, err := os.OpenFile(outPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return err
- }
- defer f.Close()
-
- for _, b := range boundaries {
- boundary, _ := b.(map[string]any)
- id := stringValue(boundary["id"])
- failureMode := stringValue(boundary["failure_mode"])
- enforcement := stringValue(boundary["enforcement_mechanism"])
- if id != "" && failureMode != "" && enforcement != "" {
- fmt.Fprintf(f, "- **%s**: Prevents `%s` using `%s`\n", id, failureMode, enforcement)
- }
- }
- return nil
-}
-
-func PRPreviewTemplate(context PRContext) string {
- quote := func(value string) string {
- encoded, _ := json.Marshal(value)
- return string(encoded)
- }
- safetySummary := "Repository safety scan: `" + context.SafetyStatus + "`. Destructive recovery remains operator-only outside Boatstack."
- lines := []string{
- "---",
- fmt.Sprintf("boatstack_pr_version: %d", prPreviewSchemaVersion),
- "title: " + quote("Describe the product or user value of this change (e.g., 'Enable historical data migration')"),
- "mode: " + quote(context.Mode),
- "feature: " + quote(context.Feature),
- "slice: " + quote(context.SliceID),
- "base: " + quote(context.BaseBranch),
- "head: " + quote(context.HeadBranch),
- "context_fingerprint: " + quote(context.ContextFingerprint),
- "pr_visual_evidence_policy: " + quote(context.PRVisualEvidencePolicy),
- "pr_visual_evidence_status: " + quote(context.PRVisualEvidenceStatus),
- fmt.Sprintf("pr_visual_evidence_count: %d", context.PRVisualEvidenceCount),
- "pr_visual_evidence_fingerprint: " + quote(context.PRVisualEvidenceFingerprint),
- "pr_visual_privacy_status: " + quote(context.PRVisualPrivacyStatus),
- "pr_visual_privacy_receipt_fingerprint: " + quote(context.PRVisualPrivacyFingerprint),
- "---",
- "## Why this change", "", "Explain the user or engineering outcome.", "",
- "## What changed", "", "| Area | Before | After | Reviewer focus |", "|---|---|---|---|", "| | | | |", "",
- "## Review order", "", "1. Start with the contract or boundary that defines the behavior.", "",
- "## Evidence", "", "| Claim | Evidence | Result | Source |", "|---|---|---|---|", "| | | `NOT_VERIFIED` | |", "",
- "## Operational safety", "", safetySummary, "",
- "## Known gaps and risks", "", "List explicit gaps or say that no material gaps are known.", "",
- "## Rollout and rollback", "", "Describe deployment impact and the smallest safe rollback.", "",
- }
- if context.PRVisualEvidenceStatus != "NOT_APPLICABLE" {
- // The Commit column names the commit the pixels were captured from;
- // evidence stays trusted across preview-only commits, so this can
- // legitimately trail HeadCommit.
- evidenceCommit := context.HeadCommit
- if context.PRVisualEvidence != nil && strings.TrimSpace(context.PRVisualEvidence.SourceCommit) != "" {
- evidenceCommit = context.PRVisualEvidence.SourceCommit
- }
- lines = append(lines,
- "## Visual evidence", "",
- "Screenshots are human-review evidence, not mechanical proof. Public-repository attachments are publicly accessible.", "",
- "| Scenario | Viewport | Commit | Result | Publication |", "|---|---|---|---|---|",
- "| Describe the approved state | viewport | "+evidenceCommit+" | `"+context.PRVisualEvidenceStatus+"` | Boatstack evidence comment or manual fallback |", "",
- )
- }
- if context.TotalSlices > 1 {
- lines = append(lines, fmt.Sprintf("> *(This is PR %d of %d in the `%s` feature)*", context.SliceIndex, context.TotalSlices, context.Feature), "")
- }
- lines = append(lines, "", "Boatstack provenance
", "", "Summarize mode, approval/evidence availability, and coding-host attribution here.", "", " ", "")
- return strings.Join(lines, "\n")
-}
-
-func PRContextJSON(context PRContext) ([]byte, error) {
- return MarshalJSON(context)
-}
-
-func PRBody(preview PRPreview) []byte {
- return bytes.TrimSpace([]byte(preview.Body))
-}
diff --git a/boatstack/pr_phase.go b/boatstack/pr_phase.go
deleted file mode 100644
index 2bacbd7..0000000
--- a/boatstack/pr_phase.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package boatstack
-
-import "strings"
-
-// PRPhase is the observed position of a published pull request between
-// publication and merge. It is derived ONLY from a live GitHub observation
-// (checks, review decision, merge state) at the moment of a read-only
-// resolution; it is never persisted, recorded by an agent, or accepted from
-// text. Anything the derivation cannot classify with certainty degrades to
-// PRPhaseUnknown, which downstream classification treats as the operator's.
-// control-law: pr-phase-derives-only-from-live-observation
-type PRPhase string
-
-const (
- // PRPhaseUnknown: the observation is missing, partial, or names a
- // combination this derivation does not understand. Fail-closed default.
- PRPhaseUnknown PRPhase = "PR_UNKNOWN"
- // PRPhaseChecksPending: the PR is open and at least one check has not finished.
- PRPhaseChecksPending PRPhase = "PR_CHECKS_PENDING"
- // PRPhaseChecksFailing: the PR is open and at least one check concluded badly.
- PRPhaseChecksFailing PRPhase = "PR_CHECKS_FAILING"
- // PRPhaseChangesRequested: a reviewer requested changes. This outranks check
- // status: a human review verdict is a stronger signal than CI and hands the
- // step to the operator regardless of what the checks are doing.
- PRPhaseChangesRequested PRPhase = "PR_CHANGES_REQUESTED"
- // PRPhaseReviewRequired: checks are green but a required review approval is
- // still owed. Granting approval is never Boatstack's or the agent's to do.
- PRPhaseReviewRequired PRPhase = "PR_REVIEW_REQUIRED"
- // PRPhaseMergeEligible: checks green, review satisfied, and GitHub reports
- // the branch cleanly mergeable.
- PRPhaseMergeEligible PRPhase = "PR_MERGE_ELIGIBLE"
- // PRPhaseMerged / PRPhaseClosed: terminal, mirrors the PR lifecycle.
- PRPhaseMerged PRPhase = "PR_MERGED"
- PRPhaseClosed PRPhase = "PR_CLOSED"
-)
-
-// prStatusCheck is one element of gh's statusCheckRollup array. GitHub emits
-// two shapes — CheckRun (Actions/checks API: status+conclusion+name) and
-// StatusContext (legacy commit status: state+context) — and this struct holds
-// the union so one decode covers both.
-type prStatusCheck struct {
- TypeName string `json:"__typename"`
- Name string `json:"name"`
- Status string `json:"status"`
- Conclusion string `json:"conclusion"`
- Context string `json:"context"`
- State string `json:"state"`
-}
-
-// prCheckSummary aggregates a statusCheckRollup. Unrecognized is sticky: one
-// entry the tables below cannot classify poisons the whole summary, because a
-// phase derived from a partially understood rollup would be a guess.
-type prCheckSummary struct {
- Total int
- Passed int
- Failed int
- Pending int
- Failing []string
- Unrecognized bool
-}
-
-// prFailingChecksCap bounds the failing-check name list carried into status
-// output so one enormous check matrix cannot flood a rendered response.
-const prFailingChecksCap = 8
-
-func summarizeCheckRollup(entries []prStatusCheck) prCheckSummary {
- summary := prCheckSummary{Total: len(entries)}
- for _, entry := range entries {
- name := strings.TrimSpace(entry.Name)
- if name == "" {
- name = strings.TrimSpace(entry.Context)
- }
- switch classifyStatusCheck(entry) {
- case "passed":
- summary.Passed++
- case "pending":
- summary.Pending++
- case "failed":
- summary.Failed++
- if name != "" && len(summary.Failing) < prFailingChecksCap {
- summary.Failing = append(summary.Failing, name)
- }
- default:
- summary.Unrecognized = true
- }
- }
- return summary
-}
-
-// classifyStatusCheck maps one rollup entry to passed/pending/failed, or ""
-// when the entry's vocabulary is not in the tables. The typename is trusted
-// first; when absent, the populated field set identifies the shape.
-func classifyStatusCheck(entry prStatusCheck) string {
- shape := strings.TrimSpace(entry.TypeName)
- if shape == "" {
- switch {
- case entry.State != "" || entry.Context != "":
- shape = "StatusContext"
- case entry.Status != "" || entry.Conclusion != "":
- shape = "CheckRun"
- }
- }
- switch shape {
- case "CheckRun":
- if !strings.EqualFold(strings.TrimSpace(entry.Status), "COMPLETED") {
- return "pending"
- }
- switch strings.ToUpper(strings.TrimSpace(entry.Conclusion)) {
- case "SUCCESS", "NEUTRAL", "SKIPPED":
- return "passed"
- case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE":
- return "failed"
- }
- case "StatusContext":
- switch strings.ToUpper(strings.TrimSpace(entry.State)) {
- case "SUCCESS":
- return "passed"
- case "PENDING", "EXPECTED":
- return "pending"
- case "FAILURE", "ERROR":
- return "failed"
- }
- }
- return ""
-}
-
-// derivePRPhase turns one live observation into a PRPhase. The derivation is
-// pure and total: every input lands somewhere, and everything outside the
-// explicitly understood combinations lands on PRPhaseUnknown. Notably absent
-// on purpose: DIRTY/BEHIND/BLOCKED/DRAFT merge states (conflicts, stale base,
-// branch protection this derivation cannot see, drafts) all stay Unknown so
-// they reach the operator instead of being guessed at.
-func derivePRPhase(prState string, checks prCheckSummary, reviewDecision, mergeState string) PRPhase {
- switch strings.ToUpper(strings.TrimSpace(prState)) {
- case "MERGED":
- return PRPhaseMerged
- case "CLOSED":
- return PRPhaseClosed
- case "OPEN":
- default:
- return PRPhaseUnknown
- }
- if checks.Unrecognized {
- return PRPhaseUnknown
- }
- decision := strings.ToUpper(strings.TrimSpace(reviewDecision))
- if decision == "CHANGES_REQUESTED" {
- return PRPhaseChangesRequested
- }
- if checks.Failed > 0 {
- return PRPhaseChecksFailing
- }
- if checks.Pending > 0 {
- return PRPhaseChecksPending
- }
- switch decision {
- case "REVIEW_REQUIRED":
- return PRPhaseReviewRequired
- case "", "APPROVED":
- default:
- return PRPhaseUnknown
- }
- // An empty rollup means no checks are configured; green-by-absence is
- // acceptable only because merge eligibility still requires GitHub itself
- // to report the branch cleanly mergeable below.
- switch strings.ToUpper(strings.TrimSpace(mergeState)) {
- case "CLEAN", "HAS_HOOKS":
- return PRPhaseMergeEligible
- }
- return PRPhaseUnknown
-}
diff --git a/boatstack/pr_phase_conformance_test.go b/boatstack/pr_phase_conformance_test.go
deleted file mode 100644
index 6df7140..0000000
--- a/boatstack/pr_phase_conformance_test.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package boatstack
-
-// control-law: pr-phase-derives-only-from-live-observation
-//
-// The post-publish PR phase (checks pending/failing, changes requested,
-// review required, merge eligible, merged, closed) is derived exclusively
-// from one live GitHub observation at read-only resolution time. It is never
-// persisted, never accepted from an agent's text, and every observation the
-// derivation does not understand with certainty degrades to PR_UNKNOWN.
-// Companion law re-pinned here: gate resolution stays network-free — a
-// non-terminal observation must leave the delivery ledger byte-identical
-// (persistObservedTerminalPRState caches terminal lifecycles only).
-//
-// Test classes: positive (each understood observation → its phase, through
-// the real ResolveNext path, over both statusCheckRollup shapes), negative
-// (degraded/malformed/unrecognized observations → PR_UNKNOWN), bypass (a
-// non-terminal observation writes nothing), failure-state (an older gh that
-// rejects the enriched field list still yields the legacy lifecycle).
-
-import (
- "errors"
- "fmt"
- "os"
- "strings"
- "testing"
-)
-
-const (
- rollupCheckRunPass = `{"__typename":"CheckRun","name":"unit","status":"COMPLETED","conclusion":"SUCCESS"}`
- rollupCheckRunFail = `{"__typename":"CheckRun","name":"unit","status":"COMPLETED","conclusion":"FAILURE"}`
- rollupCheckRunPending = `{"__typename":"CheckRun","name":"unit","status":"IN_PROGRESS","conclusion":""}`
- rollupContextPass = `{"__typename":"StatusContext","context":"ci/lint","state":"SUCCESS"}`
- rollupContextFail = `{"__typename":"StatusContext","context":"ci/lint","state":"FAILURE"}`
- rollupContextPending = `{"__typename":"StatusContext","context":"ci/lint","state":"PENDING"}`
- rollupUnrecognized = `{"__typename":"CheckRun","name":"novel","status":"COMPLETED","conclusion":"SOMETHING_NEW"}`
-)
-
-func phaseObservationPayload(prState, reviewDecision, mergeState, rollup string) func(string, ...string) (string, error) {
- return func(_ string, _ ...string) (string, error) {
- return fmt.Sprintf(
- `{"state":%q,"headRefName":"feat/phase","headRefOid":"head1","url":"https://example.invalid/pr/9","baseRefName":"main","mergeable":"MERGEABLE","mergeStateStatus":%q,"reviewDecision":%q,"statusCheckRollup":[%s]}`,
- prState, mergeState, reviewDecision, rollup), nil
- }
-}
-
-func publishedPhaseRepo(t *testing.T) string {
- t.Helper()
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "phased", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "phased", "feat/phase", "https://example.invalid/pr/9", "")
- return repo
-}
-
-// Positive: every understood live observation maps to exactly one phase,
-// through the real ResolveNext path, over both rollup shapes.
-func TestResolveNextDerivesPRPhaseFromLiveObservation(t *testing.T) {
- for _, test := range []struct {
- name string
- prState string
- reviewDecision string
- mergeState string
- rollup string
- wantPhase PRPhase
- wantStage string
- reasonContains string
- }{
- {"green_approved_clean_is_merge_eligible", "OPEN", "APPROVED", "CLEAN", rollupCheckRunPass + "," + rollupContextPass, PRPhaseMergeEligible, "PUBLISHED", "clean merge state"},
- {"no_required_review_green_clean_is_merge_eligible", "OPEN", "", "CLEAN", rollupCheckRunPass, PRPhaseMergeEligible, "PUBLISHED", "clean merge state"},
- {"no_checks_configured_green_by_absence", "OPEN", "APPROVED", "CLEAN", "", PRPhaseMergeEligible, "PUBLISHED", "clean merge state"},
- {"has_hooks_is_merge_eligible", "OPEN", "APPROVED", "HAS_HOOKS", rollupCheckRunPass, PRPhaseMergeEligible, "PUBLISHED", "clean merge state"},
- {"failing_check_run", "OPEN", "APPROVED", "CLEAN", rollupCheckRunFail + "," + rollupContextPass, PRPhaseChecksFailing, "PUBLISHED", "failing (unit)"},
- {"failing_status_context", "OPEN", "", "CLEAN", rollupCheckRunPass + "," + rollupContextFail, PRPhaseChecksFailing, "PUBLISHED", "failing (ci/lint)"},
- {"pending_check_run", "OPEN", "", "CLEAN", rollupCheckRunPending, PRPhaseChecksPending, "PUBLISHED", "still running"},
- {"pending_status_context", "OPEN", "", "CLEAN", rollupContextPending, PRPhaseChecksPending, "PUBLISHED", "still running"},
- {"review_required_after_green", "OPEN", "REVIEW_REQUIRED", "BLOCKED", rollupCheckRunPass, PRPhaseReviewRequired, "PUBLISHED", "required review approval"},
- {"changes_requested_outranks_failing_checks", "OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunFail, PRPhaseChangesRequested, "PUBLISHED", "requested changes"},
- {"merged_pr_is_terminal", "MERGED", "", "", "", PRPhaseMerged, "FEATURE_COMPLETE", "is merged"},
- {"closed_pr_is_terminal", "CLOSED", "", "", "", PRPhaseClosed, "PUBLISHED", "closed without a verified merge"},
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := publishedPhaseRepo(t)
- withRecoveryGh(t, phaseObservationPayload(test.prState, test.reviewDecision, test.mergeState, test.rollup))
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.PRPhase != string(test.wantPhase) {
- t.Fatalf("phase = %q, want %q (%#v)", status.PRPhase, test.wantPhase, status)
- }
- if status.ObservedStage != test.wantStage {
- t.Fatalf("stage = %q, want %q", status.ObservedStage, test.wantStage)
- }
- if !strings.Contains(status.Reason, test.reasonContains) {
- t.Fatalf("reason %q does not mention %q", status.Reason, test.reasonContains)
- }
- if status.NextOperation != "none" {
- t.Fatalf("phase observation must not change the prescribed operation yet: %q", status.NextOperation)
- }
- })
- }
-}
-
-// Positive: the failing-check names ride along for status output, bounded by
-// the cap so a huge check matrix cannot flood a rendered response.
-func TestFailingCheckNamesSurfaceBounded(t *testing.T) {
- repo := publishedPhaseRepo(t)
- entries := make([]string, 0, prFailingChecksCap+4)
- for i := 0; i < prFailingChecksCap+4; i++ {
- entries = append(entries, fmt.Sprintf(`{"__typename":"CheckRun","name":"job-%02d","status":"COMPLETED","conclusion":"FAILURE"}`, i))
- }
- withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", strings.Join(entries, ",")))
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.PRPhase != string(PRPhaseChecksFailing) {
- t.Fatalf("phase = %q", status.PRPhase)
- }
- if len(status.PRFailingChecks) != prFailingChecksCap {
- t.Fatalf("failing names = %d, want cap %d", len(status.PRFailingChecks), prFailingChecksCap)
- }
- if status.PRFailingChecks[0] != "job-00" {
- t.Fatalf("unexpected first failing check: %v", status.PRFailingChecks)
- }
-}
-
-// Negative: degraded, malformed, or partially understood observations all
-// land on PR_UNKNOWN and keep the pre-phase behavior intact.
-func TestPRPhaseFailsClosedToUnknown(t *testing.T) {
- for _, test := range []struct {
- name string
- gh func(string, ...string) (string, error)
- wantLifecycle string
- }{
- {"gh_unavailable", func(string, ...string) (string, error) { return "", errors.New("not authenticated") }, "PUBLISHED_UNKNOWN"},
- {"malformed_payload", func(string, ...string) (string, error) { return "not json", nil }, "PUBLISHED_UNKNOWN"},
- {"unrecognized_rollup_entry", phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupUnrecognized), "PUBLISHED_OPEN"},
- {"unrecognized_review_decision", phaseObservationPayload("OPEN", "SOMETHING_NEW", "CLEAN", rollupCheckRunPass), "PUBLISHED_OPEN"},
- {"dirty_merge_state_is_not_guessed", phaseObservationPayload("OPEN", "APPROVED", "DIRTY", rollupCheckRunPass), "PUBLISHED_OPEN"},
- {"behind_merge_state_is_not_guessed", phaseObservationPayload("OPEN", "APPROVED", "BEHIND", rollupCheckRunPass), "PUBLISHED_OPEN"},
- {"draft_merge_state_is_not_guessed", phaseObservationPayload("OPEN", "", "DRAFT", ""), "PUBLISHED_OPEN"},
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := publishedPhaseRepo(t)
- withRecoveryGh(t, test.gh)
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.PRPhase != string(PRPhaseUnknown) {
- t.Fatalf("phase = %q, want PR_UNKNOWN", status.PRPhase)
- }
- if status.Lifecycle != test.wantLifecycle {
- t.Fatalf("lifecycle = %q, want %q", status.Lifecycle, test.wantLifecycle)
- }
- if status.NextOperation != "none" {
- t.Fatalf("unexpected operation %q", status.NextOperation)
- }
- if rendered := FormatNextStatus(status); strings.Contains(rendered, "PR phase:") {
- t.Fatalf("an Unknown phase must not earn a rendered line:\n%s", rendered)
- }
- })
- }
-}
-
-// Bypass: a non-terminal observation — however rich — must leave the delivery
-// ledger byte-identical. Only a terminal lifecycle is cached, exactly as
-// before the enrichment.
-func TestNonTerminalPhaseObservationWritesNothing(t *testing.T) {
- repo := publishedPhaseRepo(t)
- statePath, err := deliveryStatePath(repo, "phased")
- if err != nil {
- t.Fatal(err)
- }
- before, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunFail))
- if _, err := ResolveNext(repo, ""); err != nil {
- t.Fatal(err)
- }
- after, err := os.ReadFile(statePath)
- if err != nil {
- t.Fatal(err)
- }
- if string(before) != string(after) {
- t.Fatal("a non-terminal observation modified the delivery ledger")
- }
-
- // Relation: the terminal cache write still happens after the enrichment.
- withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", ""))
- if _, err := ResolveNext(repo, ""); err != nil {
- t.Fatal(err)
- }
- state, err := LoadDeliveryState(repo, "phased")
- if err != nil {
- t.Fatal(err)
- }
- if state.Slices[len(state.Slices)-1].PRState != "PUBLISHED_MERGED" {
- t.Fatalf("terminal lifecycle was not cached: %#v", state.Slices)
- }
-}
-
-// Failure-state: an older gh that rejects the enriched field list must not
-// cost the basic lifecycle observation — the observer falls back to the
-// legacy field list and the phase stays Unknown.
-func TestObservationFallsBackToLegacyFieldsOnOlderGh(t *testing.T) {
- repo := publishedPhaseRepo(t)
- var requested []string
- withRecoveryGh(t, func(_ string, args ...string) (string, error) {
- fields := args[len(args)-1]
- requested = append(requested, fields)
- if strings.Contains(fields, "statusCheckRollup") {
- return "", errors.New("unknown JSON field: statusCheckRollup")
- }
- return `{"state":"OPEN","headRefName":"feat/phase","headRefOid":"head1","url":"https://example.invalid/pr/9"}`, nil
- })
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.Lifecycle != "PUBLISHED_OPEN" {
- t.Fatalf("legacy lifecycle lost: %q", status.Lifecycle)
- }
- if status.PRPhase != string(PRPhaseUnknown) {
- t.Fatalf("phase = %q, want PR_UNKNOWN on a legacy observation", status.PRPhase)
- }
- if len(requested) != 2 || requested[0] != publishedPRFields || requested[1] != publishedPRLegacyFields {
- t.Fatalf("unexpected field negotiation: %v", requested)
- }
-}
diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go
deleted file mode 100644
index 7b15057..0000000
--- a/boatstack/pr_test.go
+++ /dev/null
@@ -1,1149 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "errors"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strconv"
- "strings"
- "testing"
- "time"
-)
-
-func runGit(t *testing.T, repo string, arguments ...string) string {
- t.Helper()
- command := exec.Command("git", append([]string{"-C", repo}, arguments...)...)
- value, err := command.CombinedOutput()
- if err != nil {
- t.Fatalf("git %s: %v: %s", strings.Join(arguments, " "), err, value)
- }
- return strings.TrimSpace(string(value))
-}
-
-func prTestRepo(t *testing.T) string {
- return prTestRepoConfigured(t, nil)
-}
-
-func prTestRepoConfigured(t *testing.T, configure func(*ProjectConfig)) string {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Project.Context = []string{"README.md"}
- config.Project.HighRiskPaths = []string{"feature.go"}
- config.Workflow.IndependentReviewForHighRisk = false
- if configure != nil {
- configure(&config)
- }
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- remote := filepath.Join(t.TempDir(), "origin.git")
- if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
- t.Fatalf("git init --bare: %v: %s", err, output)
- }
- runGit(t, repo, "remote", "add", "origin", remote)
- runGit(t, repo, "push", "--set-upstream", "origin", "main")
- runGit(t, repo, "switch", "-c", "feat/reviewer-ready")
- if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "feature.go")
- runGit(t, repo, "commit", "-m", "add reviewer-visible behavior")
- return repo
-}
-
-func quoted(value string) string {
- encoded, _ := json.Marshal(value)
- return string(encoded)
-}
-
-func previewDocument(context PRContext, title, body string) string {
- return strings.Join([]string{
- "---",
- "boatstack_pr_version: 4",
- "title: " + quoted(title),
- "mode: " + quoted(context.Mode),
- "feature: " + quoted(context.Feature),
- "slice: " + quoted(context.SliceID),
- "base: " + quoted(context.BaseBranch),
- "head: " + quoted(context.HeadBranch),
- "context_fingerprint: " + quoted(context.ContextFingerprint),
- "pr_visual_evidence_policy: " + quoted(context.PRVisualEvidencePolicy),
- "pr_visual_evidence_status: " + quoted(context.PRVisualEvidenceStatus),
- "pr_visual_evidence_count: " + strconv.Itoa(context.PRVisualEvidenceCount),
- "pr_visual_evidence_fingerprint: " + quoted(context.PRVisualEvidenceFingerprint),
- "pr_visual_privacy_status: " + quoted(context.PRVisualPrivacyStatus),
- "pr_visual_privacy_receipt_fingerprint: " + quoted(context.PRVisualPrivacyFingerprint),
- "---",
- strings.TrimSpace(body),
- "",
- }, "\n")
-}
-
-func fixturePRBody(t *testing.T) string {
- t.Helper()
- value, err := os.ReadFile(filepath.Join("testdata", "reviewer-pr-body.md"))
- if err != nil {
- t.Fatal(err)
- }
- return string(value)
-}
-
-func writePreview(t *testing.T, repo string, context PRContext, title, body string) string {
- t.Helper()
- path := filepath.Join(repo, filepath.FromSlash(context.PreviewPath))
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte(previewDocument(context, title, body)), 0o644); err != nil {
- t.Fatal(err)
- }
- return path
-}
-
-func TestPRBaseCanonicalizationAcceptsOnlyOriginSpellings(t *testing.T) {
- repo := prTestRepo(t)
- for _, input := range []string{"main", "origin/main", "refs/remotes/origin/main", "refs/heads/main"} {
- t.Run(strings.ReplaceAll(input, "/", "_"), func(t *testing.T) {
- base, err := canonicalPRBase(repo, input)
- if err != nil || base != "main" {
- t.Fatalf("canonicalPRBase(%q) = %q, %v", input, base, err)
- }
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Base: input})
- if err != nil {
- t.Fatal(err)
- }
- if context.BaseBranch != "main" {
- t.Fatalf("context retained noncanonical base %q", context.BaseBranch)
- }
- })
- }
- for _, input := range []string{"refs/remotes/upstream/main", "HEAD", "-main", "refs/tags/main"} {
- if _, err := canonicalPRBase(repo, input); err == nil {
- t.Fatalf("malformed or foreign base %q was accepted", input)
- }
- }
- if _, err := resolveFetchedOriginBaseCommit(repo, "missing"); err == nil || !strings.Contains(err.Error(), "origin/missing") {
- t.Fatalf("unavailable origin ref did not fail precisely: %v", err)
- }
-}
-
-func TestAdHocPRContextAndPreviewAreEvidenceLimited(t *testing.T) {
- repo := prTestRepo(t)
- context, err := PreparePRContext(PRContextOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if context.Mode != "ad-hoc" || context.Feature != "" {
- t.Fatalf("unexpected ad-hoc context: %#v", context)
- }
- if context.PreviewPath != ".product-loop/pr-briefs/feat-reviewer-ready/pr.md" {
- t.Fatalf("unexpected preview path: %s", context.PreviewPath)
- }
- if len(context.Sources) != 1 || context.Sources[0].Kind != "project_config" || len(context.GateStatus) != 0 {
- t.Fatal("ad-hoc context must not manufacture managed provenance")
- }
- if context.DiffStat == "" || len(context.HighRiskFiles) != 1 || context.HighRiskFiles[0] != "feature.go" || len(context.ContextPaths) != 1 {
- t.Fatalf("ad-hoc context did not project review boundaries: %#v", context)
- }
- previewPath := writePreview(t, repo, context, "Make hooks and privacy fallback predictable", fixturePRBody(t))
- preview, checked, err := CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- if checked.ContextFingerprint != context.ContextFingerprint {
- t.Fatal("checked context fingerprint changed")
- }
- if strings.Contains(string(PRBody(preview)), "boatstack_pr_version") || !strings.Contains(string(PRBody(preview)), "## Security and privacy") {
- t.Fatal("rendered PR body must exclude frontmatter and preserve adaptive sections")
- }
- if !strings.Contains(preview.Body, "NOT_VERIFIED") {
- t.Fatal("ad-hoc fixture must expose unavailable evidence")
- }
- runGit(t, repo, "add", context.PreviewPath)
- runGit(t, repo, "commit", "-m", "record reviewer-ready PR preview")
- if _, _, err := CheckPRPreview(repo, previewPath); err != nil {
- t.Fatalf("committing only pr.md must not invalidate its own product-diff fingerprint: %v", err)
- }
-}
-
-func activateManagedFeature(t *testing.T, repo, feature string) string {
- t.Helper()
- return activateManagedFeatureLayout(t, repo, feature, true)
-}
-
-// activateManagedFeatureLayout activates a feature in either the newer compiled/
-// layout (compiled=true → OutDir=/compiled) or the older feature-root
-// layout (compiled=false → OutDir=, tasks.json at the feature root, no
-// compiled/ dir), so managed-PR resolution can be exercised against both.
-func activateManagedFeatureLayout(t *testing.T, repo, feature string, compiled bool) string {
- t.Helper()
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = feature
- plan["spec_path"] = "feature-spec.md"
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- if normalizedPRVisualEvidencePolicy(config.Workflow.PRVisualEvidence) != "off" {
- plan["pr_visual_evidence"] = map[string]any{
- "relevance": "relevant",
- "scenarios": []any{map[string]any{
- "id": "warning", "entry": "/onboarding", "state": "picker open", "viewport": "1440x900",
- "expected": []any{"warning visible"},
- }},
- }
- }
- if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Host plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "feature-spec.md"), []byte("# Feature spec\n\nDeliver reviewer-ready output.\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true)
- check, err := CheckPlan(filepath.Join(directory, "plan.md"))
- if err != nil {
- t.Fatal(err)
- }
- approvalPath := ""
- if config.Workflow.HumanPlanApproval {
- approvalPath = filepath.Join(directory, "approval.md")
- writeApprovalReceipt(t, approvalPath, check.Fingerprint)
- }
- outDir := directory
- if compiled {
- outDir = filepath.Join(directory, "compiled")
- }
- if err := ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: approvalPath,
- OutDir: outDir, OutputPath: filepath.Join(directory, "plan.lock.json"),
- SourceCommit: runGit(t, repo, "rev-parse", "HEAD"),
- }); err != nil {
- t.Fatal(err)
- }
- for _, artifact := range []struct{ name, body string }{
- {"questions.md", "# Questions\n\nAll material decisions answered.\n"},
- {"gaps.md", "# Gaps\n\nNo material ship-blocking gaps.\n"},
- {"test-plan.md", "# Test plan\n\nRun the approved contract check.\n"},
- } {
- if err := os.WriteFile(filepath.Join(directory, artifact.name), []byte(artifact.body), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- evidence := `# Evidence ledger
-
-- Test gate: ` + "`PASS`" + `
-- Review gate: ` + "`PASS_WITH_GAPS`" + `
-- Ship gate: ` + "`BLOCKED`" + `
-
-## Acceptance evidence
-
-| Criterion | Tasks | Result | Evidence |
-|---|---|---|---|
-| AC-1 | T-1 | ` + "`PASS`" + ` | Contract assertions passed |
-
-## Commands and checks
-
-The project checks passed.
-
-## Review findings
-
-No blocking findings.
-
-## Known gaps
-
-One non-critical portability gap is owned.
-
-## Rollout and rollback
-
-No migration; revert the feature commit.
-`
- if err := os.WriteFile(filepath.Join(directory, "evidence.md"), []byte(evidence), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".product-loop/features/"+feature)
- runGit(t, repo, "commit", "-m", "record approved feature evidence")
- for _, gate := range []string{"test", "review"} {
- status := "PASS"
- if gate == "review" {
- status = "PASS_WITH_GAPS"
- }
- if _, err := RecordDeliveryGate(DeliveryGateOptions{
- Repo: repo, Feature: feature, SliceID: "delivery", Gate: gate,
- Status: status, EvidencePath: filepath.Join(directory, "evidence.md"),
- }); err != nil {
- t.Fatalf("record %s delivery gate: %v", gate, err)
- }
- }
- return directory
-}
-
-// TestFeatureArtifactPathResolvesBothLayouts pins the layout-resolution rule
-// from the root up, independent of PR wiring: the canonical location is tried
-// first, the alternate is the fallback, and when neither exists the last
-// candidate is returned for a clear downstream error path.
-func TestFeatureArtifactPathResolvesBothLayouts(t *testing.T) {
- for _, test := range []struct {
- name string
- present []string
- want string
- }{
- {name: "only compiled", present: []string{filepath.Join("compiled", "tasks.json")}, want: filepath.Join("compiled", "tasks.json")},
- {name: "only root", present: []string{"tasks.json"}, want: "tasks.json"},
- {name: "both prefer canonical", present: []string{filepath.Join("compiled", "tasks.json"), "tasks.json"}, want: filepath.Join("compiled", "tasks.json")},
- {name: "neither returns last", present: nil, want: "tasks.json"},
- } {
- t.Run(test.name, func(t *testing.T) {
- directory := t.TempDir()
- for _, rel := range test.present {
- path := filepath.Join(directory, rel)
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
- t.Fatal(err)
- }
- }
- got := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json")
- if got != filepath.Join(directory, test.want) {
- t.Fatalf("resolved %q, want %q", got, filepath.Join(directory, test.want))
- }
- })
- }
-}
-
-// TestManagedPRSourcesAcceptsBothTaskGraphLayouts is the conformance guard for
-// the build-lock layout bug: a feature activated in the older feature-root
-// layout (tasks.json at the feature root, no compiled/ dir) must resolve its
-// build lock exactly like the newer compiled layout, so the ship-gate is not
-// blocked after build/test/review all passed.
-func TestManagedPRSourcesAcceptsBothTaskGraphLayouts(t *testing.T) {
- for _, test := range []struct {
- name string
- compiled bool
- }{
- {name: "compiled layout", compiled: true},
- {name: "feature-root layout", compiled: false},
- } {
- t.Run(test.name, func(t *testing.T) {
- repo := prTestRepo(t)
- feature := "cta-transport-feedback"
- directory := activateManagedFeatureLayout(t, repo, feature, test.compiled)
-
- if !test.compiled {
- if !fileExists(filepath.Join(directory, "tasks.json")) {
- t.Fatal("feature-root fixture must place tasks.json at the feature root")
- }
- if fileExists(filepath.Join(directory, "compiled", "tasks.json")) {
- t.Fatal("feature-root fixture must not have a compiled/tasks.json")
- }
- }
-
- sources, statuses, err := managedPRSources(repo, feature)
- if err != nil {
- t.Fatalf("managed PR sources must resolve for the %s: %v", test.name, err)
- }
- if statuses["test"] != "PASS" || statuses["review"] != "PASS_WITH_GAPS" {
- t.Fatalf("unexpected gate statuses: %+v", statuses)
- }
- found := false
- for _, source := range sources {
- if source.Kind == "plan_lock" {
- found = true
- }
- }
- if !found {
- t.Fatalf("managed PR sources missing plan_lock: %+v", sources)
- }
- })
- }
-}
-
-func TestManagedPRRechecksCurrentAuthorizationAndGapPolicy(t *testing.T) {
- t.Run("policy activation omits approval source", func(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.HumanPlanApproval = false
- })
- directory := activateManagedFeature(t, repo, "policy-feature")
- if fileExists(filepath.Join(directory, "approval.md")) {
- t.Fatal("policy activation created approval.md")
- }
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "policy-feature"})
- if err != nil {
- t.Fatal(err)
- }
- for _, source := range context.Sources {
- if source.Kind == "approval" {
- t.Fatalf("policy-activated PR claimed human approval: %#v", source)
- }
- }
- })
-
- t.Run("policy change rejects existing gaps", func(t *testing.T) {
- repo := prTestRepo(t)
- activateManagedFeature(t, repo, "gap-policy")
- configPath := filepath.Join(repo, ".product-loop", "project.json")
- config, _, err := LoadConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- config.Workflow.AllowPassWithGaps = false
- value, _ := MarshalJSON(config)
- if err := os.WriteFile(configPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".product-loop/project.json")
- runGit(t, repo, "commit", "-m", "tighten gap policy")
- if _, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "gap-policy"}); err == nil || !strings.Contains(err.Error(), "allow_pass_with_gaps=false") {
- t.Fatalf("managed PR reused a receipt forbidden by current policy: %v", err)
- }
- })
-}
-
-func managedPRBody() string {
- return `## Why this change
-
-Reviewers need a concise, evidence-backed view of the approved outcome.
-
-## What changed
-
-| Area | Before | After | Reviewer focus |
-|---|---|---|---|
-| PR preparation | Generic placeholder | Reviewer-ready evidence projection | Evidence remains traceable |
-
-## Review order
-
-1. Review the evidence boundary, then the rendered output.
-
-## Evidence
-
-| Claim | Evidence | Result | Source |
-|---|---|---|---|
-| Approved behavior is implemented | Contract assertions passed | ` + "`PASS`" + ` | [Evidence ledger](.product-loop/features/reviewer-ready/evidence.md) |
-| Review found no blocking issue | Independent diff review | ` + "`PASS_WITH_GAPS`" + ` | [Evidence ledger](.product-loop/features/reviewer-ready/evidence.md) |
-
-## Operational safety
-
-Repository safety scan passed. Destructive recovery remains operator-only.
-
-## Known gaps and risks
-
-One non-critical portability gap remains recorded with an owner.
-
-## Rollout and rollback
-
-No migration is required; revert the feature commit to roll back.
-
-
-Boatstack provenance
-
-- Mode: managed
-- Approval and gates: current
-- Coding-host attribution: recorded when known
-
-
-`
-}
-
-func TestManagedPRBlocksCommittedIrreversibleCapability(t *testing.T) {
- repo := prTestRepo(t)
- activateManagedFeature(t, repo, "reviewer-ready")
- // A committed SCRIPT that runs a destructive reset is a live capability the
- // deploy pipeline would execute — the managed PR must block it. (A declarative
- // .sql migration is data and is intentionally allowed; see safety_test.go.)
- path := filepath.Join(repo, "scripts", "recover.sh")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("#!/usr/bin/env bash\nsupabase db reset --linked\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "scripts/recover.sh")
- runGit(t, repo, "commit", "-m", "add unsafe recovery")
- if _, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"}); err == nil || !strings.Contains(err.Error(), "irreversible capability") {
- t.Fatalf("managed PR did not block committed destructive code: %v", err)
- }
-}
-
-// A managed delivery that commits a declarative migration must NOT be blocked as
-// an irreversible capability — regenerating and committing schema SQL is the
-// normal migration step, applied later by the controlled deploy pipeline. This is
-// the positive counterpart that dissolves the migration-bearing-delivery deadlock.
-func TestManagedPRAllowsDeclarativeMigration(t *testing.T) {
- repo := prTestRepo(t)
- activateManagedFeature(t, repo, "reviewer-ready")
- path := filepath.Join(repo, "schema", "generated", "staging.sql")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("DROP SCHEMA public CASCADE;\nCREATE SCHEMA public;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "schema/generated/staging.sql")
- runGit(t, repo, "commit", "-m", "regenerate staging schema")
- // PreparePRContext may fail for unrelated reasons in this fixture; the delivery
- // deadlock only exists if it fails SPECIFICALLY on the migration capability.
- if _, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"}); err != nil && strings.Contains(err.Error(), "irreversible capability") {
- t.Fatalf("declarative migration wrongly blocked the managed delivery: %v", err)
- }
-}
-
-func TestManagedPRRequiresCurrentApprovalLockAndGateEvidence(t *testing.T) {
- repo := prTestRepo(t)
- directory := activateManagedFeature(t, repo, "reviewer-ready")
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- if context.Mode != "managed" || context.GateStatus["test"] != "PASS" || context.GateStatus["review"] != "PASS_WITH_GAPS" {
- t.Fatalf("unexpected managed context: %#v", context)
- }
- previewPath := writePreview(t, repo, context, "Generate evidence-backed PR reviews", managedPRBody())
- preview, _, err := CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}); err == nil || !strings.Contains(err.Error(), "commit the exact reviewed pr.md") {
- t.Fatalf("expected uncommitted preview to block publication, got %v", err)
- }
- runGit(t, repo, "add", context.PreviewPath)
- runGit(t, repo, "commit", "-m", "record PR preview")
- preview, _, err = CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- unsupportedSource := strings.Replace(managedPRBody(), "[Evidence ledger](.product-loop/features/reviewer-ready/evidence.md)", "Unlinked summary", 1)
- if err := os.WriteFile(previewPath, []byte(previewDocument(context, preview.Title, unsupportedSource)), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, _, err := CheckPRPreview(repo, previewPath); err == nil || !strings.Contains(err.Error(), "link the current evidence ledger") {
- t.Fatalf("expected untraceable managed evidence to block, got %v", err)
- }
-
- unsafeBody := strings.Replace(managedPRBody(), "`PASS_WITH_GAPS`", "`NOT_VERIFIED`", 1)
- if err := os.WriteFile(previewPath, []byte(previewDocument(context, preview.Title, unsafeBody)), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := ParsePRPreview(previewPath); err == nil || !strings.Contains(err.Error(), "managed PR evidence") {
- t.Fatalf("expected unsupported managed claim to block, got %v", err)
- }
- if err := os.WriteFile(previewPath, []byte(previewDocument(context, preview.Title, managedPRBody())), 0o644); err != nil {
- t.Fatal(err)
- }
-
- evidencePath := filepath.Join(directory, "evidence.md")
- evidence, _ := os.ReadFile(evidencePath)
- if err := os.WriteFile(evidencePath, append(evidence, []byte("\nAdditional runtime evidence.\n")...), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", filepath.ToSlash(filepath.Join(".product-loop", "features", "reviewer-ready", "evidence.md")))
- runGit(t, repo, "commit", "-m", "add runtime evidence")
- if _, _, err := CheckPRPreview(repo, previewPath); err == nil || !strings.Contains(err.Error(), "stale") {
- t.Fatalf("expected evidence drift to invalidate preview, got %v", err)
- }
-}
-
-func TestPRPreviewRejectsMissingSectionsMalformedRowsAndStaleDiff(t *testing.T) {
- repo := prTestRepo(t)
- context, err := PreparePRContext(PRContextOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- body := fixturePRBody(t)
- previewPath := writePreview(t, repo, context, "Reviewer-ready change", body)
-
- missing := strings.Replace(body, "## Review order", "## Reading notes", 1)
- if err := os.WriteFile(previewPath, []byte(previewDocument(context, "Reviewer-ready change", missing)), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := ParsePRPreview(previewPath); err == nil || !strings.Contains(err.Error(), "Review order") {
- t.Fatalf("expected missing section failure, got %v", err)
- }
-
- malformed := strings.Replace(body, "| Hook runs without an activated environment | Repository hook smoke procedure | `NOT_VERIFIED` | Current branch test notes |", "| incomplete | row |", 1)
- if err := os.WriteFile(previewPath, []byte(previewDocument(context, "Reviewer-ready change", malformed)), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := ParsePRPreview(previewPath); err == nil || !strings.Contains(err.Error(), "Evidence rows") {
- t.Fatalf("expected malformed evidence row failure, got %v", err)
- }
-
- if err := os.WriteFile(previewPath, []byte(previewDocument(context, "Reviewer-ready change", body)), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package fixture\n\nconst Changed = true\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, _, err := CheckPRPreview(repo, previewPath); err == nil || !strings.Contains(err.Error(), "working-tree changes") {
- t.Fatalf("expected uncommitted product drift to block, got %v", err)
- }
-}
-
-func visualEvidenceBody(base, status string) string {
- return strings.TrimSpace(base) + `
-
-## Visual evidence
-
-Screenshots are human-review evidence, not mechanical proof.
-
-| Scenario | Viewport | Commit | Result | Publication |
-|---|---|---|---|---|
-| Sheet picker warning | 1440x900 | current | ` + "`" + status + "`" + ` | Boatstack evidence comment or manual fallback |
-`
-}
-
-func TestPRVisualEvidenceIsStructuralForManagedAndAdHocPreviews(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- context, err := PreparePRContext(PRContextOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if context.PRVisualEvidencePolicy != "suggest" || context.PRVisualEvidenceStatus != "NOT_VERIFIED" || context.PRVisualEvidenceCount != 0 {
- t.Fatalf("unexpected visual evidence context: %#v", context)
- }
- body := visualEvidenceBody(fixturePRBody(t), context.PRVisualEvidenceStatus)
- previewPath := writePreview(t, repo, context, "Expose visual review evidence", body)
- if _, _, err := CheckPRPreview(repo, previewPath); err != nil {
- t.Fatal(err)
- }
- withoutVisual := strings.Replace(body, "## Visual evidence", "## Screenshot notes", 1)
- if err := os.WriteFile(previewPath, []byte(previewDocument(context, "Expose visual review evidence", withoutVisual)), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := ParsePRPreview(previewPath); err == nil || !strings.Contains(err.Error(), "Visual evidence") {
- t.Fatalf("missing structural visual section was not rejected: %v", err)
- }
-}
-
-func TestManagedPRVisualEvidenceUsesApprovedPlanScenarios(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- // A plan-approved visual decision escalates suggest to require semantics,
- // so missing evidence is BLOCKED here, not a shippable NOT_VERIFIED gap.
- if context.Mode != "managed" || context.PRVisualEvidenceRelevance != "relevant" || context.PRVisualEvidenceSource != "managed-plan" || context.PRVisualEvidenceStatus != "BLOCKED" {
- t.Fatalf("managed visual decision was not projected: %#v", context)
- }
- if context.PRVisualEvidencePolicy != "require" || context.PRVisualEvidencePolicySource != "plan-escalated" {
- t.Fatalf("plan-approved scenarios did not escalate suggest: %#v", context)
- }
- previewPath := writePreview(t, repo, context, "Expose approved visual review scenario", visualEvidenceBody(managedPRBody(), context.PRVisualEvidenceStatus))
- if _, _, err := CheckPRPreview(repo, previewPath); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestRequiredPRVisualEvidenceBlocksPublicationBeforeMutation(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "require"
- })
- context, err := PreparePRContext(PRContextOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if context.PRVisualEvidenceStatus != "BLOCKED" {
- t.Fatalf("required missing evidence should block, got %s", context.PRVisualEvidenceStatus)
- }
- previewPath := writePreview(t, repo, context, "Require visual review evidence", visualEvidenceBody(fixturePRBody(t), context.PRVisualEvidenceStatus))
- preview, _, err := CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}); err == nil || !strings.Contains(err.Error(), "required visual evidence") {
- t.Fatalf("required visual evidence did not block publication: %v", err)
- }
-}
-
-// savePassVisualManifest records PASS evidence for the fixture feature's
-// approved scenario, bound to the given source commit and product diff.
-func savePassVisualManifest(t *testing.T, repo, feature, sourceCommit, diffHash string) {
- t.Helper()
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- _, _, scenarios, err := planVisualDecision(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- scenarioHash, commandHash, err := currentVisualEvidenceIdentity(scenarios, config)
- if err != nil {
- // Manual evidence remains bindable even when no automatic command is
- // registered: use the explicit empty command-set identity.
- scenarioRaw, marshalErr := MarshalJSON(scenarios)
- if marshalErr != nil {
- t.Fatal(marshalErr)
- }
- commandRaw, marshalErr := MarshalJSON(map[string]string{})
- if marshalErr != nil {
- t.Fatal(marshalErr)
- }
- scenarioHash, commandHash = SHA256Bytes(scenarioRaw), SHA256Bytes(commandRaw)
- }
- pngPath := filepath.Join(t.TempDir(), "warning.png")
- writeTestPNG(t, pngPath)
- if _, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{
- Key: feature, Policy: "suggest", Relevance: "relevant", RelevanceSource: "managed-plan",
- Status: "PASS", SourceCommit: sourceCommit, ProductDiffSHA256: diffHash, ScenarioDefinitionSHA256: scenarioHash, CaptureCommandSHA256: commandHash,
- Scenarios: []PRVisualScenario{{ID: "warning", Entry: "/onboarding", State: "picker open", Viewport: "1440x900", Expected: []string{"warning visible"}}},
- Items: []PRVisualEvidenceItem{{
- ScenarioID: "warning", Path: pngPath, Viewport: "1440x900",
- CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
- Status: "captured", PrivacyStatus: "human-reviewed",
- }},
- Publication: PRVisualPublication{State: "pending"},
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestCommittingPreviewNeverInvalidatesPassVisualEvidence(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- captureCommit := runGit(t, repo, "rev-parse", "HEAD")
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- savePassVisualManifest(t, repo, "reviewer-ready", captureCommit, context.ProductDiffSHA256)
- fresh, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- if fresh.PRVisualEvidenceStatus != "PASS" || fresh.PRVisualEvidenceCount != 1 {
- t.Fatalf("recorded PASS evidence was not trusted: %#v", fresh)
- }
- previewPath := writePreview(t, repo, fresh, "Keep evidence trusted across the preview commit", visualEvidenceBody(managedPRBody(), fresh.PRVisualEvidenceStatus))
- runGit(t, repo, "add", fresh.PreviewPath)
- runGit(t, repo, "commit", "-m", "record exact PR preview")
- committed, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- if committed.PRVisualEvidenceStatus != "PASS" {
- t.Fatalf("committing the reviewed pr.md invalidated PASS evidence: %s", committed.PRVisualEvidenceStatus)
- }
- if committed.PRVisualEvidenceFingerprint != fresh.PRVisualEvidenceFingerprint {
- t.Fatalf("preview commit changed the visual evidence fingerprint")
- }
- if _, _, err := CheckPRPreview(repo, previewPath); err != nil {
- t.Fatalf("committed preview no longer checks: %v", err)
- }
- if committed.PRVisualEvidence == nil || committed.PRVisualEvidence.SourceCommit != captureCommit {
- t.Fatalf("evidence provenance lost its capture commit: %#v", committed.PRVisualEvidence)
- }
- if template := PRPreviewTemplate(committed); !strings.Contains(template, captureCommit) {
- t.Fatalf("preview template does not name the capture commit")
- }
-}
-
-func TestProductDiffChangeInvalidatesPassVisualEvidence(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- savePassVisualManifest(t, repo, "reviewer-ready", runGit(t, repo, "rev-parse", "HEAD"), context.ProductDiffSHA256)
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- changedDiff := strings.Repeat("c", 64)
- // The plan declares relevant scenarios, so suggest ships with require
- // semantics: stale evidence is BLOCKED, never a shippable gap.
- _, status, _, _, _, _, _, _, err := resolvePRVisualEvidence(repo, config, "managed", "reviewer-ready", context.HeadBranch, changedDiff)
- if err != nil {
- t.Fatal(err)
- }
- if status != "BLOCKED" {
- t.Fatalf("product change did not stale the evidence to a block: %s", status)
- }
- config.Workflow.PRVisualEvidence = "require"
- _, status, _, _, _, _, _, _, err = resolvePRVisualEvidence(repo, config, "managed", "reviewer-ready", context.HeadBranch, changedDiff)
- if err != nil {
- t.Fatal(err)
- }
- if status != "BLOCKED" {
- t.Fatalf("require did not coerce stale evidence to BLOCKED: %s", status)
- }
-}
-
-// Invariant: a plan that promises pixels cannot ship without them — suggest
-// escalates to require semantics for plan-approved scenarios even when no
-// capture capability is registered, and the denial says why.
-func TestSuggestEscalatesToRequireWhenPlanDeclaresScenarios(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- if context.PRVisualEvidencePolicy != "require" || context.PRVisualEvidencePolicySource != "plan-escalated" || context.PRVisualEvidenceStatus != "BLOCKED" {
- t.Fatalf("plan-approved scenarios did not escalate: %#v", context)
- }
- previewPath := writePreview(t, repo, context, "Escalate approved visual scenarios", visualEvidenceBody(managedPRBody(), context.PRVisualEvidenceStatus))
- preview, _, err := CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- _, err = PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"})
- if err == nil {
- t.Fatal("escalated require did not block publication")
- }
- for _, needle := range []string{"required visual evidence", "require semantics", "capability-register"} {
- if !strings.Contains(err.Error(), needle) {
- t.Fatalf("escalated denial is missing %q:\n%s", needle, err.Error())
- }
- }
-}
-
-// The escalation predicate's full semantics table: `off` is the global
-// opt-out, not_relevant the per-feature escape, and configured require needs
-// no escalation. Capability availability is deliberately absent — a missing
-// harness is a provisioning gap, not a license to ship unverified.
-func TestVisualEscalationPredicate(t *testing.T) {
- cases := []struct {
- policy, relevance string
- scenarios int
- want bool
- }{
- {"suggest", "relevant", 1, true},
- {"suggest", "relevant", 3, true},
- {"suggest", "relevant", 0, false},
- {"suggest", "not_relevant", 0, false},
- {"suggest", "unresolved", 1, false},
- {"require", "relevant", 1, false},
- {"off", "relevant", 1, false},
- }
- for _, c := range cases {
- if got := visualEscalationApplies(c.policy, c.relevance, c.scenarios); got != c.want {
- t.Errorf("visualEscalationApplies(%s, %s, %d) = %v, want %v", c.policy, c.relevance, c.scenarios, got, c.want)
- }
- }
-}
-
-// Invariant: a not_relevant plan decision (with its reason) keeps the
-// configured suggest semantics — the per-feature escape for nonvisual changes.
-func TestNotRelevantPlanKeepsSuggestSemantics(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- directory := filepath.Join(repo, ".product-loop", "features", "log-rotation")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = "log-rotation"
- plan["pr_visual_evidence"] = map[string]any{
- "relevance": "not_relevant",
- "reason": "backend log rotation has no reviewer-visible surface",
- }
- writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true)
- config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- policy, status, _, _, _, _, policySource, _, err := resolvePRVisualEvidence(repo, config, "managed", "log-rotation", "feat/log-rotation", strings.Repeat("d", 64))
- if err != nil {
- t.Fatal(err)
- }
- if policy != "suggest" || policySource != "configured" || status != "NOT_APPLICABLE" {
- t.Fatalf("not_relevant did not keep suggest semantics: policy=%s source=%s status=%s", policy, policySource, status)
- }
-}
-
-// Invariant: declared-relevant visual evidence is captured by ship itself,
-// never prescribed to the agent, and repeat context preparation is a no-op
-// while the product diff is unchanged.
-func TestPreparePRContextAutoCapturesRelevantVisualEvidence(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- config.Project.Commands["visual"] = "repo-owned-harness"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- writeTestPNG(t, request.OutputPath)
- return nil
- }}
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner})
- if err != nil {
- t.Fatal(err)
- }
- if context.PRVisualEvidenceStatus != "PASS" || context.PRVisualEvidenceCount != 1 {
- t.Fatalf("ship preparation did not capture declared evidence itself: %#v", context)
- }
- if context.PRVisualPrivacyStatus != "REVIEW_REQUIRED" || context.PRVisualPrivacyFingerprint != "" {
- t.Fatalf("automated clean capture bypassed human privacy review: %#v", context)
- }
- if context.PRVisualEvidenceCaptureDetail != "" {
- t.Fatalf("successful auto-capture reported a gap: %s", context.PRVisualEvidenceCaptureDetail)
- }
- if runner.calls != 1 {
- t.Fatalf("expected one harness run for one scenario, got %d", runner.calls)
- }
- again, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner})
- if err != nil {
- t.Fatal(err)
- }
- if runner.calls != 1 {
- t.Fatalf("auto-capture re-ran the harness for an unchanged product diff: %d calls", runner.calls)
- }
- if again.PRVisualEvidenceFingerprint != context.PRVisualEvidenceFingerprint {
- t.Fatal("repeat context preparation destabilized the visual fingerprint")
- }
- review, err := RecordPRVisualPrivacyReview(repo, context.PRVisualEvidence.Key, context.PRVisualEvidence.Fingerprint, "reviewer@example.invalid")
- if err != nil {
- t.Fatal(err)
- }
- reviewed, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner})
- if err != nil {
- t.Fatal(err)
- }
- if reviewed.PRVisualPrivacyStatus != "PASS" || reviewed.PRVisualPrivacyFingerprint != review.Fingerprint || runner.calls != 1 {
- t.Fatalf("exact human review did not unlock current pixels: %#v", reviewed)
- }
-}
-
-// Bypass: neither check-pr nor publish-pr may contact GitHub while automated
-// pixels still lack the exact human privacy receipt.
-func TestPublishPRRunsNoGitHubCommandBeforeAutomatedPrivacyReview(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("fake gh fixture uses a POSIX shell")
- }
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- config.Project.Commands["visual"] = "repo-owned-harness"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- writeTestPNG(t, request.OutputPath)
- return nil
- }}
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner})
- if err != nil {
- t.Fatal(err)
- }
- previewPath := writePreview(t, repo, context, "Require exact pixel privacy review", visualEvidenceBody(managedPRBody(), context.PRVisualEvidenceStatus))
- preview, err := ParsePRPreview(previewPath)
- if err != nil {
- t.Fatal(err)
- }
- fakeDir := t.TempDir()
- logPath := filepath.Join(fakeDir, "gh.log")
- script := filepath.Join(fakeDir, "gh")
- if err := os.WriteFile(script, []byte("#!/bin/sh\necho called >> \"$BOATSTACK_GH_LOG\"\nexit 1\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
- t.Setenv("BOATSTACK_GH_LOG", logPath)
- _, err = PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"})
- if err == nil || !strings.Contains(err.Error(), "privacy review") {
- t.Fatalf("pre-review publication did not fail at privacy boundary: %v", err)
- }
- if _, statErr := os.Stat(logPath); !os.IsNotExist(statErr) {
- t.Fatalf("GitHub command ran before privacy review: %v", statErr)
- }
-}
-
-// Invariant: a failing harness never errors context preparation — it records
-// a bounded detail, and the plan-escalated require semantics hold the block.
-func TestAutoCaptureFailureRecordsBoundedDetail(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- config.Project.Commands["visual"] = "repo-owned-harness"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- return errors.New("dev server unreachable")
- }}
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner})
- if err != nil {
- t.Fatal(err)
- }
- if context.PRVisualEvidenceStatus != "BLOCKED" {
- t.Fatalf("escalated require should block on a failed capture, got %s", context.PRVisualEvidenceStatus)
- }
- if !strings.Contains(context.PRVisualEvidenceCaptureDetail, "dev server unreachable") {
- t.Fatalf("capture detail lost the harness failure: %q", context.PRVisualEvidenceCaptureDetail)
- }
-}
-
-// Invariant (zero-value): without a registered repository command the runner
-// is never consulted and behavior is exactly the prior prescribed path.
-func TestAutoCaptureUnavailableCapabilityFallsBackToPrescribedPath(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "suggest"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- runner := &stubCaptureRunner{write: func(request CaptureRequest) error {
- t.Fatal("runner must not run without a registered repository command")
- return nil
- }}
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner})
- if err != nil {
- t.Fatal(err)
- }
- if runner.calls != 0 || context.PRVisualEvidenceStatus != "BLOCKED" {
- t.Fatalf("unavailable capability did not fall back cleanly: calls=%d status=%s", runner.calls, context.PRVisualEvidenceStatus)
- }
- if !strings.Contains(context.PRVisualEvidenceCaptureDetail, "capability-register") {
- t.Fatalf("capture detail does not name the provisioning verb: %q", context.PRVisualEvidenceCaptureDetail)
- }
-}
-
-// Invariant: the require block is a calm denial carrying the computed
-// solution set, so a blocked publication names its own recovery ladder.
-func TestRequiredVisualEvidenceDenialCarriesSolutionSet(t *testing.T) {
- repo := prTestRepoConfigured(t, func(config *ProjectConfig) {
- config.Workflow.PRVisualEvidence = "require"
- })
- activateManagedFeature(t, repo, "reviewer-ready")
- context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"})
- if err != nil {
- t.Fatal(err)
- }
- if context.PRVisualEvidenceStatus != "BLOCKED" {
- t.Fatalf("require with no evidence should block, got %s", context.PRVisualEvidenceStatus)
- }
- previewPath := writePreview(t, repo, context, "Require visual review evidence", visualEvidenceBody(managedPRBody(), context.PRVisualEvidenceStatus))
- preview, _, err := CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- _, err = PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"})
- if err == nil {
- t.Fatal("required visual evidence did not block publication")
- }
- for _, needle := range []string{"required visual evidence", "capture-evidence", "reviewer-ready"} {
- if !strings.Contains(err.Error(), needle) {
- t.Fatalf("denial is missing %q:\n%s", needle, err.Error())
- }
- }
-}
-
-func TestPublishPRRequiresExactConfirmationAndUsesBodyWithoutFrontmatter(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("fake gh fixture uses a POSIX shell; publication behavior is covered by cross-platform pure-Go checks")
- }
- repo := prTestRepo(t)
- context, err := PreparePRContext(PRContextOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- previewPath := writePreview(t, repo, context, "Make hooks and privacy fallback predictable", fixturePRBody(t))
- preview, _, err := CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}); err == nil || !strings.Contains(err.Error(), "commit the exact reviewed pr.md") {
- t.Fatalf("expected uncommitted preview to block publication, got %v", err)
- }
- runGit(t, repo, "add", context.PreviewPath)
- runGit(t, repo, "commit", "-m", "record exact PR preview")
- preview, _, err = CheckPRPreview(repo, previewPath)
- if err != nil {
- t.Fatal(err)
- }
- fakeDir := t.TempDir()
- capture := filepath.Join(fakeDir, "body.md")
- script := filepath.Join(fakeDir, "gh")
- scriptBody := `#!/bin/sh
-if [ "$1" = "auth" ]; then exit 0; fi
-if [ "$1" = "pr" ] && [ "$2" = "view" ]; then
- if [ "$BOATSTACK_GH_ERROR" = "1" ]; then echo "network timeout" >&2; exit 1; fi
- if [ "$BOATSTACK_EXISTING_PR" = "1" ]; then echo "https://github.com/example/repo/pull/7"; exit 0; fi
- echo "no pull requests found for branch" >&2
- exit 1
-fi
-if [ "$1" = "pr" ] && [ "$2" = "create" ]; then
- while [ "$#" -gt 0 ]; do
- if [ "$1" = "--body-file" ]; then shift; cp "$1" "$BOATSTACK_BODY_CAPTURE"; fi
- shift
- done
- echo "https://github.com/example/repo/pull/8"
- exit 0
-fi
-if [ "$1" = "pr" ] && [ "$2" = "edit" ]; then
- while [ "$#" -gt 0 ]; do
- if [ "$1" = "--body-file" ]; then shift; cp "$1" "$BOATSTACK_BODY_CAPTURE"; fi
- shift
- done
- exit 0
-fi
-exit 1
-`
- if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
- t.Setenv("BOATSTACK_BODY_CAPTURE", capture)
- t.Setenv("BOATSTACK_GH_ERROR", "1")
- if action, _, err := RecommendedPRAction(repo); action != "manual" || err == nil || !strings.Contains(err.Error(), "cannot determine") {
- t.Fatalf("expected GitHub lookup failure to remain distinct from no PR, got action=%s err=%v", action, err)
- }
- t.Setenv("BOATSTACK_GH_ERROR", "")
- if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: "wrong", Action: "open"}); err == nil || !strings.Contains(err.Error(), "confirmed") {
- t.Fatalf("expected exact confirmation fingerprint, got %v", err)
- }
- url, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"})
- if err != nil {
- t.Fatal(err)
- }
- if url != "https://github.com/example/repo/pull/8" {
- t.Fatalf("unexpected created PR URL: %s", url)
- }
- publishedBody, _ := os.ReadFile(capture)
- if strings.Contains(string(publishedBody), "boatstack_pr_version") || !strings.Contains(string(publishedBody), "## Why this change") {
- t.Fatal("publisher did not strip preview frontmatter")
- }
-
- t.Setenv("BOATSTACK_EXISTING_PR", "1")
- if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}); err == nil || !strings.Contains(err.Error(), "already exists") {
- t.Fatalf("expected open/update mismatch to block, got %v", err)
- }
- url, err = PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "update"})
- if err != nil {
- t.Fatal(err)
- }
- if url != "https://github.com/example/repo/pull/7" {
- t.Fatalf("unexpected updated PR URL: %s", url)
- }
-}
diff --git a/boatstack/provenance.go b/boatstack/provenance.go
deleted file mode 100644
index 93063a4..0000000
--- a/boatstack/provenance.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package boatstack
-
-import (
- "context"
- "fmt"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-)
-
-// readBinaryIdentity returns the (version, sourceCommit) a helper binary reports
-// for itself. It is a package var so conformance tests can substitute a candidate
-// identity without building real multi-version binaries (mirrors recoveryGh).
-var readBinaryIdentity = execBinaryIdentity
-
-// execBinaryIdentity executes ` version` and parses the helper's own
-// self-report. The generated bundle and version constants are embedded in each
-// binary, so the only authoritative source of a binary's identity is the binary
-// itself — never the running process's compile-time globals.
-func execBinaryIdentity(path string) (string, string, error) {
- absolute, err := filepath.Abs(path)
- if err != nil {
- return "", "", err
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- output, err := exec.CommandContext(ctx, absolute, "version").Output()
- if err != nil {
- return "", "", fmt.Errorf("run %q version: %w", absolute, err)
- }
- return parseVersionOutput(string(output))
-}
-
-// parseVersionOutput reads the `version` subcommand line, formatted exactly as
-// `Boatstack ()`.
-func parseVersionOutput(output string) (string, string, error) {
- trimmed := strings.TrimSpace(output)
- const prefix = "Boatstack "
- if !strings.HasPrefix(trimmed, prefix) {
- return "", "", fmt.Errorf("unrecognized helper version output: %q", trimmed)
- }
- rest := strings.TrimPrefix(trimmed, prefix)
- open := strings.LastIndex(rest, " (")
- if open < 0 || !strings.HasSuffix(rest, ")") {
- return "", "", fmt.Errorf("unrecognized helper version output: %q", trimmed)
- }
- version := strings.TrimSpace(rest[:open])
- sourceCommit := strings.TrimSpace(rest[open+2 : len(rest)-1])
- if version == "" || sourceCommit == "" {
- return "", "", fmt.Errorf("incomplete helper identity in version output: %q", trimmed)
- }
- return version, sourceCommit, nil
-}
diff --git a/boatstack/provision.go b/boatstack/provision.go
deleted file mode 100644
index 7fdd0c3..0000000
--- a/boatstack/provision.go
+++ /dev/null
@@ -1,248 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-// FrontendStack are the detected facts about a repository's frontend tooling. It
-// is the input to a context-aware provisioning guide: it is discovered, never
-// imposed, so a repository without a frontend framework is reported honestly
-// rather than forced to adopt one.
-type FrontendStack struct {
- Framework string `json:"framework"` // next | vite | react | vue | svelte | angular | none
- PackageManager string `json:"package_manager"` // npm | pnpm | yarn | bun | none
- DevCommand string `json:"dev_command,omitempty"`
- HasPlaywright bool `json:"has_playwright"`
- HasCypress bool `json:"has_cypress"`
- HasStorybook bool `json:"has_storybook"`
-}
-
-// detectFrontendStack reads package.json, lockfiles, and known config files to
-// report a repository's frontend facts. It mirrors detectTestCommand's idiom:
-// evidence-driven, no network, and silent (all-zero) when nothing is found.
-func detectFrontendStack(repo string) FrontendStack {
- stack := FrontendStack{Framework: "none", PackageManager: "none"}
- var manifest struct {
- Scripts map[string]string `json:"scripts"`
- Dependencies map[string]string `json:"dependencies"`
- DevDependencies map[string]string `json:"devDependencies"`
- }
- value, err := os.ReadFile(filepath.Join(repo, "package.json"))
- if err != nil || json.Unmarshal(value, &manifest) != nil {
- return stack
- }
-
- stack.PackageManager = detectPackageManager(repo)
- dependency := func(name string) bool {
- _, direct := manifest.Dependencies[name]
- _, dev := manifest.DevDependencies[name]
- return direct || dev
- }
- switch {
- case dependency("next"):
- stack.Framework = "next"
- case dependency("@angular/core"):
- stack.Framework = "angular"
- case dependency("svelte"):
- stack.Framework = "svelte"
- case dependency("vue"):
- stack.Framework = "vue"
- case dependency("vite"):
- stack.Framework = "vite"
- case dependency("react"):
- stack.Framework = "react"
- }
- for _, name := range []string{"dev", "start", "serve"} {
- if strings.TrimSpace(manifest.Scripts[name]) != "" {
- stack.DevCommand = packageManagerRun(stack.PackageManager, name)
- break
- }
- }
- stack.HasPlaywright = dependency("@playwright/test") || dependency("playwright") ||
- fileExists(filepath.Join(repo, "playwright.config.ts")) || fileExists(filepath.Join(repo, "playwright.config.js"))
- stack.HasCypress = dependency("cypress") ||
- fileExists(filepath.Join(repo, "cypress.config.ts")) || fileExists(filepath.Join(repo, "cypress.config.js"))
- stack.HasStorybook = dependency("storybook") || dependency("@storybook/react") ||
- dirExists(filepath.Join(repo, ".storybook"))
- return stack
-}
-
-func detectPackageManager(repo string) string {
- switch {
- case fileExists(filepath.Join(repo, "pnpm-lock.yaml")):
- return "pnpm"
- case fileExists(filepath.Join(repo, "yarn.lock")):
- return "yarn"
- case fileExists(filepath.Join(repo, "bun.lock")), fileExists(filepath.Join(repo, "bun.lockb")):
- return "bun"
- default:
- return "npm"
- }
-}
-
-func packageManagerRun(manager, script string) string {
- if manager == "" || manager == "none" {
- manager = "npm"
- }
- return manager + " run " + script
-}
-
-func dirExists(path string) bool {
- info, err := os.Stat(path)
- return err == nil && info.IsDir()
-}
-
-// ProvisionGuide is the context-aware answer to "this repository cannot yet
-// produce evidence — how do we help?". Boatstack ships the contract
-// the in-repository harness must satisfy plus stack-tailored steps; the harness
-// itself is authored in the user's repository, never shipped by Boatstack.
-type ProvisionGuide struct {
- Capability string `json:"capability"`
- Tier string `json:"tier"` // available | provision | unsupported
- Available bool `json:"available"`
- ResolvedCommand string `json:"resolved_command,omitempty"`
- Stack FrontendStack `json:"stack"`
- SuggestedAlias string `json:"suggested_alias,omitempty"`
- SuggestedCommand string `json:"suggested_command,omitempty"`
- Contract []string `json:"contract"`
- Steps []string `json:"steps"`
-}
-
-// captureContract is the framework-agnostic contract every capture harness must
-// satisfy, expressed as the environment the harness is invoked with. It mirrors
-// execCaptureRunner so the guide and the runtime never drift.
-var captureContract = []string{
- "Boatstack invokes the registered command once per scenario.",
- "BOATSTACK_CAPTURE_CAPABILITY — the capability being captured (e.g. visual).",
- "BOATSTACK_CAPTURE_SCENARIO_ID — the plan scenario id.",
- "BOATSTACK_CAPTURE_ENTRY — the scenario entry point (route or component).",
- "BOATSTACK_CAPTURE_STATE — the scenario state to render.",
- "BOATSTACK_CAPTURE_VIEWPORT — the required viewport, e.g. 1440x900.",
- "BOATSTACK_CAPTURE_SURFACE — the scenario's declared product surface (empty when undeclared).",
- "BOATSTACK_CAPTURE_OUTPUT — the absolute path the harness must write exactly one PNG to.",
- "Render fixture or mock data only; never production secrets or PII.",
-}
-
-// CapabilityProvisionGuide composes capability detection with frontend-stack
-// facts to produce a context-aware provisioning guide for a repository.
-func CapabilityProvisionGuide(repo, name string) (ProvisionGuide, error) {
- resolved, err := ResolveRepository(repo)
- if err != nil {
- return ProvisionGuide{}, err
- }
- if strings.TrimSpace(name) == "" {
- name = "visual"
- }
- capability, ok := LookupCapability(name)
- if !ok {
- return ProvisionGuide{}, fmt.Errorf("unknown evidence capability %q", name)
- }
- config, _, err := LoadConfig(WorkspaceFor(resolved).ProjectConfigPath())
- if err != nil {
- return ProvisionGuide{}, fmt.Errorf("provisioning requires a valid Boatstack project configuration: %w", err)
- }
- resolution, err := ResolveCapability(name, config)
- if err != nil {
- return ProvisionGuide{}, err
- }
-
- guide := ProvisionGuide{Capability: capability.Name, Contract: captureContract, Stack: detectFrontendStack(resolved)}
- if resolution.Kind == "repository-command" {
- guide.Tier = "available"
- guide.Available = true
- guide.ResolvedCommand = resolution.Command
- guide.Steps = []string{
- fmt.Sprintf("%s evidence is already wired: project.commands resolves %q.", capability.Name, resolution.Command),
- "Run capture-evidence to generate evidence for the active feature.",
- }
- return guide, nil
- }
-
- guide.SuggestedAlias = capability.Name
- if guide.Stack.Framework == "none" {
- guide.Tier = "unsupported"
- guide.Steps = []string{
- "No frontend framework was detected, so visual evidence cannot be captured yet.",
- "Boatstack never adds a framework for you: if this change is user-visible, set one up (or opt out by recording the scenario as not_relevant).",
- "Once a framework and dev command exist, re-run provisioning to get a stack-tailored harness guide.",
- }
- return guide, nil
- }
-
- guide.Tier = "provision"
- guide.SuggestedCommand = packageManagerRun(guide.Stack.PackageManager, "capture:"+capability.Name)
- guide.Steps = provisionSteps(guide.Stack, guide.SuggestedAlias, guide.SuggestedCommand)
- return guide, nil
-}
-
-func provisionSteps(stack FrontendStack, alias, suggestedCommand string) []string {
- steps := []string{
- fmt.Sprintf("Detected a %s app using %s.", stack.Framework, stack.PackageManager),
- }
- if stack.HasPlaywright {
- steps = append(steps, "Reuse the existing Playwright setup: add a script that reads the BOATSTACK_CAPTURE_* env and screenshots the scenario to BOATSTACK_CAPTURE_OUTPUT.")
- } else {
- steps = append(steps, "Add a headless rasterizer (Playwright is the least-effort fit) that renders one scenario and writes it to BOATSTACK_CAPTURE_OUTPUT.")
- }
- if stack.HasStorybook {
- steps = append(steps, "Storybook is present: map each scenario id to a story so the harness renders isolated component state with fixture data.")
- }
- steps = append(steps,
- fmt.Sprintf("Expose the harness as a project command, then register it: capability-register --capability %s --command \"%s\".", alias, suggestedCommand),
- "Confirm registration with provision-capability; its tier should flip to available. Then run capture-evidence.",
- )
- return steps
-}
-
-// RegisteredCapability reports the outcome of registering a capability command.
-type RegisteredCapability struct {
- Capability string `json:"capability"`
- Alias string `json:"alias"`
- Command string `json:"command"`
- Source string `json:"source"` // source-and-export | generated-only
-}
-
-// RegisterCapabilityCommand records a repository-owned command for a capability
-// through the shared authority-aware configuration mutation boundary.
-func RegisterCapabilityCommand(repo, name, surface, command string) (RegisteredCapability, error) {
- resolved, err := ResolveRepository(repo)
- if err != nil {
- return RegisteredCapability{}, err
- }
- capability, ok := LookupCapability(strings.TrimSpace(name))
- if !ok {
- return RegisteredCapability{}, fmt.Errorf("unknown evidence capability %q", name)
- }
- command = strings.TrimSpace(command)
- if command == "" {
- return RegisteredCapability{}, fmt.Errorf("capability-register requires a non-empty --command")
- }
- // A surface scopes the registration to one product surface's harness
- // (project.commands["visual:"]); empty keeps the global key.
- alias := capability.Name
- if surface = strings.TrimSpace(surface); surface != "" {
- if !surfaceSlugPattern.MatchString(surface) {
- return RegisteredCapability{}, fmt.Errorf("capability-register --surface must be a lowercase kebab slug")
- }
- alias = capability.Name + ":" + surface
- }
-
- mutation, err := mutateManagedConfiguration(resolved, func(config *ProjectConfig) (bool, error) {
- if config.Project.Commands == nil {
- config.Project.Commands = map[string]string{}
- }
- if config.Project.Commands[alias] == command {
- return false, nil
- }
- config.Project.Commands[alias] = command
- return true, nil
- })
- if err != nil {
- return RegisteredCapability{}, err
- }
- return RegisteredCapability{Capability: capability.Name, Alias: alias, Command: command, Source: mutation.Source}, nil
-}
diff --git a/boatstack/provision_test.go b/boatstack/provision_test.go
deleted file mode 100644
index 48ec063..0000000
--- a/boatstack/provision_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func writeProjectConfig(t *testing.T, repo string, mutate func(*ProjectConfig)) {
- t.Helper()
- runGit(t, repo, "init", "-b", "main")
- config := testConfig()
- config.Project.DefaultBranch = "main"
- if mutate != nil {
- mutate(&config)
- }
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestDetectFrontendStackReadsManifestAndTooling(t *testing.T) {
- repo := t.TempDir()
- manifest := `{
- "scripts": {"dev": "vite", "test": "vitest"},
- "dependencies": {"react": "^18.0.0", "vite": "^5.0.0"},
- "devDependencies": {"@playwright/test": "^1.40.0"}
- }`
- if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(manifest), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "pnpm-lock.yaml"), []byte("lockfileVersion: '9.0'\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- stack := detectFrontendStack(repo)
- if stack.Framework != "vite" || stack.PackageManager != "pnpm" || stack.DevCommand != "pnpm run dev" || !stack.HasPlaywright {
- t.Fatalf("unexpected stack facts: %#v", stack)
- }
-}
-
-func TestDetectFrontendStackReportsNoneWithoutManifest(t *testing.T) {
- stack := detectFrontendStack(t.TempDir())
- if stack.Framework != "none" || stack.PackageManager != "none" || stack.DevCommand != "" {
- t.Fatalf("empty repo should report no frontend stack: %#v", stack)
- }
-}
-
-func TestProvisionGuideReportsAvailableWhenCommandResolves(t *testing.T) {
- repo := t.TempDir()
- writeProjectConfig(t, repo, func(config *ProjectConfig) {
- config.Project.Commands["visual"] = "pnpm run capture:visual"
- })
- guide, err := CapabilityProvisionGuide(repo, "visual")
- if err != nil {
- t.Fatal(err)
- }
- if guide.Tier != "available" || !guide.Available || guide.ResolvedCommand != "pnpm run capture:visual" {
- t.Fatalf("resolved capability should be available: %#v", guide)
- }
-}
-
-func TestProvisionGuideTailorsStepsToDetectedStack(t *testing.T) {
- repo := t.TempDir()
- writeProjectConfig(t, repo, nil) // no visual command → must provision
- manifest := `{"scripts": {"dev": "next dev", "test": "jest"}, "dependencies": {"next": "^14.0.0"}}`
- if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(manifest), 0o644); err != nil {
- t.Fatal(err)
- }
- guide, err := CapabilityProvisionGuide(repo, "visual")
- if err != nil {
- t.Fatal(err)
- }
- if guide.Tier != "provision" || guide.Available {
- t.Fatalf("missing command should require provisioning: %#v", guide)
- }
- if guide.Stack.Framework != "next" || guide.SuggestedCommand != "npm run capture:visual" {
- t.Fatalf("guide did not reflect the detected stack: %#v", guide)
- }
- if len(guide.Contract) == 0 || len(guide.Steps) == 0 {
- t.Fatalf("provision guide must ship a contract and steps: %#v", guide)
- }
-}
-
-func TestProvisionGuideReportsUnsupportedWithoutFrontend(t *testing.T) {
- repo := t.TempDir()
- writeProjectConfig(t, repo, nil) // no command, no package.json
- guide, err := CapabilityProvisionGuide(repo, "visual")
- if err != nil {
- t.Fatal(err)
- }
- if guide.Tier != "unsupported" || guide.SuggestedCommand != "" {
- t.Fatalf("a backend-only repo should be unsupported, never forced: %#v", guide)
- }
-}
-
-func TestRegisterCapabilityCommandSyncsSourceAndExport(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- config := testConfig()
- config.Project.DefaultBranch = "main"
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- // Seed a canonical source plus its generated export, as a real install would.
- if err := os.WriteFile(filepath.Join(repo, ".boatstack-project.json"), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
-
- result, err := RegisterCapabilityCommand(repo, "visual", "", "pnpm run capture:visual")
- if err != nil {
- t.Fatal(err)
- }
- if result.Source != "source-and-export" || result.Alias != "visual" {
- t.Fatalf("unexpected registration outcome: %#v", result)
- }
- // Both the source and the generated export must now resolve the command.
- source, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- generated, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- if source.Project.Commands["visual"] != "pnpm run capture:visual" || generated.Project.Commands["visual"] != "pnpm run capture:visual" {
- t.Fatalf("source and export drifted: source=%q export=%q", source.Project.Commands["visual"], generated.Project.Commands["visual"])
- }
- // And provisioning must now report the capability as available.
- guide, err := CapabilityProvisionGuide(repo, "visual")
- if err != nil {
- t.Fatal(err)
- }
- if guide.Tier != "available" {
- t.Fatalf("registration did not make the capability available: %#v", guide)
- }
-}
-
-func TestRegisterCapabilityCommandFallsBackToGeneratedConfig(t *testing.T) {
- repo := t.TempDir()
- writeProjectConfig(t, repo, nil) // generated project.json only, no source
- result, err := RegisterCapabilityCommand(repo, "visual", "", "npm run capture:visual")
- if err != nil {
- t.Fatal(err)
- }
- if result.Source != "generated-only" {
- t.Fatalf("expected generated-only registration: %#v", result)
- }
- generated, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- if generated.Project.Commands["visual"] != "npm run capture:visual" {
- t.Fatalf("command was not persisted: %#v", generated.Project.Commands)
- }
-}
diff --git a/boatstack/publication_ignored_repro_test.go b/boatstack/publication_ignored_repro_test.go
deleted file mode 100644
index 77ed236..0000000
--- a/boatstack/publication_ignored_repro_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package boatstack
-
-import "testing"
-
-// TestPublicationBypassHonorsIgnoredDeliveries reproduces the external report on
-// PR #322: `next`/`run` scope ambiguity through workflow.ignored_deliveries
-// (see TestResolveNextIgnoredActiveDeliveryClearsAmbiguity), but the
-// publication-authority path — publicationBypassFinding, which emits
-// relation=ambiguous on a denied push — iterates ActiveManagedDeliveries
-// directly and never filters ignored slugs. A single ignored, stale-but-active
-// delivery (agentic-l3-full: APPROVED lock, never published) therefore poisons
-// publication authority for every other delivery.
-//
-// This mirrors the `next` test: two active deliveries, one ignored, with the
-// remaining delivery bound to the current branch. The finding must not be
-// ambiguous.
-func TestPublicationBypassHonorsIgnoredDeliveries(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "agentic-l3-full", "BUILD", 0) // stale, ignored blocker
- writeNextDelivery(t, repo, "roles-access-policies", "BUILD", 0)
- state, err := LoadDeliveryState(repo, "roles-access-policies")
- if err != nil {
- t.Fatal(err)
- }
- state.Slices[0].HeadBranch = gitOutput(repo, "branch", "--show-current")
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- setIgnoredDeliveries(t, repo, "agentic-l3-full")
-
- finding, blocked := publicationBypassFinding(repo, "denied push", "tool-input")
- if !blocked {
- t.Fatalf("expected a publication finding for the remaining active delivery")
- }
- if finding.BranchRelation == "ambiguous" {
- t.Fatalf("ignored active delivery poisoned publication authority: BlockingFeature=%q relation=%q",
- finding.BlockingFeature, finding.BranchRelation)
- }
- if finding.BlockingFeature != "roles-access-policies" {
- t.Fatalf("expected the un-ignored delivery to be blocking, got %q", finding.BlockingFeature)
- }
-}
diff --git a/boatstack/published_slice_routing_test.go b/boatstack/published_slice_routing_test.go
deleted file mode 100644
index dcb4624..0000000
--- a/boatstack/published_slice_routing_test.go
+++ /dev/null
@@ -1,175 +0,0 @@
-package boatstack
-
-import "testing"
-
-// Regression for the published-slice correction trap: after a slice publishes and
-// the BUILD pointer advances, the actuator layer (resolveAddressableSlice) keeps
-// the published-but-open slice correctable in place, but the advisor/router/safety
-// layer used to decide purely on the whole-delivery pointer (ActiveIndex <
-// len(Slices)) and mis-routed a correction for the published slice to the active
-// slice — repairing the wrong slice and reporting the fix branch as unrelated.
-//
-// These tests pin the advisor layer to the SAME addressable set the actuators use.
-
-// publishOpenFirstSlice activates a two-slice delivery, gates and publishes the
-// first slice, and returns the repo/feature with the pointer advanced to the
-// still-building second slice. The first slice is PUBLISHED with an open PR; the
-// test repo's git branch (feat/reviewer-ready) is the first slice's head branch.
-func publishOpenFirstSlice(t *testing.T) (string, string) {
- t.Helper()
- repo, feature := activateTwoSliceDelivery(t)
- gateSlice(t, repo, feature, "phase-one")
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil {
- t.Fatalf("publish phase-one: %v", err)
- }
- state := loadDelivery(t, repo, feature)
- if state.ActiveIndex != 1 || state.Slices[0].Status != "PUBLISHED" || state.Slices[0].PRState != "OPEN" {
- t.Fatalf("setup did not leave phase-one published-open with the pointer on phase-two: %#v", state)
- }
- return repo, feature
-}
-
-func TestRecoveryRoutesPublishedOpenSliceInPlace(t *testing.T) {
- repo, feature := publishOpenFirstSlice(t)
-
- // The correction is reported from phase-one's own branch (feat/reviewer-ready),
- // which is the test repo's current branch — no explicit --feature is needed.
- status, err := ResolveRecovery(RecoveryStatusOptions{
- Repo: repo, Feature: feature,
- Message: "required checks failed on the published PR", SourceStage: "ci",
- })
- if err != nil {
- t.Fatalf("resolve recovery: %v", err)
- }
- if status.NextOperation != "repair_published_slice" {
- t.Fatalf("correction for a published-open slice was not routed in place: %#v", status)
- }
- if status.Slice != "phase-one" {
- t.Fatalf("router named the wrong slice: got %q, want phase-one", status.Slice)
- }
- if status.Lifecycle != "PUBLISHED_OPEN" {
- t.Fatalf("router did not mark the published-open lifecycle: %#v", status)
- }
-}
-
-func TestRecordChangeBindsPublishedOpenSliceWithoutDisturbingActive(t *testing.T) {
- assertInPlace := func(t *testing.T, options ChangeObservationOptions) {
- t.Helper()
- repo, feature := publishOpenFirstSlice(t)
- options.Repo = repo
- options.Feature = feature
- options.Message = "required checks failed on the published PR"
- options.SourceStage = "ci"
- options.Classification = "verification_repair"
-
- observation, _, err := RecordChangeObservation(options)
- if err != nil {
- t.Fatalf("record change: %v", err)
- }
- if observation.Outcome != "RESUME_PUBLISHED_SLICE" {
- t.Fatalf("change bound to the wrong path: %#v", observation)
- }
- if observation.SliceID != "phase-one" {
- t.Fatalf("change bound to the wrong slice: got %q, want phase-one", observation.SliceID)
- }
- got := loadDelivery(t, repo, feature)
- // The active slice's build loop must be untouched.
- if got.ActiveIndex != 1 {
- t.Fatalf("recording a published-slice correction moved the pointer: %#v", got)
- }
- if got.Slices[1].Status != "BUILD" {
- t.Fatalf("active slice was disturbed: %#v", got.Slices[1])
- }
- if got.RepairAttempt != 0 {
- t.Fatalf("active repair budget was consumed by a published-slice correction: %d", got.RepairAttempt)
- }
- if got.Mode == "REWORK" {
- t.Fatalf("published-slice correction hijacked the delivery mode: %q", got.Mode)
- }
- // The published slice is driven back to an in-place re-gate; its open PR
- // identity is preserved so publish-pr --action update can target it.
- if got.Slices[0].Status != "BUILD" {
- t.Fatalf("published slice was not reset for an in-place re-gate: %#v", got.Slices[0])
- }
- if got.Slices[0].PRState != "OPEN" {
- t.Fatalf("in-place re-gate dropped the published PR identity: %#v", got.Slices[0])
- }
- }
-
- t.Run("explicit --slice", func(t *testing.T) {
- assertInPlace(t, ChangeObservationOptions{SliceID: "phase-one"})
- })
- t.Run("resolved from the correction branch", func(t *testing.T) {
- assertInPlace(t, ChangeObservationOptions{})
- })
-}
-
-// TestActiveSliceCorrectionStillRoutesToRepairActive guards the ordinary path: a
-// correction on the active slice's own branch, with no earlier published-open
-// slice, must still route to repair_active unchanged.
-func TestActiveSliceCorrectionStillRoutesToRepairActive(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- // Gate (but do not publish) phase-one so it becomes the branch-matched ACTIVE
- // slice; the pointer stays at index 0.
- gateSlice(t, repo, feature, "phase-one")
-
- status, err := ResolveRecovery(RecoveryStatusOptions{
- Repo: repo, Feature: feature,
- Message: "a review note on the active slice", SourceStage: "review",
- })
- if err != nil {
- t.Fatalf("resolve recovery: %v", err)
- }
- if status.NextOperation != "repair_active" {
- t.Fatalf("active-slice correction no longer routes to repair_active: %#v", status)
- }
-}
-
-// TestSafetyFindingNamesPublishedOpenSlice pins the publication-bypass finding to
-// the addressable slice the branch owns: a denied push on the published slice's
-// branch must name that slice and read as the current branch, not the active slice
-// with relation=unrelated.
-func TestSafetyFindingNamesPublishedOpenSlice(t *testing.T) {
- repo, _ := publishOpenFirstSlice(t)
-
- finding, blocked := publicationBypassFinding(repo, "denied direct push", "tool-input")
- if !blocked {
- t.Fatalf("expected a publication-bypass finding for the active delivery")
- }
- if finding.BranchRelation != "current_branch" {
- t.Fatalf("published-slice fix branch read as %q, want current_branch", finding.BranchRelation)
- }
- if finding.BlockingSlice != "phase-one" {
- t.Fatalf("finding named the wrong slice: got %q, want phase-one", finding.BlockingSlice)
- }
-}
-
-// TestAdvisorAndActuatorResolveSameSlice is the anti-drift invariant: for a given
-// branch, the advisor resolver (by branch) and the actuator resolver (by id) must
-// select the same slice. This is the structural guarantee that the advisor layer
-// cannot silently diverge from the actuator layer again.
-func TestAdvisorAndActuatorResolveSameSlice(t *testing.T) {
- state := DeliveryState{
- ActiveIndex: 1,
- Slices: []DeliverySlice{
- {ID: "phase-one", Status: "PUBLISHED", PRState: "OPEN", HeadBranch: "feat/phase-one"},
- {ID: "phase-two", Status: "BUILD", HeadBranch: "feat/phase-two"},
- },
- }
- for _, slice := range state.Slices {
- byBranch, addressable, ok := resolveAddressableSliceByBranch(state, slice.HeadBranch)
- if !ok {
- t.Fatalf("advisor resolver could not resolve branch %q", slice.HeadBranch)
- }
- byID, _, err := resolveAddressableSlice(state, slice.ID)
- if err != nil {
- t.Fatalf("actuator resolver rejected slice %q: %v", slice.ID, err)
- }
- if byBranch != byID {
- t.Fatalf("advisor/actuator drift for %q: branch->%d, id->%d", slice.ID, byBranch, byID)
- }
- if addressable.ID != slice.ID {
- t.Fatalf("advisor resolver returned %q for branch %q", addressable.ID, slice.HeadBranch)
- }
- }
-}
diff --git a/boatstack/readiness.go b/boatstack/readiness.go
deleted file mode 100644
index 2b0af09..0000000
--- a/boatstack/readiness.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-)
-
-type ReadinessReceipt struct {
- Fingerprint string
- PlanFingerprint string
- BaseBranch string
- HeadBranch string
- BaseCommit string
- HeadCommit string
- Upstream string
- Relation string
- JourneyManifestSHA256 string
-}
-
-func readinessFingerprint(receipt ReadinessReceipt) (string, error) {
- canonical, err := MarshalJSON(map[string]any{
- "schema_version": 1, "plan_fingerprint": receipt.PlanFingerprint,
- "base_branch": receipt.BaseBranch, "head_branch": receipt.HeadBranch,
- "base_commit": receipt.BaseCommit, "head_commit": receipt.HeadCommit,
- "upstream": receipt.Upstream, "relation": receipt.Relation,
- "journey_manifest_sha256": receipt.JourneyManifestSHA256,
- })
- if err != nil {
- return "", err
- }
- return SHA256Bytes(canonical), nil
-}
-
-func checkPlanReadiness(repo, planPath string) (ReadinessReceipt, error) {
- check, err := CheckPlanForRepository(repo, planPath)
- if err != nil {
- return ReadinessReceipt{}, err
- }
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- return ReadinessReceipt{}, fmt.Errorf("readiness requires valid Boatstack configuration: %w", err)
- }
- feature := stringValue(check.Plan["feature_id"])
- if resolveWorkspace(config.Workspace).Enabled && needsFreshCut(repo, feature) {
- return ReadinessReceipt{}, fmt.Errorf("readiness requires the feature workspace; run workspace-cut --repo %s --feature %s and continue from destination_repository", repo, feature)
- }
- if err := guardManagedActivationWorktree(repo, config, feature); err != nil {
- return ReadinessReceipt{}, err
- }
- preflight := CheckRunPreflight(repo, "")
- if preflight.VerificationStatus != "VERIFIED" {
- return ReadinessReceipt{}, fmt.Errorf("readiness blocked (%s): %s; recover the workspace and retry approval", preflight.Relation, preflight.Reason)
- }
- manifest, err := CompileJourneyManifest(check.Plan)
- if err != nil {
- return ReadinessReceipt{}, fmt.Errorf("journey capability is not ready: %w", err)
- }
- if err := checkJourneyCapabilities(repo, check.Plan); err != nil {
- return ReadinessReceipt{}, err
- }
- var manifestValue map[string]any
- if err := DecodeJSON("compiled journey oracle manifest", "journey-oracles.json", manifest, &manifestValue); err != nil {
- return ReadinessReceipt{}, err
- }
- baseCommit, err := runGitCommand(repo, "rev-parse", "refs/remotes/origin/"+preflight.BaseBranch+"^{commit}")
- if err != nil {
- return ReadinessReceipt{}, err
- }
- headCommit, err := runGitCommand(repo, "rev-parse", "HEAD^{commit}")
- if err != nil {
- return ReadinessReceipt{}, err
- }
- receipt := ReadinessReceipt{
- PlanFingerprint: check.Fingerprint, BaseBranch: preflight.BaseBranch,
- HeadBranch: preflight.HeadBranch, BaseCommit: strings.TrimSpace(baseCommit),
- HeadCommit: strings.TrimSpace(headCommit), Upstream: preflight.Upstream,
- Relation: preflight.Relation, JourneyManifestSHA256: stringValue(manifestValue["manifest_sha256"]),
- }
- fingerprint, err := readinessFingerprint(receipt)
- if err != nil {
- return ReadinessReceipt{}, err
- }
- receipt.Fingerprint = fingerprint
- return receipt, nil
-}
-
-func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) {
- repo, err := ResolveControllerRepository(filepath.Dir(planPath))
- if err != nil {
- return ReadinessReceipt{}, err
- }
- return checkPlanReadiness(repo, planPath)
-}
-
-func CheckPlanReadinessForRepository(repoPath, planPath string) (ReadinessReceipt, error) {
- repo, err := ResolveControllerRepositoryFor(repoPath, filepath.Dir(planPath))
- if err != nil {
- return ReadinessReceipt{}, err
- }
- return checkPlanReadiness(repo, planPath)
-}
-
-func checkJourneyCapabilities(repo string, plan map[string]any) error {
- decision, _ := plan["journey_evidence"].(map[string]any)
- if strings.ToLower(stringValue(decision["relevance"])) != "relevant" {
- return nil
- }
- oracles, _ := objectSlice(decision["oracles"])
- for _, oracle := range oracles {
- fields := strings.Fields(stringValue(oracle["run"]))
- if len(fields) == 0 {
- return fmt.Errorf("journey capability %s has no runnable command", stringValue(oracle["id"]))
- }
- command := fields[0]
- if strings.Contains(command, "/") {
- if !filepath.IsAbs(command) {
- command = filepath.Join(repo, command)
- }
- info, err := os.Stat(command)
- if err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
- return fmt.Errorf("journey capability %s is missing executable %s", stringValue(oracle["id"]), fields[0])
- }
- } else if _, err := exec.LookPath(command); err != nil {
- return fmt.Errorf("journey capability %s is missing command %s", stringValue(oracle["id"]), command)
- }
- }
- return nil
-}
diff --git a/boatstack/readiness_conformance_test.go b/boatstack/readiness_conformance_test.go
deleted file mode 100644
index 0382dec..0000000
--- a/boatstack/readiness_conformance_test.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "os/exec"
- "path/filepath"
- "testing"
-)
-
-// control-law: activation-requires-current-readiness-bound-to-exact-authority
-func TestApprovalAndActivationBindSameReadinessFingerprint(t *testing.T) {
- repo := runTestRepo(t)
- remote := filepath.Join(t.TempDir(), "origin.git")
- if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
- t.Fatalf("git init --bare: %v: %s", err, output)
- }
- runGit(t, repo, "remote", "add", "origin", remote)
- runGit(t, repo, "push", "-u", "origin", "main")
- runGit(t, repo, "switch", "-c", "readiness-feature")
-
- feature := "readiness-feature"
- dir := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "source-plan.md"), []byte("# source\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "feature-spec.md"), []byte("# spec\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["schema_version"] = float64(3)
- plan["feature_id"] = feature
- plan["spec_path"] = "feature-spec.md"
- plan["architecture_facts"] = []any{}
- plan["architecture_unknowns"] = []any{}
- task := plan["tasks"].([]any)[0].(map[string]any)
- task["requires_facts"] = []any{}
- task["affected_paths"] = []any{"README.md"}
- task["rollback_boundary"] = "revert the change"
- task["side_effects"] = []any{}
- plan["journey_evidence"] = map[string]any{
- "relevance": "not_relevant", "reason": "internal control-only change", "oracles": []any{},
- }
- planPath := filepath.Join(dir, "plan.md")
- writeMarkdownPlan(t, planPath, plan, true)
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- readiness, err := CheckPlanReadiness(planPath)
- if err != nil {
- t.Fatal(err)
- }
- approvalPath := filepath.Join(dir, "approval.md")
- runGit(t, repo, "remote", "rename", "origin", "temporarily-unavailable")
- if err := RecordApproval(ApprovalRecordOptions{
- Repo: repo,
- PlanPath: planPath, OutputPath: approvalPath, ApprovedBy: "Test Human",
- ApprovedAt: "2026-07-29T12:00:00Z", Fingerprint: check.Fingerprint,
- }); err == nil {
- t.Fatal("missing readiness allowed approval")
- }
- if fileExists(approvalPath) {
- t.Fatal("blocked readiness created an approval artifact")
- }
- runGit(t, repo, "remote", "rename", "temporarily-unavailable", "origin")
- writeApprovalReceipt(t, approvalPath, check.Fingerprint)
- if _, err := CheckApprovalReceipt(approvalPath, check); err == nil {
- t.Fatal("unactivated legacy approval must not authorize a schema-v3 plan")
- }
- if err := RecordApproval(ApprovalRecordOptions{
- Repo: repo,
- PlanPath: planPath, OutputPath: approvalPath, ApprovedBy: "Test Human",
- ApprovedAt: "2026-07-29T12:00:00Z", Fingerprint: check.Fingerprint,
- }); err != nil {
- t.Fatal(err)
- }
- receipt, err := LoadApprovalReceipt(approvalPath)
- if err != nil {
- t.Fatal(err)
- }
- if receipt.SchemaVersion != 3 || receipt.Readiness.Fingerprint != readiness.Fingerprint {
- t.Fatalf("approval readiness mismatch: %+v vs %+v", receipt.Readiness, readiness)
- }
- lockPath := filepath.Join(dir, "plan.lock.json")
- if err := ActivatePlan(ActivationOptions{
- Repo: repo,
- PlanPath: planPath, ApprovalPath: approvalPath,
- OutDir: filepath.Join(dir, "compiled"), OutputPath: lockPath,
- }); err != nil {
- t.Fatal(err)
- }
- lockValue, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- var lock map[string]any
- if err := json.Unmarshal(lockValue, &lock); err != nil {
- t.Fatal(err)
- }
- if intValue(lock["schema_version"]) != 3 || stringValue(lock["readiness_fingerprint"]) != readiness.Fingerprint {
- t.Fatalf("activation lock did not preserve approval readiness: %+v", lock)
- }
- manifestPath := filepath.Join(dir, "compiled", "journey-oracles.json")
- manifestValue, err := os.ReadFile(manifestPath)
- if err != nil {
- t.Fatal(err)
- }
- var manifest map[string]any
- if err := json.Unmarshal(manifestValue, &manifest); err != nil {
- t.Fatal(err)
- }
- manifest["reason"] = "tampered after activation"
- tampered, _ := MarshalJSON(manifest)
- if err := os.WriteFile(manifestPath, tampered, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CheckApprovalLock(ApprovalOptions{
- Repo: repo,
- SourcePlanPath: filepath.Join(dir, "source-plan.md"), SpecPath: filepath.Join(dir, "feature-spec.md"),
- PlanPath: planPath, TasksPath: filepath.Join(dir, "compiled", "tasks.json"),
- AuthorizationMode: "human", OutputPath: lockPath,
- }); err == nil {
- t.Fatal("tampered journey manifest bypassed the immutable lock")
- }
-}
diff --git a/boatstack/recovery.go b/boatstack/recovery.go
deleted file mode 100644
index fab3b03..0000000
--- a/boatstack/recovery.go
+++ /dev/null
@@ -1,706 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "time"
-)
-
-const recoveryStatusSchemaVersion = 1
-
-// RecoveryStatus is the read-only, host-neutral decision for a reported
-// delivery problem. It deliberately carries no authority to edit, approve, or
-// publish anything.
-type RecoveryStatus struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- Feature string `json:"feature,omitempty"`
- Slice string `json:"slice,omitempty"`
- ParentDelivery string `json:"parent_delivery,omitempty"`
- Lifecycle string `json:"lifecycle,omitempty"`
- PRPhase string `json:"pr_phase,omitempty"`
- PRFailingChecks []string `json:"pr_failing_checks,omitempty"`
- PRURL string `json:"pr_url,omitempty"`
- HeadBranch string `json:"head_branch,omitempty"`
- ObservedPRHeadSHA string `json:"observed_pr_head_sha,omitempty"`
- NextOperation string `json:"next_operation"`
- SuggestedFeatureID string `json:"suggested_feature_id,omitempty"`
- ExistingDiffSHA256 string `json:"existing_diff_sha256,omitempty"`
- ExistingChangedPaths []string `json:"existing_changed_paths,omitempty"`
- Reason string `json:"reason"`
- Blockers []string `json:"blockers,omitempty"`
-}
-
-type RecoveryStatusOptions struct {
- Repo string
- Feature string
- Message string
- SourceStage string
- Evidence string
- ObservedHeadSHA string
-}
-
-type publishedPRObservation struct {
- Lifecycle string
- URL string
- Branch string
- HeadSHA string
- // Post-publish position, observed live and never persisted. Phase is the
- // fail-closed classification; the remaining fields carry the raw facts it
- // was derived from so status output can explain the classification.
- // control-law: pr-phase-derives-only-from-live-observation
- Phase PRPhase
- BaseBranch string
- ReviewDecision string
- MergeState string
- FailingChecks []string
- ChecksTotal int
- ChecksPassed int
- ChecksFailed int
- ChecksPending int
-}
-
-// publishedPRFields is the field list for the single live PR observation.
-// publishedPRLegacyFields is the pre-phase list kept as a fallback so an older
-// gh binary that rejects the newer fields still yields the basic lifecycle
-// observation it always did.
-const (
- publishedPRFields = "state,headRefName,headRefOid,url,baseRefName,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision"
- publishedPRLegacyFields = "state,headRefName,headRefOid,url"
-)
-
-var recoveryGh = func(repo string, arguments ...string) (string, error) {
- return commandOutput(repo, "gh", arguments...)
-}
-
-func blockedRecovery(reason string, blockers ...string) RecoveryStatus {
- return RecoveryStatus{
- SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "BLOCKED",
- NextOperation: "resolve_ambiguity", Reason: reason, Blockers: blockers,
- }
-}
-
-// allManagedDeliveryStates partitions the delivery-state store the way the
-// read-only recovery boundary needs it: states whose plan lock verifies on this
-// branch are returned as data, and slugs that cannot be verified are returned as
-// invalid rather than aborting the whole scan. Like scanManagedDeliveries (the
-// ResolveNext counterpart) it never lets one corrupt or cross-branch delivery
-// poison recovery of an unrelated one; ResolveRecovery applies the
-// ignored-deliveries filter to both lists before any invalidity becomes fatal.
-// control-law: stale-delivery-cannot-block-unrelated-feature
-func allManagedDeliveryStates(repo string) (states []DeliveryState, invalid []string, err error) {
- directory, err := deliveryStateDirectory(repo)
- if err != nil {
- return nil, nil, err
- }
- entries, err := os.ReadDir(directory)
- if os.IsNotExist(err) {
- return nil, nil, nil
- }
- if err != nil {
- return nil, nil, err
- }
- states = []DeliveryState{}
- for _, entry := range entries {
- if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) {
- continue
- }
- state, loadErr := CurrentDeliveryState(repo, entry.Name())
- if loadErr != nil {
- invalid = append(invalid, entry.Name())
- continue
- }
- states = append(states, state)
- }
- sort.Slice(states, func(i, j int) bool { return states[i].Feature < states[j].Feature })
- sort.Strings(invalid)
- return states, invalid, nil
-}
-
-func deliveryBranchAndSlice(state DeliveryState) (string, string, string) {
- if len(state.Slices) == 0 {
- return "", "", ""
- }
- index := state.ActiveIndex
- if index >= len(state.Slices) {
- index = len(state.Slices) - 1
- }
- slice := state.Slices[index]
- return slice.HeadBranch, slice.ID, slice.PRURL
-}
-
-func stateMatchesBranch(state DeliveryState, branch string) bool {
- if strings.TrimSpace(branch) == "" {
- return false
- }
- // A correction may be pushed on the branch of any addressable slice — the active
- // slice or a published-but-open earlier slice — not only the active slice's head.
- // Matching solely the active slice's head stranded corrections for a published
- // slice inside a still-active delivery, so they never selected their own delivery.
- if _, _, ok := resolveAddressableSliceByBranch(state, branch); ok {
- return true
- }
- head, _, _ := deliveryBranchAndSlice(state)
- if head != "" {
- return head == branch
- }
- return branchForFeature(state.Feature) == branch
-}
-
-func selectRecoveryDelivery(states []DeliveryState, explicitFeature, currentBranch string) (DeliveryState, []string, error) {
- if explicitFeature != "" {
- for _, state := range states {
- if state.Feature == explicitFeature {
- return state, nil, nil
- }
- }
- return DeliveryState{}, nil, fmt.Errorf("managed delivery %s does not exist", explicitFeature)
- }
- selectMatching := func(active bool) []DeliveryState {
- matches := []DeliveryState{}
- for _, state := range states {
- isActive := state.ActiveIndex < len(state.Slices)
- if isActive == active && stateMatchesBranch(state, currentBranch) {
- matches = append(matches, state)
- }
- }
- return matches
- }
- for _, active := range []bool{true, false} {
- matches := selectMatching(active)
- if len(matches) == 1 {
- return matches[0], nil, nil
- }
- if len(matches) > 1 {
- features := make([]string, 0, len(matches))
- for _, state := range matches {
- features = append(features, state.Feature)
- }
- return DeliveryState{}, features, nil
- }
- }
- if len(states) == 1 {
- return states[0], nil, nil
- }
- features := make([]string, 0, len(states))
- for _, state := range states {
- features = append(features, state.Feature)
- }
- return DeliveryState{}, features, nil
-}
-
-func observePublishedPR(repo string, state DeliveryState) publishedPRObservation {
- branch, _, prURL := deliveryBranchAndSlice(state)
- return observePRTarget(repo, prURL, branch)
-}
-
-// observePRTarget performs the single live, read-only PR observation for one
-// explicit PR URL or head branch. Split from observePublishedPR so callers
-// that must not write anything (the frontier report) and callers that need a
-// non-active slice's PR (an earlier published-but-open slice) share the exact
-// same observation.
-func observePRTarget(repo, prURL, branch string) publishedPRObservation {
- observation := publishedPRObservation{Lifecycle: "PUBLISHED_UNKNOWN", URL: prURL, Branch: branch, Phase: PRPhaseUnknown}
- target := prURL
- if target == "" {
- target = branch
- }
- if target == "" {
- return observation
- }
- value, err := recoveryGh(repo, "pr", "view", target, "--json", publishedPRFields)
- if err != nil {
- // An older gh may reject the phase fields; fall back to the legacy
- // list so the lifecycle observation this function always produced is
- // never lost to the enrichment. The phase stays Unknown.
- value, err = recoveryGh(repo, "pr", "view", target, "--json", publishedPRLegacyFields)
- if err != nil {
- return observation
- }
- }
- var payload struct {
- State string `json:"state"`
- HeadRefName string `json:"headRefName"`
- HeadRefOID string `json:"headRefOid"`
- URL string `json:"url"`
- BaseRefName string `json:"baseRefName"`
- Mergeable string `json:"mergeable"`
- MergeStateStatus string `json:"mergeStateStatus"`
- ReviewDecision string `json:"reviewDecision"`
- StatusCheckRollup []prStatusCheck `json:"statusCheckRollup"`
- }
- if DecodeJSON("inspect published PR", target, []byte(value), &payload) != nil {
- return observation
- }
- if payload.URL != "" {
- observation.URL = payload.URL
- }
- if payload.HeadRefName != "" {
- observation.Branch = payload.HeadRefName
- }
- observation.HeadSHA = payload.HeadRefOID
- switch strings.ToUpper(strings.TrimSpace(payload.State)) {
- case "OPEN":
- observation.Lifecycle = "PUBLISHED_OPEN"
- case "MERGED":
- observation.Lifecycle = "PUBLISHED_MERGED"
- case "CLOSED":
- observation.Lifecycle = "PUBLISHED_CLOSED"
- }
- checks := summarizeCheckRollup(payload.StatusCheckRollup)
- observation.BaseBranch = payload.BaseRefName
- observation.ReviewDecision = payload.ReviewDecision
- observation.MergeState = payload.MergeStateStatus
- observation.FailingChecks = checks.Failing
- observation.ChecksTotal = checks.Total
- observation.ChecksPassed = checks.Passed
- observation.ChecksFailed = checks.Failed
- observation.ChecksPending = checks.Pending
- observation.Phase = derivePRPhase(payload.State, checks, payload.ReviewDecision, payload.MergeStateStatus)
- return observation
-}
-
-// persistObservedTerminalPRState caches a terminal (MERGED/CLOSED) PR lifecycle
-// on the specific published slice it belongs to. This is a bounded, best-effort
-// write of an already-observed external fact — not a workflow mutation — so the
-// network-free gate resolver (resolveAddressableSlice) can later refuse in-place
-// correction of a slice whose PR has closed and route it to a corrective child
-// instead. Non-terminal lifecycles are left as the OPEN default so the slice
-// stays re-gateable in place. Failures are swallowed: the observation is
-// authoritative regardless of whether the cache write succeeds.
-func persistObservedTerminalPRState(repo string, state DeliveryState, observation publishedPRObservation) {
- if !isTerminalPRState(observation.Lifecycle) {
- return
- }
- url := strings.TrimSpace(observation.URL)
- branch := strings.TrimSpace(observation.Branch)
- for i, s := range state.Slices {
- if s.Status != "PUBLISHED" {
- continue
- }
- matches := (url != "" && strings.TrimSpace(s.PRURL) == url) ||
- (branch != "" && strings.TrimSpace(s.HeadBranch) == branch)
- if !matches {
- continue
- }
- if strings.EqualFold(strings.TrimSpace(s.PRState), observation.Lifecycle) {
- return
- }
- state.Slices[i].PRState = observation.Lifecycle
- if saveDeliveryState(repo, state) == nil {
- reconcileInsightsForFeature(repo, state.Feature)
- }
- return
- }
-}
-
-func suggestedCorrectionFeature(states []DeliveryState, parent string) string {
- used := map[int]bool{}
- prefix := parent + "-correction-"
- for _, state := range states {
- if state.ParentDelivery != parent || !strings.HasPrefix(state.Feature, prefix) {
- continue
- }
- value, err := strconv.Atoi(strings.TrimPrefix(state.Feature, prefix))
- if err == nil && value > 0 {
- used[value] = true
- }
- }
- for value := 1; ; value++ {
- if !used[value] {
- return fmt.Sprintf("%s%02d", prefix, value)
- }
- }
-}
-
-func existingRecoveryDiff(repo string, state DeliveryState) (string, []string) {
- baseCommit := ""
- if len(state.Slices) > 0 {
- last := state.Slices[len(state.Slices)-1]
- if receipt, err := readDeliveryReceipt(repo, state.Feature, last.ID, "review"); err == nil {
- baseCommit = strings.TrimSpace(receipt.HeadCommit)
- }
- }
- if baseCommit == "" {
- base := defaultPRBase(repo)
- if len(state.Slices) > 0 && strings.TrimSpace(state.Slices[len(state.Slices)-1].BaseBranch) != "" {
- base = state.Slices[len(state.Slices)-1].BaseBranch
- }
- resolved, err := resolveBaseCommit(repo, base)
- if err != nil {
- return "", nil
- }
- baseCommit = resolved
- }
- diff, err := exec.Command("git", "-C", repo, "diff", "--binary", "--no-ext-diff", baseCommit, "--", ".").Output()
- if err != nil {
- return "", nil
- }
- // Read stdout only: Git may emit platform-specific line-ending warnings on
- // stderr, and those messages are not changed paths. NUL separation also
- // preserves unusual but valid filenames.
- names, err := exec.Command("git", "-C", repo, "diff", "--name-only", "-z", baseCommit, "--", ".").Output()
- if err != nil {
- return "", nil
- }
- paths := []string{}
- for _, raw := range bytes.Split(names, []byte{0}) {
- if len(raw) != 0 {
- paths = append(paths, filepath.ToSlash(string(raw)))
- }
- }
- untracked, err := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard", "-z").Output()
- if err != nil {
- return "", nil
- }
- canonical := bytes.NewBuffer(diff)
- for _, raw := range bytes.Split(untracked, []byte{0}) {
- if len(raw) == 0 {
- continue
- }
- path := filepath.ToSlash(string(raw))
- value, readErr := os.ReadFile(filepath.Join(repo, filepath.FromSlash(path)))
- if readErr != nil {
- return "", nil
- }
- paths = append(paths, path)
- canonical.WriteString("\nuntracked ")
- canonical.WriteString(path)
- canonical.WriteString(" ")
- canonical.WriteString(SHA256Bytes(value))
- }
- if canonical.Len() == 0 {
- return "", nil
- }
- sort.Strings(paths)
- return SHA256Bytes(canonical.Bytes()), paths
-}
-
-// ResolveRecovery identifies the delivery that owns an exact correction and
-// returns the safe transition. It does not record the request or draft files.
-func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return RecoveryStatus{}, err
- }
- if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil {
- return blockedRecovery(workspaceErr.Error()), nil
- }
- if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" {
- return RecoveryStatus{}, fmt.Errorf("recovery status requires the exact message and source stage")
- }
- switch strings.ToLower(strings.TrimSpace(options.SourceStage)) {
- case "ci", "review", "publication", "user":
- default:
- return RecoveryStatus{}, fmt.Errorf("recovery source stage must be ci, review, publication, or user")
- }
- if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) {
- return RecoveryStatus{
- SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "UNVERIFIED",
- NextOperation: "none", Reason: "This repository has no managed delivery installation to inspect.",
- }, nil
- }
- // Read-only boundary: partition the store instead of failing closed, then
- // apply the operator's ignored-deliveries filter to BOTH the readable states
- // and the invalid slugs before any invalidity becomes fatal. Only a
- // still-unignored invalid delivery blocks — named, and routed to the
- // discard-delivery remedy. This is the same law the ResolveNext boundary
- // already enforces; without it one abandoned, already-ignored delivery in the
- // shared store poisons recovery of an unrelated healthy delivery.
- // control-law: stale-delivery-cannot-block-unrelated-feature
- states, invalidDeliveries, err := allManagedDeliveryStates(repo)
- if err != nil {
- return blockedRecovery("Managed delivery state cannot be verified: " + err.Error()), nil
- }
- config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if configErr != nil {
- return blockedRecovery("Boatstack project configuration is invalid: " + configErr.Error()), nil
- }
- states = withoutIgnoredDeliveryStates(states, config.Workflow.IgnoredDeliveries)
- invalidDeliveries = withoutIgnoredDeliveries(invalidDeliveries, config.Workflow.IgnoredDeliveries)
- if len(invalidDeliveries) > 0 {
- status := blockedRecovery("Boatstack found managed delivery state it cannot verify. Restore its evidence, add it to workflow.ignored_deliveries, or run discard-delivery to clear it before recovering.", invalidDeliveries...)
- status.NextOperation = "discard-delivery"
- return status, nil
- }
- branch, _ := gitCommand(repo, "branch", "--show-current")
- selected, ambiguity, selectErr := selectRecoveryDelivery(states, strings.TrimSpace(options.Feature), strings.TrimSpace(branch))
- if selectErr != nil {
- return blockedRecovery(selectErr.Error()), nil
- }
- if len(ambiguity) > 0 {
- return blockedRecovery("More than one managed delivery could own this correction; choose the feature explicitly.", ambiguity...), nil
- }
- if selected.Feature == "" {
- return RecoveryStatus{
- SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "UNVERIFIED",
- NextOperation: "none", Reason: "No managed delivery matches the current branch or correction.",
- }, nil
- }
- head, sliceID, prURL := deliveryBranchAndSlice(selected)
- status := RecoveryStatus{
- SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "VERIFIED",
- Feature: selected.Feature, Slice: sliceID, ParentDelivery: selected.ParentDelivery,
- HeadBranch: head, PRURL: prURL,
- }
- // A correction can target a published-but-open earlier slice whose PR has not
- // yet merged or closed. The actuator layer (resolveAddressableSlice) can re-gate
- // and re-publish such a slice in place, so the advisor must point the correction
- // there rather than at the active slice. Resolve the target from the correction's
- // branch through the same addressable set the actuators use; only an earlier
- // (already published) slice needs this redirect — the active slice falls through
- // to the ordinary repair boundary below. This redirect is scoped to a still-active
- // delivery: once the whole delivery is published the corrective-child logic below
- // owns the decision (and observes the live PR terminal-ness rather than the cache).
- if selected.ActiveIndex < len(selected.Slices) {
- if idx, addressable, ok := resolveAddressableSliceByBranch(selected, strings.TrimSpace(branch)); ok && idx < selected.ActiveIndex {
- status.Slice = addressable.ID
- status.HeadBranch = addressable.HeadBranch
- status.PRURL = addressable.PRURL
- status.Lifecycle = "PUBLISHED_OPEN"
- status.NextOperation = "repair_published_slice"
- status.Reason = fmt.Sprintf("Delivery slice %q is published with an open pull request; re-gate it and republish with publish-pr --action update on its own branch, rather than repairing the active slice.", addressable.ID)
- return status, nil
- }
- status.Lifecycle = "ACTIVE"
- status.NextOperation = "repair_active"
- status.Reason = fmt.Sprintf("Managed delivery %q is active; route the exact correction through its current repair boundary.", selected.Feature)
- return status, nil
- }
- pr := observePublishedPR(repo, selected)
- persistObservedTerminalPRState(repo, selected, pr)
- status.Lifecycle = pr.Lifecycle
- status.PRPhase = string(pr.Phase)
- status.PRFailingChecks = pr.FailingChecks
- status.PRURL = pr.URL
- status.ObservedPRHeadSHA = pr.HeadSHA
- if pr.Branch != "" {
- status.HeadBranch = pr.Branch
- }
- if head != "" && pr.Branch != "" && head != pr.Branch {
- status.VerificationStatus = "BLOCKED"
- status.NextOperation = "resolve_ambiguity"
- status.Reason = "The recorded delivery branch does not match the observed PR head branch."
- status.Blockers = []string{head, pr.Branch}
- return status, nil
- }
- if expected := strings.TrimSpace(options.ObservedHeadSHA); expected != "" && pr.HeadSHA != "" && expected != pr.HeadSHA {
- status.VerificationStatus = "BLOCKED"
- status.NextOperation = "none"
- status.Reason = "The reported failure belongs to a stale PR head; refresh the failure evidence before drafting a correction."
- status.Blockers = []string{"reported_head=" + expected, "current_head=" + pr.HeadSHA}
- return status, nil
- }
- status.NextOperation = "draft_corrective_child"
- status.SuggestedFeatureID = suggestedCorrectionFeature(states, selected.Feature)
- status.ExistingDiffSHA256, status.ExistingChangedPaths = existingRecoveryDiff(repo, selected)
- switch pr.Lifecycle {
- case "PUBLISHED_OPEN":
- status.Reason = "The published PR is open; draft an independently approved corrective child that will update the same PR."
- case "PUBLISHED_MERGED", "PUBLISHED_CLOSED":
- status.Reason = "The prior PR is no longer open; draft an independently approved corrective child on a fresh branch and PR."
- default:
- status.Reason = "The PR state cannot be verified; draft the corrective child now and defer its publication destination until verification succeeds."
- }
- return status, nil
-}
-
-const repairStateSchemaVersion = 1
-
-// RepairStateResult is the host-neutral outcome of the bounded recovery that
-// clears a workflow stuck at INVALID_STATE because of a hand-authored,
-// unregistered, malformed feature draft. It carries no authority to edit,
-// approve, or publish product code; its only mutation is to quarantine one such
-// draft directory so planning can restart cleanly.
-type RepairStateResult struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED | UNVERIFIED
- Feature string `json:"feature,omitempty"`
- Action string `json:"action"` // quarantined | none | refused
- QuarantinePath string `json:"quarantine_path,omitempty"`
- NextOperation string `json:"next_operation"`
- Reason string `json:"reason"`
- Blockers []string `json:"blockers,omitempty"`
-}
-
-func refusedRepairState(feature, reason string, blockers ...string) RepairStateResult {
- return RepairStateResult{
- SchemaVersion: repairStateSchemaVersion, VerificationStatus: "BLOCKED",
- Feature: feature, Action: "refused", NextOperation: "none", Reason: reason, Blockers: blockers,
- }
-}
-
-// RepairState inspects one unregistered, malformed feature draft directory and
-// quarantines it so the workflow can return to auto-plan. It NEVER touches a
-// directory that carries durable authority: any plan.lock.json, pr.md, managed
-// delivery state, git-tracked file, or active/published delivery causes a
-// refusal. When feature is empty it resolves the sole malformed unregistered
-// candidate and blocks if more than one is present.
-func RepairState(repoPath, feature string) (RepairStateResult, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return RepairStateResult{}, err
- }
- feature = strings.TrimSpace(feature)
- if feature == "" {
- candidates, candErr := featurePlanCandidates(repo)
- if candErr != nil {
- return RepairStateResult{}, candErr
- }
- eligible := []string{}
- for _, candidate := range candidates {
- if _, checkErr := CheckPlan(filepath.Join(WorkspaceFor(repo).FeatureDir(candidate), "plan.md")); checkErr != nil {
- eligible = append(eligible, candidate)
- }
- }
- switch {
- case len(eligible) == 0:
- return RepairStateResult{
- SchemaVersion: repairStateSchemaVersion, VerificationStatus: "UNVERIFIED",
- Action: "none", NextOperation: "none",
- Reason: "no unregistered malformed feature draft was found to repair",
- }, nil
- case len(eligible) > 1:
- return RepairStateResult{
- SchemaVersion: repairStateSchemaVersion, VerificationStatus: "BLOCKED",
- Action: "refused", NextOperation: "resolve_ambiguity",
- Reason: "more than one unregistered malformed draft matches; rerun with --feature",
- Blockers: eligible,
- }, nil
- default:
- feature = eligible[0]
- }
- }
- if !featureSlugPattern.MatchString(feature) {
- return RepairStateResult{}, fmt.Errorf("invalid feature slug: %q", feature)
- }
-
- directory := WorkspaceFor(repo).FeatureDir(feature)
- planPath := filepath.Join(directory, "plan.md")
- if !fileExists(planPath) {
- return refusedRepairState(feature, "no plan.md exists for this feature; nothing to repair"), nil
- }
- if _, checkErr := CheckPlan(planPath); checkErr == nil {
- // Coreachability: repair-state accepts only a malformed unregistered draft, so
- // the resolver must never PRESCRIBE it for a valid one. A valid draft advances
- // (plan-gate/activate); an orphan or unverifiable delivery is prescribed
- // discard-delivery, not repair-state — see preActivationFinding / ResolveNext.
- return refusedRepairState(feature, "the saved plan is valid; repair-state only quarantines a malformed unregistered draft"), nil
- }
-
- // The draft must carry no durable authority. Any of these markers means a
- // registered, published, or tracked feature that must never be quarantined.
- blockers := []string{}
- if fileExists(filepath.Join(directory, "plan.lock.json")) {
- blockers = append(blockers, "plan.lock.json present (registered or activated plan)")
- }
- if fileExists(filepath.Join(directory, "pr.md")) {
- blockers = append(blockers, "pr.md present (published or orphaned delivery)")
- }
- if statePath, stateErr := deliveryStatePath(repo, feature); stateErr == nil && fileExists(statePath) {
- blockers = append(blockers, "managed delivery state present")
- }
- relDir := filepath.ToSlash(filepath.Join(".product-loop", "features", feature))
- tracked, gitErr := gitCommand(repo, "ls-files", "--", relDir)
- if gitErr != nil {
- return refusedRepairState(feature, "cannot verify git tracking state; refusing to quarantine", gitErr.Error()), nil
- }
- if strings.TrimSpace(tracked) != "" {
- blockers = append(blockers, "directory contains git-tracked files")
- }
- active, activeErr := ActiveManagedDeliveries(repo)
- if activeErr != nil {
- return refusedRepairState(feature, "cannot verify active managed deliveries; refusing to quarantine", activeErr.Error()), nil
- }
- for _, candidate := range active {
- if candidate == feature {
- blockers = append(blockers, "feature has an active managed delivery")
- }
- }
- if completed, completedErr := completedManagedStates(repo); completedErr == nil {
- for _, state := range completed {
- if state.Feature == feature {
- blockers = append(blockers, "feature has a published managed delivery")
- }
- }
- }
- if len(blockers) > 0 {
- return refusedRepairState(feature, "refusing to quarantine a registered, published, or tracked feature directory", blockers...), nil
- }
-
- common, err := gitCommonDir(repo)
- if err != nil {
- return RepairStateResult{}, err
- }
- stamp := time.Now().UTC().Format("20060102T150405Z")
- destParent := filepath.Join(common, "boatstack", "quarantine", feature)
- dest := filepath.Join(destParent, stamp)
- for index := 2; fileExists(dest); index++ {
- dest = filepath.Join(destParent, fmt.Sprintf("%s-%d", stamp, index))
- }
- if err := rejectSymlinkComponents(common, dest); err != nil {
- return RepairStateResult{}, err
- }
- if err := os.MkdirAll(destParent, 0o700); err != nil {
- return RepairStateResult{}, err
- }
- if err := os.Rename(directory, dest); err != nil {
- if copyErr := copyTree(directory, dest); copyErr != nil {
- return RepairStateResult{}, copyErr
- }
- if rmErr := os.RemoveAll(directory); rmErr != nil {
- return RepairStateResult{}, rmErr
- }
- }
-
- quarantine := dest
- if rel, relErr := filepath.Rel(repo, dest); relErr == nil {
- quarantine = filepath.ToSlash(rel)
- }
- return RepairStateResult{
- SchemaVersion: repairStateSchemaVersion, VerificationStatus: "VERIFIED",
- Feature: feature, Action: "quarantined", QuarantinePath: quarantine,
- NextOperation: "auto-plan",
- Reason: "quarantined an unregistered malformed feature draft; restart planning with auto-plan",
- }, nil
-}
-
-// copyTree copies a directory tree file-by-file for the cross-device fallback of
-// os.Rename. It refuses symlinks so quarantine cannot follow a link out of the
-// repository.
-func copyTree(source, destination string) error {
- return filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- relative, relErr := filepath.Rel(source, path)
- if relErr != nil {
- return relErr
- }
- target := filepath.Join(destination, relative)
- if entry.IsDir() {
- return os.MkdirAll(target, 0o755)
- }
- info, infoErr := entry.Info()
- if infoErr != nil {
- return infoErr
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("refusing to copy symlinked path: %s", path)
- }
- data, readErr := os.ReadFile(path)
- if readErr != nil {
- return readErr
- }
- if mkErr := os.MkdirAll(filepath.Dir(target), 0o755); mkErr != nil {
- return mkErr
- }
- return os.WriteFile(target, data, info.Mode().Perm())
- })
-}
diff --git a/boatstack/recovery_test.go b/boatstack/recovery_test.go
deleted file mode 100644
index ac033d5..0000000
--- a/boatstack/recovery_test.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package boatstack
-
-import (
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "testing"
-)
-
-func withRecoveryGh(t *testing.T, fn func(string, ...string) (string, error)) {
- t.Helper()
- previous := recoveryGh
- recoveryGh = fn
- t.Cleanup(func() { recoveryGh = previous })
-}
-
-func recoveryPR(state, branch, sha string) func(string, ...string) (string, error) {
- return func(_ string, _ ...string) (string, error) {
- return fmt.Sprintf(`{"state":%q,"headRefName":%q,"headRefOid":%q,"url":"https://example.invalid/pr/1"}`, state, branch, sha), nil
- }
-}
-
-func updateRecoveryDelivery(t *testing.T, repo, feature, branch, prURL, parent string) {
- t.Helper()
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- index := state.ActiveIndex
- if index >= len(state.Slices) {
- index = len(state.Slices) - 1
- }
- state.Slices[index].HeadBranch = branch
- state.Slices[index].PRURL = prURL
- state.ParentDelivery = parent
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestResolveRecoveryRoutesActiveCurrentBranch(t *testing.T) {
- repo := nextTestRepo(t)
- branch, _ := gitCommand(repo, "branch", "--show-current")
- writeNextDelivery(t, repo, "active", "BUILD", 0)
- updateRecoveryDelivery(t, repo, "active", branch, "", "")
-
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the test failed", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "VERIFIED" || status.Feature != "active" || status.Lifecycle != "ACTIVE" || status.NextOperation != "repair_active" {
- t.Fatalf("unexpected active recovery: %#v", status)
- }
-}
-
-func TestResolveRecoveryDraftsPublishedCorrectionByPRState(t *testing.T) {
- for _, test := range []struct {
- state string
- lifecycle string
- }{
- {state: "OPEN", lifecycle: "PUBLISHED_OPEN"},
- {state: "MERGED", lifecycle: "PUBLISHED_MERGED"},
- {state: "CLOSED", lifecycle: "PUBLISHED_CLOSED"},
- } {
- t.Run(test.state, func(t *testing.T) {
- repo := nextTestRepo(t)
- branch, _ := gitCommand(repo, "branch", "--show-current")
- writeNextDelivery(t, repo, "published", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "published", branch, "https://example.invalid/pr/1", "")
- withRecoveryGh(t, recoveryPR(test.state, branch, "head123"))
-
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "review found a regression", SourceStage: "review", ObservedHeadSHA: "head123"})
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "VERIFIED" || status.Lifecycle != test.lifecycle || status.NextOperation != "draft_corrective_child" || status.SuggestedFeatureID != "published-correction-01" {
- t.Fatalf("unexpected published recovery: %#v", status)
- }
- })
- }
-}
-
-func TestResolveRecoveryAllowsDraftWhenGitHubUnavailable(t *testing.T) {
- repo := nextTestRepo(t)
- branch, _ := gitCommand(repo, "branch", "--show-current")
- writeNextDelivery(t, repo, "published", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "published", branch, "https://example.invalid/pr/1", "")
- withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("not authenticated") })
-
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "CI failed", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.Lifecycle != "PUBLISHED_UNKNOWN" || status.NextOperation != "draft_corrective_child" {
- t.Fatalf("unknown PR state blocked drafting: %#v", status)
- }
-}
-
-func TestResolveRecoveryRejectsStaleHeadEvidence(t *testing.T) {
- repo := nextTestRepo(t)
- branch, _ := gitCommand(repo, "branch", "--show-current")
- writeNextDelivery(t, repo, "published", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "published", branch, "https://example.invalid/pr/1", "")
- withRecoveryGh(t, recoveryPR("OPEN", branch, "current"))
-
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "CI failed", SourceStage: "ci", ObservedHeadSHA: "stale"})
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.NextOperation != "none" {
- t.Fatalf("stale evidence was accepted: %#v", status)
- }
-}
-
-func TestResolveRecoveryBlocksAmbiguousPublishedHistory(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "first", "PUBLISHED", 1)
- writeNextDelivery(t, repo, "second", "PUBLISHED", 1)
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the PR failed", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.NextOperation != "resolve_ambiguity" || len(status.Blockers) != 2 {
- t.Fatalf("ambiguous history was selected: %#v", status)
- }
-}
-
-func TestResolveRecoveryIncrementsOnlyLinkedCorrectionIDs(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "parent", "PUBLISHED", 1)
- writeNextDelivery(t, repo, "parent-correction-01", "BUILD", 0)
- updateRecoveryDelivery(t, repo, "parent-correction-01", "feat/other", "", "parent")
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Feature: "parent", Message: "another failure", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.SuggestedFeatureID != "parent-correction-02" {
- t.Fatalf("unexpected correction id: %#v", status)
- }
-}
-
-func TestResolveRecoveryBlocksObservedPRBranchMismatch(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "published", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "published", "feat/expected", "https://example.invalid/pr/1", "")
- withRecoveryGh(t, recoveryPR("OPEN", "feat/different", "head123"))
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Feature: "published", Message: "CI failed", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || len(status.Blockers) != 2 {
- t.Fatalf("PR branch mismatch was accepted: %#v", status)
- }
-}
-
-func TestResolveRecoveryFingerprintsExistingCommittedAndLocalCorrection(t *testing.T) {
- repo := nextTestRepo(t)
- runGit(t, repo, "config", "user.name", "Recovery Test")
- runGit(t, repo, "config", "user.email", "recovery@example.invalid")
- runGit(t, repo, "branch", "-M", "main")
- if err := os.WriteFile(filepath.Join(repo, "app.txt"), []byte("published\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- writeNextDelivery(t, repo, "published", "PUBLISHED", 1)
- updateRecoveryDelivery(t, repo, "published", "main", "https://example.invalid/pr/1", "")
- runGit(t, repo, "add", ".product-loop")
- runGit(t, repo, "commit", "-m", "published evidence")
- if err := os.WriteFile(filepath.Join(repo, "app.txt"), []byte("corrected\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "new-test.txt"), []byte("regression\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- withRecoveryGh(t, recoveryPR("OPEN", "main", "head123"))
-
- status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Feature: "published", Message: "CI failed", SourceStage: "ci"})
- if err != nil {
- t.Fatal(err)
- }
- if status.ExistingDiffSHA256 == "" || len(status.ExistingChangedPaths) != 2 || status.ExistingChangedPaths[0] != "app.txt" || status.ExistingChangedPaths[1] != "new-test.txt" {
- t.Fatalf("existing correction was not fingerprinted: %#v", status)
- }
-}
diff --git a/boatstack/reexec.go b/boatstack/reexec.go
deleted file mode 100644
index d7dfa8d..0000000
--- a/boatstack/reexec.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
-)
-
-// reexecProcess replaces (or, on Windows, spawns-and-exits) the current process
-// with another binary. It is a package var so conformance tests can observe the
-// hand-off without actually replacing the test process.
-var reexecProcess = execReplaceProcess
-
-// reexecUpdate hands the entire update to a replacement helper binary. A running
-// helper embeds its own generated bundle and version constants, so it cannot
-// correctly install a *different* version in-process; the replacement binary must
-// perform its own install so its bundle, constants, and durable operation receipt
-// are the authoritative ones. The child runs `update -binary `, where its
-// self-report matches its running identity, so it proceeds in-process and the
-// hand-off terminates after exactly one hop.
-func reexecUpdate(candidate string, options InitOptions) error {
- absolute, err := filepath.Abs(candidate)
- if err != nil {
- return err
- }
- args := []string{absolute, "update"}
- if options.Repo != "" {
- args = append(args, "-repo", options.Repo)
- }
- args = append(args, "-binary", absolute)
- if options.Yes {
- args = append(args, "-yes")
- }
- if options.Repair {
- args = append(args, "-repair")
- }
- if options.AllowDowngrade {
- args = append(args, "-allow-downgrade")
- }
- return reexecProcess(absolute, args, os.Environ())
-}
diff --git a/boatstack/reexec_unix.go b/boatstack/reexec_unix.go
deleted file mode 100644
index e858f23..0000000
--- a/boatstack/reexec_unix.go
+++ /dev/null
@@ -1,11 +0,0 @@
-//go:build !windows
-
-package boatstack
-
-import "syscall"
-
-// execReplaceProcess replaces the current process image with the target binary.
-// On success it never returns; the replacement inherits stdio and environment.
-func execReplaceProcess(path string, args []string, env []string) error {
- return syscall.Exec(path, args, env)
-}
diff --git a/boatstack/reexec_windows.go b/boatstack/reexec_windows.go
deleted file mode 100644
index 1ffde00..0000000
--- a/boatstack/reexec_windows.go
+++ /dev/null
@@ -1,27 +0,0 @@
-//go:build windows
-
-package boatstack
-
-import (
- "errors"
- "os"
- "os/exec"
-)
-
-// execReplaceProcess spawns the target binary, mirrors its stdio, waits for it,
-// and exits with its status. Windows has no execve, so this is the closest
-// equivalent to replacing the current process; it never returns on success.
-func execReplaceProcess(path string, args []string, env []string) error {
- command := exec.Command(path, args[1:]...)
- command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr
- command.Env = env
- if err := command.Run(); err != nil {
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
- os.Exit(exitErr.ExitCode())
- }
- return err
- }
- os.Exit(0)
- return nil
-}
diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md
index 651a618..793569c 100644
--- a/boatstack/references/artifacts.md
+++ b/boatstack/references/artifacts.md
@@ -1,187 +1,17 @@
-# Artifact contract
+# V2 artifact ownership
-Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Combining them into one context document makes stale assumptions difficult to detect.
+Repository artifacts live below `.boatstack/` and are written only by
+registered effects:
-| Artifact | Purpose | Create or update when |
-|---|---|---|
-| Source plan | Host Plan-mode interpretation of ordinary product intent; required input and provenance for `auto-plan` | Before invoking `auto-plan`; keep hash-current through build |
-| Project constitution | Stable principles and non-negotiable invariants | A rule should govern most future work |
-| Repository map | Minimal entry points, interfaces, commands, and verification boundaries | The relevant architecture or tooling changes |
-| Feature brief/spec | Product intent, outcomes, scenarios, acceptance criteria, non-goals | A product slice is proposed or its intent changes |
-| Question ledger | Unknowns, choices, human answers, provenance, expiry | The repo cannot answer a material question |
-| ADR | Accepted durable architecture decision and rationale | A meaningful architecture choice is accepted |
-| Markdown plan | Human-readable plan plus its one marked structured block; canonical before and during build | A spec is resolved enough to propose tasks and checks |
-| Approval receipt | Named human, timestamp, and fingerprint in Markdown; not executable state | The exact draft is explicitly approved in Plan mode |
-| Autonomy receipt | Invocation-scoped target, policy decisions, repository/branch identity, evidence, and plan fingerprint in Markdown | An explicit goal-driven run selects `plan`, `verified`, or `pr` |
-| Compiled tasks | Deterministic dependency graph generated from the approved Markdown plan | Build activation succeeds |
-| Journey oracle manifest | Fingerprinted typed journey oracles compiled from the plan-level decision | Build activation succeeds |
-| Journey results | PASS/FAIL and evidence bound to the oracle manifest, head commit, and diff | Before a relevant journey reaches test or review gate |
-| Delivery state | Ignored worktree-local Git active-slice state bound to the approved plan lock; never an approval artifact | Build activation and successful slice publication |
-| `changes.md` | Append-only, reviewable post-build observations with exact user message, expected/actual behavior, classification, evidence, and resolution | Controlled `record-change` transition |
-| Repair state | Ignored delivery mode, resume stage, class-specific attempt counters, active mechanism observation, and superseded receipt references | Controlled repair and gate transitions |
-| Recovery status | Read-only active/published delivery, PR lifecycle, branch/SHA identity, ambiguity, and safe next transition | Before responding to CI, review, publication denial, or ordinary correction language |
-| Operation receipt | Ignored Git-common identity, fingerprinted authority, lease, durable attempt budget, expected postcondition, and secret-free completion observation | Before and after each managed mutation or external side effect |
-| Installation repair receipt | Ignored Git-common installed/target version, direction, owned-state classifications, exact path hashes, repair fingerprint, and backup location | An update discovers or repairs Boatstack-owned control drift |
-| Gate receipt | Machine-local test or review transition bound to one delivery slice, base/head branches, commit, product diff, and evidence hash | A slice passes test or review |
-| Test plan | Requirement-to-evidence mapping with each validation's origin, falsifiable oracle, procedure, and independence | Planning and after discovered failure modes |
-| Gap ledger | Known divergence between desired and current state | Work is deferred, partial, incompatible, or intentionally absent |
-| Risk/threat note | Assets, actors, trust boundaries, abuse/failure paths | Security, data, tenancy, billing, auth, or destructive paths change |
-| Side-effect declaration | Affected paths, immutable external target, reversibility, failure policy, and destructive flag | A task can write outside the repository |
-| Runbook | Deploy, observe, recover, and roll back | Operational behavior changes |
-| Evidence ledger | Commands, results, review evidence, screenshots, CI and runtime links | Every gate |
-| PR visual manifest | Machine-local scenario, source revision, screenshot hashes, capture metadata, and publication state | Relevant PR capture and publication |
-| PR preview | Exact reviewer-ready title/body plus a hidden fingerprint of the committed diff and evidence | Ship gate, before opening or updating GitHub |
-| Move ledger | Failure class, intervention, prediction, paired result, decision | Improving the loop itself |
+- `project.json`: strict schema-2 policy;
+- `plans/.source`: exact source plan bytes;
+- `approvals/.json`: current plan-fingerprint approval;
+- `evidence//`: revision-bound gate and visual manifests;
+- `publication/.preview.json`: exact external-effect preview.
-## ADR boundary
+Machine-local state, journals, locks, receipts, and JSONL events are not product
+evidence to commit. They are partitioned by repository, clone, and worktree
+identity.
-An ADR is not a dump of all project context. It records one durable decision:
-
-- status: proposed, accepted, superseded, or rejected;
-- context and forces;
-- decision;
-- alternatives;
-- consequences and risks;
-- verification and supersession rule.
-
-Unknowns stay in the question ledger. Known incomplete work stays in the gap ledger. Temporary implementation detail stays in the plan or PR.
-
-## Gap boundary
-
-A gap is an explicit difference between the accepted target and the current implementation. Record:
-
-- expected state and actual state;
-- impact and severity;
-- reason it remains;
-- owner;
-- trigger or deadline for revisiting;
-- affected acceptance criteria;
-- whether it blocks ship.
-
-`PASS_WITH_GAPS` is allowed only if project policy permits it and no gap is critical.
-
-## Provenance
-
-Every material statement should indicate whether it came from:
-
-- the supplied host Plan-mode file;
-- repository evidence;
-- runtime evidence;
-- a human answer;
-- an accepted ADR;
-- an assumption;
-- an external source.
-
-Generated artifacts include the canonical loop version and config hash. Human edits to generated adapters are drift and should be moved into project-owned context or canonical source.
-
-A completed parent's delivery state, plan lock, and receipts remain immutable.
-Post-publication observations append to its `changes.md`; the linked corrective
-child owns all new approval, lock, gate, and publication evidence.
-
-## Insight intake boundary
-
-Each confirmed insight lives under `docs/insights//`. `capture.json` is the
-immutable machine record, `insight.md` is its human-readable projection, and
-`events.jsonl` is the append-only association, binding, evaluation, duplicate,
-and disposition history. These files are product-intake artifacts. Every insight
-mutation creates a Git diff that can move from nontechnical input to engineering
-review through a pull request. No insight content lives in detached state or the
-Git control directory.
-
-## PR projection boundary
-
-`pr.md` is a lossy review projection, not a replacement for the feature package. Its visible body contains only why, changed behavior, review order, evidence, gaps/risks, rollout, and rollback. Approval hashes, source paths, and host attribution remain in non-rendered metadata or collapsed provenance.
-
-For managed work it lives under `.product-loop/features//pr.md` and may claim only evidence present in the current approved package. For an existing or ad-hoc branch it lives under `.product-loop/pr-briefs//pr.md`, uses observed branch facts, and labels missing approval or gate evidence `NOT_VERIFIED`. Both are committed with the branch. The preview file itself is excluded from the product-diff fingerprint.
-
-Managed preview metadata also names the active delivery slice. The ignored delivery
-state and gate receipts live under the current worktree's Git directory so branch
-changes retain control state without blocking unrelated worktrees. They are runtime
-control state, not durable product evidence; the PR links the committed evidence
-ledger while the publisher rechecks the matching receipts.
-
-## Planning boundary
-
-`auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input. New schema-v3 plans require a journey-evidence decision, and schema-v3 `approval.md` binds human approval to the plan, displayed product baseline, exact branch/base/head relation, and compiled journey-manifest fingerprint. Activation repeats readiness and stores it in the immutable lock. Schema-v1/v2 receipts and active locks remain readable, but an unactivated legacy approval has no readiness authority and must be refreshed for a schema-v3 plan.
-
-## Safety boundary
-
-The generated host hook fragments and launchers are committed installation infrastructure. Their policy is immutable in project configuration. Cursor pre/post native, shell, and MCP events; Claude and Codex `PreToolUse`/`PostToolUse`; and Gemini `BeforeTool`/`AfterTool` project into one classifier and completion observer. The machine-local helper is ignored and restored by the installer. Safety evidence belongs in the feature evidence ledger: target identity, failure behavior, independent oracle, operational-diff scan, and the operator-only recovery boundary. A source edit is reviewable evidence, not permission to execute it.
-
-Operation receipts live under the current worktree's Git directory at `boatstack/operations/v2`, never in Git history. (The Git-common `operations/v1` ledger is the orphaned pre-isolation layout; `doctor` prunes it.) They distinguish prepared, executing, unknown, retryable, and terminal work across turns and linked worktrees. Receipts contain hashes and bounded observations rather than commands, tool payloads, responses, credentials, or autonomous workflow intent. Terminal identities remain long enough to consume delayed duplicate events; old detail is compacted.
-
-Installation repair receipts and backups live under Git-common `boatstack/updates/` and `boatstack/repair-backups/`. The checksum-verified target helper owns this recovery plane. Exact installed fragments migrate automatically; `--repair` covers only a displayed fingerprinted owned-state package. User-owned or ambiguous state is never converted into repair authority.
-
-## PR visual evidence boundary
-
-When `workflow.pr_visual_evidence` is enabled, the approved plan records whether screenshots are relevant and names no more than three review scenarios. PNG bytes and capability receipts live under Git-common Boatstack state; committed ledgers retain only compact metadata and hashes. PR schema v3 binds the policy, status, count, and manifest fingerprint to the preview. Screenshots are human-review evidence rather than mechanical correctness proof.
-
-## State ownership
-
-Every tree Boatstack manages has one declared owner, class, and partition. The
-authoritative registry is `StateRegistry` in the runtime; this table mirrors it
-and a conformance test holds the two together, so neither can drift silently.
-Partitions: `checkout` lives in the working tree, `per-worktree` under the
-worktree's own Git directory, `git-common` shared by every worktree of the
-clone, `external` outside the repository (Detached Supervision).
-
-| Name | Class | Partition | Owned by |
-| --- | --- | --- | --- |
-| project-config | committed-generated | checkout | init, update, export |
-| source-config | committed-generated | checkout | init, migrate-config, update |
-| generated-references | committed-generated | checkout | init, update, export |
-| guard-hooks | committed-generated | checkout | init, update, export |
-| runtime-launchers | committed-generated | checkout | init, update, export |
-| generated-lock | committed-generated | checkout | init, update, export |
-| planning-artifacts | committed-planning | checkout | planning-write |
-| approval-receipt | committed-planning | checkout | record-approval |
-| autonomy-receipt | committed-planning | checkout | record-autonomy |
-| plan-lock | committed-planning | checkout | activate-plan |
-| compiled-artifacts | committed-planning | checkout | activate-plan |
-| pr-preview | committed-planning | checkout | ship-gate, publish-pr |
-| change-ledger | committed-planning | checkout | record-change |
-| discard-archive | committed-planning | checkout | discard-delivery |
-| pr-briefs | committed-planning | checkout | pr-context |
-| verified-boundaries | committed-planning | checkout | record-delivery-gate |
-| insight-artifacts | committed-insight | checkout | insight |
-| worktree-helper | checkout-runtime | checkout | init, update, hydrate-runtime, activate-worktree-runtime |
-| managed-worktrees | checkout-runtime | checkout | workspace-cut, workspace-cleanup, workspace-reap |
-| delivery-state | runtime-worktree | per-worktree | delivery transitions |
-| operation-ledger | runtime-worktree | per-worktree | run-preflight, publishers |
-| flow-logs | runtime-worktree | per-worktree | flow |
-| guard-denial-ledger | runtime-worktree | per-worktree | safety-hook, engagement-probe |
-| runtime-slots | runtime-shared | git-common | init, update, hydrate-runtime |
-| runtime-bootstrap-slots | runtime-shared | git-common | init, update, hydrate-runtime |
-| mutation-receipts | runtime-shared | git-common | activate-plan, undo |
-| update-previews | runtime-shared | git-common | prepare-update-pr, publish-update-pr |
-| repair-receipts | runtime-shared | git-common | update |
-| visual-evidence | runtime-shared | git-common | evidence verbs |
-| quarantine | runtime-shared | git-common | repair-state |
-| host-hook-config | host-activation | checkout | activation merge only |
-| detached-registry | detached | external | attach, detach |
-| detached-repositories | detached | external | attach, detach, activate |
-
-In detached mode, `WorkspaceContext` remaps the controller bundle and every
-feature package to the external repository control root. Source plans stay at
-their declared repository paths. Installation, update, hydration, host-hook,
-and managed-worktree paths remain repository-owned.
-
-Direct `.product-loop` literals are frozen by a conformance inventory. Each
-production file is classified as one of: canonical owner, controller bundle or
-syntax, embedded installation, product-diff syntax, policy syntax, repository
-workspace, or user guidance. A new unclassified literal fails the test. Runtime
-controller reads and writes must use `WorkspaceContext.GeneratedRoot`,
-`FeatureRoot`, or `FeatureDir`.
-
-## Detached feature reattachment
-
-Run `.product-loop/boatstack attach --repo . --force` to reattach an older embedded
-open-feature package. Boatstack verifies the plan and approval or autonomy
-fingerprints, copies the package atomically, and verifies the copied hash. The
-machine result is `IMPORTED`, `UNCHANGED`, `CONFLICTING`, or `REJECTED`.
-Conflicts and stale receipts fail closed. Boatstack never chooses by recency and
-never deletes the embedded source package.
-
-## Templates
-
-Copy only the templates required for the current slice from `assets/templates/`. Do not create empty ceremony. The feature spec, question ledger, test plan, gap ledger, and evidence ledger are the usual minimum for material product work.
+An artifact is data, not authority. The kernel checks its fingerprint, source
+snapshot, goal, transition, and authority receipt before use.
diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md
index aba83f0..5d88cd6 100644
--- a/boatstack/references/config-schema.md
+++ b/boatstack/references/config-schema.md
@@ -1,148 +1,15 @@
-# Boatstack Configuration Schema
+# Configuration schema
-
+Boatstack V2 accepts only `.boatstack/project.json` schema version 2. The
+normative Go decoder is
+`internal/kernel/protocol.DecodeProjectConfig`; the public example is
+`project.example.json`.
-This reference document defines the schema and version history of `.boatstack-project.json`.
+Top-level keys are `schema_version`, `project`, `policy`, and `hosts`.
+Unknown keys and trailing JSON fail. Hosts are selected from `claude`, `cli`,
+`codex`, `cursor`, `gemini`, and `mcp`; `cli` is mandatory.
-## Current Schema Version
-
-- **schema_version**: `1`
-
-## Field Reference
-
-This is the exhaustive serialization contract, not a list of recommended user edits. Fields are classified as **deterministic control**, **agent-mediated guidance**, **identity/compatibility metadata**, or **installer-owned state**. The public configuration guide contains only supported user controls.
-
-### Root Fields
-
-- `schema_version` (integer, required): Must be exactly `1`. Identity/compatibility metadata managed by Boatstack.
-- `project` (object, required): General project definition.
-- `workflow` (object, required): Flags controlling state machine transitions and safety gates.
-- `workspace` (object, optional): Opt-in per-feature branch or worktree management.
-- `delivery` (object, optional): The standing goal of the delivery flow.
-- `insights` (object, optional): Opt-in controls for independent, reviewable repository insight captures.
-- `adapters` (array of strings, optional): Enabled host environment adapters. If empty, defaults to enabling all.
-- `integrations` (object, optional): Installer-owned state for third-party integrations.
-
-### project Fields
-
-- `name` (string, required): Identity metadata written into generated configuration.
-- `default_branch` (string, optional): Deterministic base for freshness, PR, update, and workspace operations.
-- `context` (array of strings, optional): Agent-mediated durable-context hints; the controller does not load every path automatically.
-- `commands` (object, required): Agent-mediated repository commands:
- - `test` (string, required): The exact command to execute project-local tests.
- - `visual` / `screenshot` / `e2e` (string, optional): The repository-owned visual capture harness Boatstack runs automatically during ship. A surface-scoped key `visual:` (e.g. `visual:web`, `visual:ops`; lowercase kebab surface, registered with `capability-register --surface`) outranks the global key for scenarios that declare that `surface`; scenarios without one, or without a surface key, use the global command exactly as before.
- - Other command names (string, optional): Additional repository-owned commands such as `build`, `lint`, or `typecheck`.
-- `high_risk_paths` (array of strings, optional): Glob patterns of files requiring independent reviewer sign-off before shipping.
-- `visual_surfaces` (array of objects, optional): Registered product surfaces. Each object has a lowercase-kebab `id` and repository-relative `paths`; changes below these paths are screenshot candidates and cannot use `not_relevant`.
-- `migration` (object, optional): Declares how migrations are graded by EFFECT against a disposable database, so a committed migration stays a data artifact for the guard while its real effect is executed and observed by a conformance harness. Both commands run via `sh -c` with the disposable database coordinate in the environment as `BOATSTACK_MIGRATE_DB`; when `apply_command` is absent, grading is skipped.
- - `apply_command` (string, optional): The command that applies the migration set to the disposable database.
- - `verify_command` (string, optional): The command that asserts the post-migration invariant; a non-zero exit grades the migration FAIL.
-
-### workflow Fields
-
-- `human_plan_approval` (boolean, optional): Deterministic activation control. `true` requires a current human receipt; `false` creates a fingerprinted policy lock.
-- `independent_review_for_high_risk` (boolean, optional): Deterministic review control. Matching diffs require reviewer identity and method `human_peer` or `separate_agent`.
-- `allow_pass_with_gaps` (boolean, optional): Deterministic gate control. `false` rejects `PASS_WITH_GAPS`; `true` preserves explicit gaps.
-- `maintain_changelog` (boolean, optional): Whether a reader-visible `CHANGELOG.md` entry is required for each delivery slice.
-- `boundary_analysis` (boolean, optional): Agent-mediated planning guidance that presents local repair versus programmatic enforcement as a material product decision.
-- `pr_visual_evidence` (string, optional): `off`, `suggest`, or `require`. Omission is `off`. Relevant PRs use machine-local PNG evidence without committing media to Git; `suggest` records missing evidence as a visible gap and `require` blocks completed publication. When the approved plan declares `relevance: relevant` with scenarios, `suggest` ships with require semantics for that feature (a plan that promises pixels cannot ship without them) — even when no capture capability is registered yet. The two escapes are `off` (global) and a `not_relevant` plan decision with a reason (per feature, for genuinely nonvisual changes). Boatstack runs a registered capture command (`project.commands.visual`) automatically during ship, so under normal provisioning the escalation is invisible.
-- `visual_evidence_publish` (object, optional): Publish control for externally hosted screenshots. Omission defaults to Litterbox with a 72-hour expiry. Upload is refused until every PNG has explicit human privacy review. Fields:
- - `mode` (string, optional): Compatibility value `external-host`; external hosting is always used.
- - `host` (string, optional): `litterbox` (default) or `catbox`. Only meaningful when `mode` is `external-host`. `litterbox` auto-expires uploads; `catbox` is permanent.
- - `expiry` (string, optional): `1h`, `12h`, `24h`, or `72h` (default `72h`). Only meaningful for an expiring host; the PR comment reminds reviewers of the host and this window.
-- `external_authority` (object, optional): Declares the credential boundary for managed runs. Omission or `mode: "hook-only"` reports `HOOK_GUARDED`; it never claims cloud credentials are constrained. `mode: "credential-enforced"` requires a short-lived external receipt signed by an issuer in a protected external trust store.
- - `mode` (string): `hook-only` or `credential-enforced`.
- - `trust_store` (string): Absolute path to an operator-provisioned JSON file mapping issuer IDs to base64 Ed25519 public keys. Boatstack rejects a trust store or parent directory owned or writable by the managed principal and never holds a signing key.
-- `ignored_deliveries` (array of strings, optional): Deterministic ambiguity control. Feature slugs of past deliveries to exclude from delivery-ambiguity resolution so historical work no longer blocks new work. New, unlisted ambiguous deliveries still pause the workflow.
-
-### workspace Fields
-
-- `enabled` (boolean, optional): Enables managed per-feature workspaces. Defaults to `false`.
-- `mode` (string, optional): `worktree` or `branch`. Defaults to `worktree` when workspace management is enabled.
-- `cleanup` (string, optional): `confirm`, `auto`, or `off`. Defaults to `confirm`. Governs single-feature cleanup of the named workspace.
-- `cleanup_after` (string, optional): `merge` or `ship`. Defaults to `merge`.
-- `reap` (string, optional): `confirm`, `auto`, or `off`. Defaults to `confirm`. Governs the post-merge sweep that reclaims all terminal (merged or abandoned) Boatstack workspaces at once. `confirm` prompts the operator once when reclaimable workspaces exist; `auto` reclaims them without prompting; `off` disables the sweep and its prompt.
-
-### delivery Fields
-
-- `terminal` (string, optional): `published` or `merged`. Defaults to `published`. Deterministic goal control: the state a delivery pursues before the flow reports nothing left to do. `published` ends the flow when the slice's pull request is open (the prior behavior, unchanged). `merged` keeps the read-only flow advisors naming post-publish steps until the pull request is observed merged. The goal a delivery is activated under is snapshotted on its state, so changing this value mid-flight never changes an in-progress delivery's goal; every invalid or unreadable value resolves to `published`.
-
-### insights Fields
-
-This block is opt-in. When `enabled` is false or the block is absent, Boatstack preserves existing behavior. Every confirmed capture and every later insight event is written below `docs/insights//` so the handoff is a reviewable repository diff. Boatstack never stores insight content in detached state or the Git control directory.
-
-- `enabled` (boolean, optional): Enables independent insight capture and evaluation.
-- `capture_mode` (string, optional): `manual`. Boatstack previews each capture and requires a separate state-scoped save confirmation.
-- `value_map` (string, optional): `required`. The confirmed capture must contain the canonical Product Value Map lineage and exact source binding.
-- `suggest_features` (boolean, optional): Allows the host adapter to propose one primary topic and related topics without creating a delivery.
-- `evaluate_on_pr` (boolean, optional): Appends an evaluation event when Boatstack publishes or observes a terminal PR for a bound managed feature. Evaluation never completes a capture.
-- `pending_frontier` (boolean, optional): Enables the separate read-only insight frontier. It never replaces the delivery frontier.
-- `completion_mode` (string, optional): `human_confirmed`. A human records final disposition; completing before readiness requires a non-empty reason.
-
-### adapters Values
-
-Supported values are `cursor`, `claude`, `codex`, `gemini`, and `github`. An empty or omitted array enables all supported adapters.
-
-### integrations Fields
-
-Supported integration keys are `gstack` and `spec-kit`. The installer owns these records; hand edits do not select or pin an installation. Each state can contain:
-
-- `requested` (boolean, required when the integration is present): Whether installation was requested.
-- `status` (string, optional): Installer-maintained installation status.
-- `version` (string, optional): Installer-maintained pinned version or revision.
-- `detail` (string, optional): Installer-maintained diagnostic detail.
-
-## Version Changelog
-
-### Version 1
-
-- Initial schema with `project`, `workflow`, `workspace`, `adapters`, and `integrations`.
+Configuration changes use `configuration.mutate` with `config_path` and
+`config_sha256`. That fingerprint is the SHA-256 of the strict decoded schema-2
+value in canonical JSON form, not the source file's raw bytes; the CLI derives it
+when omitted. Never hand-edit controller state or reuse a V1 schema.
diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md
index cb8e4a4..8bc8936 100644
--- a/boatstack/references/failure-moves.md
+++ b/boatstack/references/failure-moves.md
@@ -1,74 +1,18 @@
-# Failure taxonomy and move catalog
-
-Select a move only after locating the failure below its surface symptom. “Timed out,” “tests failed,” and “the agent got confused” are starting observations, not diagnoses.
-
-The `root-cause` operation operationalizes this taxonomy for a single bug: it classifies the failure against the classes below, produces a cited root-cause chain, and proposes the structural change that eliminates the class rather than patching the instance, then hands the result to `auto-plan` as a source plan.
-
-| Failure class | Evidence | Candidate moves | Main regression risk |
-|---|---|---|---|
-| Unknown requirement | Plausible implementations disagree on product behavior | Ask a targeted human question; record answer and expiry | Invented requirements or stalled delivery |
-| Context miss | Relevant interface/invariant existed but was not loaded | Reload minimal relevant context; add routing reference | Blind truncation removes useful state |
-| Protocol malformed | Invalid JSON/schema/tool call despite recoverable intent | Parse repair; schema validation; constrained retry | Retrying semantic errors as syntax |
-| Tool/transport | API, shell, network, or environment failure | Classify retryability; bounded retry; fallback; resume | Duplicate side effects or retry storms |
-| Step/budget exhaustion | Progress is still converging at cap | Continue from checkpoint; conditional budget increase | More time converts timeout into wrong answer or thrash |
-| Thrashing | Repeated actions without new evidence | Stop after repeated tactic; re-diagnose; stronger planner | Spending more tokens on the same loop |
-| Implementation correctness | Independent tests fail the contract | Local repair from failing evidence; narrower task | Rebuilding and losing near-correct work |
-| Test fidelity | Tests pass wrong code or reject correct code | Contract fixtures; collect/load gate; mutation/differential/human oracle | Treating more model-authored tests as truth |
-| Review miss | Defect found after same-agent review | Independent reviewer; risk checklist; mechanical enforcement | Expensive review everywhere |
-| Scope drift | Diff no longer maps to approved outcomes | Re-scope; split PR; update spec with approval | Hiding product changes in implementation |
-| Update self-lockout | An installed helper, stale hook event, or damaged owned receipt blocks its own updater | Let the verified target helper classify state; migrate exact provenance automatically or offer fingerprinted `--repair` | Reinstalling blindly, overwriting user settings, or treating `--repair` as downgrade authority |
-| Controller-root split | A detached controller path is resolved under external state, then an effectful caller independently validates it against the repository or Git directory and rejects its own owned path as an escape | Carry the target and its owning boundary as one typed value; derive child paths from it; make every effect validate that value; test embedded/detached and worktree/shared storage classes | Broadening the boundary to bypass validation, or letting another caller reconstruct the root independently |
-| Recovery provenance self-dependency | A damaged local install lock is the first candidate used to decide whether that same lock may be repaired, so a development or malformed identity blocks the verified target helper before recovery begins | Treat the local lock as evidence when valid; otherwise derive the prior stable identity from the committed generated pin and let the verified target helper classify the exact owned repair | Trusting an uncommitted generated lock, inferring an arbitrary version, or overwriting mixed/user-owned state |
-| Ownership projection contradiction | Admission classifies a path as Boatstack-owned, or a configuration command writes one generated projection, then final validation rejects the controller's own bounded mutation | Build one semantic ownership projection before execution; route configuration changes through one authority-aware transaction; reuse it for admission, mutation, final verification, staging, and preview | Path-only allowlists accepting user content, generated-only configuration writers, or independently maintained validators disagreeing after a side effect |
-| Security/tenancy | Trust boundary or data scope violated | Specialist review; invariant test; deny-by-default guard | Generic prompt mistaken for enforcement |
-| Integration/deploy | Local pass but runtime fails | Environment parity; canary; health checks; rollback | Treating staging as identical to production |
-| Documentation drift | Durable behavior and docs disagree | Update source-of-truth artifact; drift check | Growing instructions with unverified rules |
-| Irreversible recovery escalation | A failed external operation causes authority/target broadening or an invented reset | Immutable pre-execution deny; preserve state; read-only diagnosis; transactional retry or fix forward | False denial of legitimate isolated development operations |
-| Worktree bootstrap deadlock | A linked worktree inherits fail-closed hooks but not the ignored runtime required to evaluate them | Versioned Git-common runtime; atomic first-use hydration; provenance check | Cross-version execution or weakened failure behavior |
-| Cross-clone runtime-absence lockout | Only pointers (the guard's baked version path, the committed version pin) travel through Git; the version-keyed runtime bytes are gitignored and delivered out of band. So a teammate who pulls a merged version bump — or clones fresh — holds the new pointers but an empty slot, and the guard fail-closes ("shared runtime is missing") before any Go runs, stranding every teammate on every bump until each manually re-installs | The guard auto-hydrates an absent slot by running the tag-pinned, `.sha256`-verified installer in a branch-free, slot-only `hydrate-runtime` mode, serialized clone-wide by an atomic `mkdir` lock and bounded by a timeout, then falls through to the existing gates which stay authoritative and fail-closed; the deny message embeds the exact one-line self-heal, and `BOATSTACK_AUTO_HYDRATE=0` is the kill switch. Hydration refuses any running-vs-pin identity mismatch and touches no committed generated file | Running a fetched installer on cold start (bounded by tag pinning, HTTPS, sidecar verification, the guard's own checksum re-verify before `exec`, and the kill switch), or falling open — hydration is additive only, never a new authority for `exec` |
-| Post-publication correction routing | CI, review, or a denied push targets work already marked published — including a published-but-open *earlier slice* inside a still-active delivery, which the pointer-based advisors mis-routed to the active slice | Resolve the target through the same addressable-slice set the actuators use ({active slice} ∪ {published slices whose PR is not terminal}), keyed off the correction's branch: route a non-terminal published slice to an in-place re-gate/`publish-pr --action update` of *that* slice (not the active one), and only a terminal PR to an independently approved corrective child. Run recovery routing, change recording, and the publication-bypass finding through that one resolver so the advisor layer cannot drift from the actuator layer | Treating PR creation as completion, asking the user to bypass the guard, or an advisor/actuator addressability split-brain that repairs the wrong slice |
-| Unobserved side-effect completion | The same visible state could mean not started, executing, succeeded with a lost response, or failed | Durable operation receipt; exact lease; observe completion; reconcile the expected postcondition before retry | Conversation-scoped retry loops, duplicate PRs, or phantom success |
-| Postcondition-regressed terminal receipt | A durable local operation once reached `SUCCEEDED`, but its repository postcondition was later restored or regressed while the detached receipt survived; a retry consumes the terminal identity and returns success without rebuilding the missing state | Before consuming terminal success, verify the operation's declared postcondition against current repository and runtime state; keep success when it holds, otherwise reopen only the same bounded atomic-local operation and re-execute under its existing authority | Globally reopening terminal receipts, retrying irreversible external effects, or trusting the receipt instead of the current postcondition |
-| Malformed-draft selection | A hand-authored feature `plan.md` never passed through the helper, so candidate discovery can mistake its `CheckPlan` failure for workflow authority | Keep unselected drafts inert; validate only an explicitly selected candidate. `repair-state` remains the bounded quarantine path and refuses any directory with a lock, `pr.md`, delivery state, or tracked files | Silently accepting the selected plan, or allowing draft evidence to create engagement |
-| Premature supervisory pointer advance | A durable supervisory pointer/state advances on request-success and revokes the correction actuator for a target whose postcondition (CI, merge) is not yet observed, so the stranded target can never be re-addressed | Separate the advance from correctability: keep a bounded in-place actuator for a non-terminal target (re-gate/re-publish the same open PR) and a bounded forward actuator once it is terminal (corrective child); resolve addressability network-free from a persisted terminal-state cache, never advance a supervisory pointer past an unobserved postcondition | Serializing legitimately-parallel work by refusing to advance, or persisting an identity/status that deadlocks the corrected retry |
-| Non-transactional multi-file promote | A managed artifact spans files that must land together (e.g. the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, and the `plan.lock.json` that binds them), but independent non-atomic writes can leave a partial set on a crash or a failed post-write check | Promote the whole set through the transactional mutation boundary as one mutation: base-hash preconditions, supervisor-authority binding, atomic all-or-nothing write, post-write verification with automatic rollback, and a reversible receipt whose inverse bytes make the boundary closed under inversion — `undo` re-applies the inverse as a mutation (with redo as undo-of-the-undo), and a domain guard refuses reversal once a delivery gate would be stranded | Patching consistency after the fact with hash guards instead of making the promote atomic, persisting a rejected identity so a corrected retry deadlocks, or undoing an activation that strands live delivery state |
-| Provenance-blind runtime install | A write path stamps an artifact's declared identity (version/commit) from one origin — the running process's compile-time globals — while binding its integrity proof (checksum) to a different origin — the passed bytes; every checksum gate passes because the lock is internally consistent, but the binary self-reports a third value and the version gate fail-closes (clone-wide when the runtime is shared). Symptom: `update -binary ` run by an older helper writes newer bytes into the older version's slot, then every worktree's guard denies at once | Derive the installed artifact's identity from the artifact itself (execute its `version` self-report) and enforce it at the *write* boundary: refuse to install a `-binary` whose self-report disagrees with the running process, and re-exec a cross-version candidate so it installs itself — running becomes installed, so its embedded bundle, constants, slot path, and receipts are all authoritative by construction. Re-hash the just-written slot against its manifest and roll back on mismatch | Executing an untrusted candidate (bounded, operator-invoked only), or converting a recoverable slot mismatch into a hard clone-wide refuse that blocks legitimate upgrades; a per-read self-report exec would tax every guard event, so identity is enforced where it is written, not on the hydration hot path |
-| Progress-erasing plan re-lock | Delivery state is keyed to the plan lock hash, so any re-activation that changes the lock (an amended tail, a reordered task, even a whitespace edit) re-derives the slice list — and the re-derivation reset the active pointer to `0`, dropping every already-published slice back to `BUILD`. The guard for this reset only covered the fully-published case, so a *partially* delivered feature (slice one merged, slice two mid-build) silently lost the record of what shipped. Downstream this deadlocks: `undo` refuses to reverse the reset because a live delivery gate would be stranded, and every forward verb re-derives the same wrong state. Symptom: after a benign plan edit the agent loops between `activate-plan` (which re-zeros progress) and `undo`/`run-preflight`/`record-delivery-gate` (all blocked) | Reconcile the amended plan against the existing delivery state instead of resetting it: preserve the published prefix `[0, ActiveIndex)` verbatim (status and PR bookkeeping intact), keep the pointer, and recompute only the recomputable tail (active slice → `BUILD`, rest → `PENDING`), recording the superseded lock. Refuse — before the transactional promote, so nothing half-applies — any amendment that drops, reorders, renames, or changes a *published* slice, or touches a fully-published (immutable) delivery, directing the operator to a corrective child delivery. "Published" is keyed off the pointer and `Status`, never `pr_state`, which real projects leave empty even on merged PRs | Keying delivery identity to the lock hash makes every plan edit a candidate reset, so the reconcile must be the only re-derivation path; treating `pr_state` as the published marker would mis-classify shipped slices as amendable; refusing legitimate tail amendments would push routine edits into unnecessary child deliveries |
-| Canonical-artifact-location resolver bypass | A managed artifact has two on-disk layouts (a compiled-trio artifact like `evidence.md` lives under `compiled/` in the current layout, at the feature root in the legacy one). A shared dual-layout resolver exists — but one consumer hand-joins a single fixed path instead of calling it, so it diverges from every layer that does. Symptom: `activate-plan` writes `compiled/evidence.md` and pr-context resolves it, yet `record-delivery-gate` (which hardcoded the feature root with no fallback) fails with "delivery gate requires current evidence" on a project that keeps its ledger only at `compiled/` — the gate demanding the file at a path nothing writes | Route *every* consumer of the artifact through the one dual-layout resolver (`featureEvidencePath` → `featureArtifactPath`, canonical location first, the other layout as fallback), so the recorder and pr-context provably resolve the same file for a given feature. Add an anti-drift test asserting the two agree, and keep the fallback strictly additive so the legacy layout still resolves | Hand-joining an artifact path anywhere a shared resolver exists — the straggler silently diverges the moment the canonical layout moves; equally, "normalizing" every consumer to the newer layout when the writer/legacy contract still prefers the other, which trades a missing-file failure for reading the wrong (stale or template) copy |
-
-## Lessons encoded from the benchmark campaign
-
-- **Parse repair is a protocol move.** It can recover malformed completion without pretending to improve reasoning.
-- **More steps are conditional.** Qwen experiments reduced step exhaustion but largely converted it into confident wrong answers. Increase budget only when trajectories show continuing progress.
-- **Strict self-checking is not monotonic.** A stricter prompt caused collateral rework and regression. Preserve a known-good snapshot and require an oracle with fidelity to the real goal.
-- **Self-authored tests are scaffolding before they are truth.** Spec-first helped a development slice but its frozen oracle agreed poorly with the hidden grader and did not transfer to the full board.
-- **Development promotion is not product promotion.** A +7 point development result became a statistical wash on the full distribution. Representative evaluation and holdout remain mandatory.
-- **Do not discard near-correct work.** Repair attempts can wash or regress, so retain prior evidence and compare states.
-- **Model changes relocate the bottleneck.** The same harness exposed different binding modes on Gemini and Qwen. Route moves by measured failure population, not by a universal “best loop.”
-- **Tool failure must not create recovery authority.** The sanitized database incident moved from a partial schema apply failure to an invented reset path. The irreversible-operation guard is `PROPOSED`, not promoted: evaluate its deny corpus, safe corpus, latency, and workflow regressions against the unguarded baseline.
-- **Fail-closed controls need an available evaluator.** A linked worktree copied the safety hook but not its ignored helper, so the guard also denied its own repair command. Share only the verified runtime within the Git clone and hydrate local ignored state before judging the original event.
-- **A retry needs a new observation.** Identical in-flight calls wait. Unknown non-idempotent calls enter `RECONCILE_REQUIRED`; Git, GitHub, filesystem, browser, and MCP boundaries must observe their exact postcondition before another attempt consumes the persistent budget.
-- **Preconditions run before leases.** Wrong branch, stale base, or invalid diff state returns a recovery operation without creating a durable attempt. A rejected precondition cannot consume retry budget or leave an identity that collides with the corrected invocation.
-- **Identity is proven at the write, not inferred from a checksum.** A checksum proves *these bytes match this lock*; it never proves *this binary is the version it claims*. When `update -binary` stamped the running helper's version onto a newer binary's bytes, both checksum gates passed and the shared runtime fail-closed clone-wide. The fix makes running == installed by re-execing a cross-version candidate to install itself, and refuses a directly-passed mismatched binary — provenance is enforced at the one boundary that writes it.
-- **When only pointers travel through Git, absence is the default state, not the exception.** The runtime bytes are gitignored and version-keyed, so every merged version bump and every fresh clone starts with an empty slot; a guard that only *denies* on absence strands every teammate on every bump. The fix lets the guard self-heal by running the pinned, verified installer in a branch-free hydrate mode — but strictly additively: the existing deny gates stay authoritative and fail-closed, hydration refuses any running-vs-pin mismatch, and a kill switch plus an embedded self-heal command keep the human in control. Convenience on cold start must never become a new authority for execution.
-
-## Move proposal schema
-
-Before experimenting, record:
-
-```yaml
-id: stable-move-name
-target_failure: one-class
-population: observable predicate selecting affected runs
-mechanism: why this intervention should change the outcome
-change: one minimal behavioral delta
-expected_effect: directional metric prediction
-cost: latency, tokens, money, and human attention
-risks: plausible regressions and affected populations
-smoke: cheapest mechanism check
-evaluation: paired sample, representative distribution, holdout
-rollback: identity/default behavior
-decision: PROPOSED | PROMOTE | REJECT | WASH
-```
-
-Never promote from an unpaired anecdote, a mid-run aggregate with mismatched coverage, or a metric produced solely by the model being evaluated.
+# V2 failure moves
+
+Use the failure class, not the latest symptom:
+
+| Failure class | V2 move |
+|---|---|
+| stale snapshot or prescription | re-observe; request a new admission |
+| ambiguous identity | preserve resources; supply exact invocation |
+| configuration drift | validate and request `configuration.mutate` |
+| runtime absent or wrong | install a verified candidate; request runtime update |
+| interrupted local transaction | resume or roll back the exact journal |
+| unknown external settlement | observe/reconcile; never blind retry |
+| closed-unmerged publication | return to frontier; preserve workspace |
+| changed approved intent | `plan.amend`, validate, and approve the amendment |
+| unproved cleanup | refuse until landing or abandonment is established |
+
+Repeated denial without a changed snapshot is a stall. Do not try another host
+or edit state to force progress.
diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md
index 8612e93..f0f17ab 100644
--- a/boatstack/references/host-hook-contracts.md
+++ b/boatstack/references/host-hook-contracts.md
@@ -1,90 +1,31 @@
-# Host hook contracts
-
-Verified against the published host contracts on 2026-07-22. Recheck these
-sources before changing a generated adapter or making a stronger enforcement
-claim.
-
-| Host | Configuration and event | Blocking result | Activation boundary |
-| --- | --- | --- | --- |
-| Cursor | `.cursor/hooks.json`; synchronous `preToolUse`, `postToolUse`/`postToolUseFailure`, `beforeShellExecution`/`afterShellExecution`, and `beforeMCPExecution`/`afterMCPExecution` | JSON `permission: "deny"`; generated entries set `failClosed: true` | Reload and host enablement are operator-visible. Native Write/Edit tools, shell, and MCP mutations cross the guard and completion observer. |
-| Claude Code | `.claude/settings.json`; `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` | Exit 0 with `hookSpecificOutput.permissionDecision: "deny"`, or exit 2 with a secret-free error | The generated command explicitly uses Bash and `${CLAUDE_PROJECT_DIR}`. Reload and confirm with `/hooks`. |
-| Codex | `.codex/hooks.json`; `PreToolUse` and `PostToolUse` | Exit 0 with `hookSpecificOutput.permissionDecision: "deny"`, or exit 2 with a secret-free error | The project path and exact hook hash must be reviewed and trusted. A linked worktree is a distinct project path. Start a new task after trust changes. |
-| Gemini CLI | `.gemini/settings.json`; `BeforeTool` and `AfterTool` | JSON `decision: "deny"` with a secret-free reason | The generated sequential hooks supervise requests and observe results through the same repository guard. Reload after installation. |
-
-Sources:
-
-- Cursor: https://cursor.com/docs/hooks
-- Claude Code: https://code.claude.com/docs/en/hooks
-- Codex: https://learn.chatgpt.com/docs/hooks
-- Gemini CLI: https://geminicli.com/docs/hooks/reference/
-
-## Compatibility policy
-
-The shared classifier accepts only the normalized tool name and input produced
-by a host adapter. Current event names are authoritative. Missing event names
-receive bounded legacy support only when the payload is unambiguous; unknown,
-ambiguous, or malformed events deny without echoing tool arguments.
-
-Deterministic schema, payload, decision, exit-code, and hydration fixtures block
-release. Live host checks are opt-in through `BOATSTACK_LIVE_HOST_TESTS=1` and
-report host availability separately from deterministic conformance.
-
-During an update, the committed fragment from the installed release is the
-ownership witness. Entries that match it exactly may move to new events or be
-removed when an event retires, even when the target release no longer accepts
-that old event. A marker-only or modified entry requires a fingerprinted
-`--repair`; unrelated host entries are preserved, and malformed or mixed state
-remains blocking.
-
-Publication denials carry only secret-free recovery context: blocking feature
-and slice, branch relation, parent delivery, and the read-only next operation.
-Every host receives the same instruction to preserve edits and enter managed
-recovery. A host must never translate denial into a request that the user repeat
-the push or PR mutation manually.
-
-Pre-activation denials use `workflow-phase-bypass` and may add only the feature,
-observed workflow stage, attempted repository path, and deterministic next
-operation. No task notification, conversation turn, or async completion changes
-the authorization decision.
-
-Managed mutations add one single-use operation lease before the host tool runs.
-Post-tool events may complete only the matching kind, target, and argument
-fingerprint. A delayed or duplicated completion cannot initiate work. Missing or
-uncertain completion becomes `RECONCILE_REQUIRED`, and safety output may expose
-only operation identity, state, attempt number, and whether reconciliation is
-required.
-
-## Denial rendering
-
-A denial is a guardrail, not a crash. Every human-facing denial is one structured
-value (`denial.go`) rendered by the surface that shows it:
-
-- Hook decision `reason` (all hosts): a plain, multi-line message — a badge line
- (`Blocked by Boatstack`), the guidance, a reassurance line, and the recovery
- hint. This is the safe default every host displays.
-- CLI errors and guard-script stderr: the same message as an ANSI soft-coral badge
- when the stream is a real terminal, and the plain form (the literal `BLOCKED:`
- prefix for CLI errors) when redirected. Controlled by `BOATSTACK_COLOR`
- (`auto` default, `always`, `never`) and `NO_COLOR`.
-- Structured object (opt-in): `BOATSTACK_DENIAL_RICH=1` adds a `boatstackDenial`
- object next to the reason for a host that adopts rich denial rendering.
-
-### Unknown-key tolerance (rechecked 2026-07-26 against the sources above)
-
-Whether a host rejects unknown keys in the decision JSON governs the structured
-object. None of the four host docs state that extra keys are rejected, and each
-already defines additional optional fields (Claude `additionalContext` /
-`updatedInput`; Gemini `systemMessage` / `continue`), which implies permissive
-parsing — but none guarantees tolerance either.
-
-| Host | Documented extra fields | Rejects unknown keys? | Structured-object default |
-| --- | --- | --- | --- |
-| Claude Code | `additionalContext`, `updatedInput` (under `hookSpecificOutput`) | Not documented; lean tolerant | off (opt-in), nested in `hookSpecificOutput` |
-| Cursor | — | Not documented; lean tolerant | off (opt-in) |
-| Gemini CLI | `systemMessage`, `continue` | Not documented; lean tolerant | off (opt-in) |
-| Codex | — | Not documented; treat as strict (portable-only host) | off (opt-in) |
-
-Because tolerance is unverified, the structured object is off by default and the
-flat `reason` string is always complete on its own. Enable it per host only after
-the host is confirmed to ignore unknown keys, and never add fields to the
-Claude/Codex empty-allow path.
+# Host and hook contract
+
+All hosts call `boatstack rpc` with one schema-2 JSON object and read one
+schema-2 response. The decoder rejects unknown fields and trailing JSON.
+
+Example read request:
+
+```json
+{
+ "schema_version": 2,
+ "operation": "resolve",
+ "repository": "/absolute/worktree",
+ "host": "codex",
+ "correlation_id": "host-123",
+ "goal": {
+ "id": "search-timeout",
+ "kind": "verified-implementation",
+ "delivery_id": "search-timeout"
+ },
+ "authority": {"receipts": []}
+}
+```
+
+For a pre-execution hook, use operation `guard` and add `command`. The helper
+classifies raw text once, returns `guard.allowed`, and never writes the command
+to receipts or events.
+
+Hosts may render commands differently. They may not change the transition ID,
+goal, source predicate, authority clauses, parameters, or expected
+postcondition. CLI, Cursor, Codex, Claude, Gemini, and MCP are capability labels,
+not controllers.
diff --git a/boatstack/references/irreversible-operation-boundary.md b/boatstack/references/irreversible-operation-boundary.md
index f491688..8ad2f9f 100644
--- a/boatstack/references/irreversible-operation-boundary.md
+++ b/boatstack/references/irreversible-operation-boundary.md
@@ -1,45 +1,12 @@
# Irreversible-operation boundary
-Boatstack removes high-confidence irreversible external side effects from the coding agent's reachable action space. It does not restrict ordinary implementation choices.
+The shared guard denies high-confidence destructive shell operations. Managed
+Git push, PR mutation, and worktree removal are routed through registered
+transitions while Boatstack is engaged.
-## Immutable engaged policy
+External effects require preview, exact authority, idempotency, execution,
+observation, and reconciliation. A request accepted by a provider is not proof
+that the effect settled. Unknown settlement remains a recovery fact.
-While Boatstack is explicitly engaged, the guard always denies:
-
-- database or schema drops, truncation, resets, flushes, destructive downgrades, clean restores, and unbounded deletes or updates;
-- recursive removal of repository, home, root, parent, or wildcard targets;
-- destructive Git cleanup, hard resets, and forced remote-history replacement;
-- cloud, project, database, cluster, namespace, or volume destruction;
-- Supabase branch deletion and lifecycle weakening that makes a protected branch deletable;
-- public or unauthenticated service exposure through supported cloud and cluster control planes;
-- disabling recovery or deleting backups and snapshots.
-
-There is no break-glass token or in-session override. Intentional destructive recovery belongs to a separately controlled operator surface outside Boatstack. Agents may edit source that describes a dangerous operation for review, but may not execute it; an operational diff containing that capability blocks build activation and subsequent gates until it is removed or transferred to the operator boundary.
-
-This policy applies only during an explicit Boatstack command or verified active delivery. Outside engagement, Boatstack is inert and repository administration remains under the host and operator's authority. During engagement, the project-local `workspace-sync` helper remains the bounded alignment actuator.
-
-## Failure response
-
-After an external-write failure:
-
-1. preserve the partial state;
-2. use read-only inspection to establish the exact target and failure;
-3. stop rather than widen credentials, targets, or authority;
-4. retry only when the operation is transactional and retry-safe, otherwise fix forward;
-5. record the failure and recovery evidence.
-
-Planning declares each external side effect with its kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Test evidence must independently prove target selection and transactional or fix-forward behavior.
-
-## Defense in depth
-
-Project hooks are deterministic interception, not a complete security sandbox. Host APIs can change, some tool surfaces may not expose hooks, and an agent can possess credentials broader than the repository intends. [Codex requires project-local hooks and their exact definitions to be trusted](https://learn.chatgpt.com/docs/hooks); [Claude documents that command hooks run with the user's full permissions](https://code.claude.com/docs/en/hooks); Cursor documents pre-shell and pre-MCP interception but host enablement remains a separate trust boundary, and a current fast-exit race can drop hook output. Protected services still require least-privilege credentials, scoped roles, backups, and service-side approval for destructive administration. `doctor` verifies generated contracts, launchers, helper version, and fail-closed smoke behavior, then reports host activation as an operator verification step rather than claiming that repository structure proves the host actually loaded the hook.
-
-Managed-run preflight reports this distinction directly. `HOOK_GUARDED` means the deterministic hook blocks recognized unsafe effects, but ambient cloud authority is not proven absent. `CREDENTIAL_ENFORCED` requires `workflow.external_authority.mode: "credential-enforced"`, an operator-provisioned trust store outside the managed principal's writable boundary, and a short-lived Ed25519-signed receipt from service IAM, a credential broker, or an isolated host. The receipt binds the repository, worktree, host session, principal, issuer, enforcement mechanism, and expiry, and must attest `repository-only` authority with no cloud control-plane capability. Missing, stale, mismatched, overprivileged, self-authored, or invalidly signed receipts block the run before delivery mutation.
-
-The external attestor obtains the expected repository and worktree fingerprints from `.product-loop/boatstack authority-context --repo .`. The managed host supplies the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the session binding in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. These coordinates are bindings, not credentials, and must contain no secret material.
-
-The receipt is strict JSON with `schema_version: 1`, the two context fingerprints, `host_session`, `principal_fingerprint`, `authority_class: "repository-only"`, `cloud_control_plane_authority: false`, `enforced_by` (`service-iam`, `credential-broker`, or `isolated-host`), `issuer`, RFC 3339 `issued_at` and `expires_at`, and a base64 Ed25519 `signature`. Its maximum lifetime is 15 minutes. The signing payload is the compact JSON returned by `AuthorityReceiptSigningBytes` with `signature` set to the empty string; unknown or duplicate fields are rejected.
-
-## Evaluation status
-
-This guard is a **PROPOSED** Move. Existing benchmark evidence supports deterministic protocol enforcement over stronger prompting, and a sanitized database incident establishes the target mechanism: failed external operation -> scope drift -> invented destructive recovery. The exact guard is not promoted until paired evaluation demonstrates zero destructive executions, retained safe diagnostics and transactional operations, bounded latency, no secret-bearing denial logs, and no workflow regression.
+There is no in-session bypass. Use a registered cleanup, rollback, reconcile,
+compensate, escalation, or abandonment transition with its declared authority.
diff --git a/boatstack/references/portability.md b/boatstack/references/portability.md
index 1c448b7..a266f73 100644
--- a/boatstack/references/portability.md
+++ b/boatstack/references/portability.md
@@ -1,69 +1,12 @@
-# Harness-neutral portability
+# Portability
-## Canonical package and adapters
+The canonical hosts are CLI, Cursor, Codex, Claude Code, Gemini CLI, and MCP.
+They share one request/response protocol and one prescription AST.
-The source of truth is `.product-loop/`:
+POSIX and Git Bash use the same quoting projection. PowerShell uses its own
+quoting rules over identical argument values. Tests compare semantic arguments,
+not presentation whitespace.
-- `project.json`: repo-specific facts and policy;
-- `workflow.md`: state machine and gate semantics;
-- `artifacts.md`: document contract;
-- `failure-moves.md`: failure taxonomy and experimental rules;
-- `templates/`: artifact templates;
-- `boatstack` and `boatstack.ps1`: tracked, pinned command launchers available in every worktree;
-- `bin/boatstack-helper`: ignored, platform-native deterministic runtime activated internally by the launchers;
-- `generated.lock.json`: generator version, config hash, and generated file list.
-
-Host-specific files are compiled adapters:
-
-- Cursor: `.cursor/rules/boatstack.mdc` and `.cursor/commands/*.md`;
-- Claude Code: `.claude/skills/boatstack/SKILL.md`;
-- Codex: `.agents/skills/boatstack/SKILL.md`;
-- GitHub: `.github/PULL_REQUEST_TEMPLATE/boatstack.md`.
-
-Adapters point to the canonical package; they do not copy its full reasoning. This keeps behavior consistent while letting each host expose its native invocation surface.
-
-## Repository ownership
-
-The exporter must not replace:
-
-- `AGENTS.md`;
-- `CLAUDE.md`;
-- existing Cursor rules or commands;
-- CI or PR templates with the same path;
-- any file without the generated marker.
-
-If a collision exists, stop and show the conflict. A human may move durable content into `.product-loop/project.json`, choose another adapter path, or explicitly reconcile it in a PR.
-
-## Export PR contract
-
-An installation or update PR should show:
-
-- canonical loop version and config hash;
-- host adapters added or changed;
-- project context paths and real verification commands;
-- existing instructions left untouched;
-- collisions or unsupported host features;
-- dry-run/check output;
-- rollout and removal steps.
-
-Generated output is reviewable code. Do not auto-merge it simply because generation succeeded.
-
-The project-local helper is not committed. A fresh clone restores the verified platform binary by re-running the one-command installer; no Python, Node, Go, or package manager is required for Boatstack core.
-
-## Host notes
-
-### Cursor
-
-Use project rules in `.cursor/rules/*.mdc`; `.cursorrules` is legacy. Use `.cursor/commands/*.md` for the named workflow commands. Keep the rule short and point it to `.product-loop/` artifacts. Cursor CLI also reads `AGENTS.md` and `CLAUDE.md`, so avoid duplicating those files into the generated rule.
-
-### Claude Code
-
-Use a project skill under `.claude/skills/`. Keep `CLAUDE.md` as project-owned durable context. If using the Agent SDK in automation, explicitly enable project setting sources when repository instructions are required; do not assume the SDK loads filesystem settings by default.
-
-### Codex
-
-Use a repo skill under `.agents/skills/`. Keep `AGENTS.md` concise for persistent repo conventions and route task-specific workflow detail into the skill and `.product-loop/` references.
-
-### GitHub
-
-The generated PR template collects evidence; branch protection and CI remain the enforcement layer. A future exporter can generate opt-in CI, but it must use commands from `project.json` and never invent repository checks.
+Local replacement is atomic on Unix and Windows. Workspace cleanup runs from a
+neutral directory so Windows never has to delete a process's current directory.
+Release CI compiles the runtime for Linux, macOS, and Windows.
diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md
index d504231..5d0303e 100644
--- a/boatstack/references/workflow.md
+++ b/boatstack/references/workflow.md
@@ -1,548 +1,40 @@
-# Canonical workflow
+# V2 workflow reference
-## State machine
+The executable catalog is the authority. Generate the full inventory with:
-```text
-INTENT
- -> SOURCE_PLAN
- -> PROJECT
- -> QUESTIONS
- -> SPEC
- -> PLAN
- -> PLAN_GATE
- -> PLAN_APPROVED
- -> BUILD_ACTIVATION
- -> PLAN_LOCKED
- -> BUILD
- -> REPAIR (when ordinary conversation reveals a change)
- -> TEST_GATE
- -> REVIEW_GATE
- -> SHIP_GATE
- -> PR_OPEN
- -> PUBLISHED (open, closed, or remotely unverified PR)
- -> CORRECTIVE_CHILD (when CI, review, or ordinary conversation reports a correction)
- -> FEATURE_COMPLETE (only after verified merge)
- -> WORKSPACE_CLEANUP (when workspace management is on and the feature's PR has merged)
- -> RETRO
+```sh
+boatstack catalog --format markdown
```
-Each transition emits an artifact and evidence. A host adapter may change how a command is invoked, but it must not skip a transition or redefine a gate.
-
-After build activation, persistent host adapters route ordinary change language through `REPAIR` before product edits. Same-intent implementation, verification, and review repairs resume at the earliest affected stage and supersede only downstream receipts. Changed or ambiguous intent enters `AMENDMENT_REQUIRED` and cannot pass a gate until a newly approved plan revision is activated. Existing `/test-gate` and `/review-gate` operations remain rerunnable; there are no repair-specific gates.
-
-A published delivery cannot be reset. Its correction uses a deterministic new feature id and declares `parent_delivery` as the published feature, producing a separate plan lock, delivery state, and receipts while preserving the original evidence. If the recorded PR is verified open and still owns the recorded head branch, the child updates that PR. Merged or closed work uses a fresh branch and PR; unknown PR state may be planned but cannot select a publication destination.
-
-`recovery-status` is the read-only resolver for CI failures, review findings, denied publication, and ordinary corrections. It selects by explicit feature, current active branch, current published branch, recorded PR identity, or one unambiguous candidate. It never chooses by recency. A stale reported head SHA, branch mismatch, or multiple match returns a blocker instead of drafting against the wrong delivery.
-
-`repair-state` is the explicit bounded recovery for an unregistered feature draft whose `plan.md` never passed through the helper, so it has no plan lock and no delivery state. Such a draft is inert outside an explicit Boatstack command. When selected, `repair-state` quarantines that directory out of `.product-loop/features/` into `/boatstack/quarantine//` — reversible, never a hard delete — and returns the workflow to `auto-plan`. It resolves the sole malformed candidate when `--feature` is omitted and refuses ambiguity. It acts only on a directory carrying no durable authority: it refuses any feature with a valid saved plan, a plan lock, a `pr.md`, managed delivery state, git-tracked files, or an active or published delivery.
-
-The **transactional mutation boundary** is the standing form of the same principle `repair-state` applies once: a supervisor that removes an actuator must still expose a bounded actuator capable of reaching every valid state, including reversing its own last move. When Boatstack promotes a managed artifact that spans files which must land together, the candidate bytes are submitted as a mutation set that the runtime confirms against per-file base hashes and a supervisor-authority token, writes atomically all-or-nothing, verifies after the write, and rolls back automatically on failure — recording a reversible receipt with per-file before/after hashes. A rejected mutation persists no identity, so a candidate recomputed against the current base and authority applies cleanly: refusal is fail-closed but never a deadlock. Plan activation promotes all five of its managed artifacts — the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, `journey-oracles.json`, and the `plan.lock.json` — through a single mutation, so they land or fail together.
-
-The boundary is **closed under inversion**: every receipt carries its own inverse bytes and *is* the undo command. Undo re-applies that inverse as an ordinary mutation through the same boundary (an explicit absent operation expresses a delete, so the inverse of a create is a first-class mutation), which makes undo atomic and verified, makes the base precondition the conflict guard (undo refuses rather than clobbering later work), and makes **redo just an undo of the undo receipt**. Two bounded verbs expose this and are allowlisted by the guard at any stage (like `repair-state`, still rejecting shell metacharacters and non-helper executables): `mutation-status` (read-only) lists or inspects receipts so an agent can find the one to reverse, and `undo --mutation ` reverses it. `undo` is state-aware — it refuses to reverse a plan activation once a delivery gate receipt exists, so it can never strand delivery state without its lock. This boundary governs Boatstack's own generated managed artifacts only, never coding-agent source-code editing.
-
-The `SOURCE_PLAN` file is required from entry through completion of `BUILD`. After build, its path and hash remain recorded for provenance, but `TEST_GATE`, `REVIEW_GATE`, and `SHIP_GATE` do not require the original file to be present.
-
-## Irreversible-operation boundary
-
-Every installed host carries an inert engagement probe. With no verified active pre-publication delivery in the current worktree and branch, the probe exits silently before runtime loading or hydration and Boatstack applies no policy. During engagement, supported shell and MCP events pass through Boatstack's immutable safety guard. High-confidence database, filesystem, Git-history, cloud-resource, and recovery destruction is then denied before execution. There is no prompt, approval reply, break-glass token, or in-session override while engaged.
-
-After an external-write failure, preserve state and use only read-only diagnosis. Do not escalate privileges, broaden the target, or invent a reset. Use a transactional retry only when retry safety is demonstrated; otherwise stop and fix forward. Destructive recovery is operator-only outside Boatstack. See `irreversible-operation-boundary.md` for the classified operations and evaluation status.
-
-Repository administration is not Boatstack engagement. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes use ordinary repository tools and never enter `auto-plan`, `repair`, or `workspace-sync` unless the user explicitly invokes Boatstack during an active delivery.
-
-During engagement, hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. `run-preflight` reports `HOOK_GUARDED` for this posture and never presents it as credential isolation. Repositories configured for `credential-enforced` mode block before delivery mutation unless a trusted external attestor supplies a current repository-only receipt; only that path reports `CREDENTIAL_ENFORCED`. A missing, drifted, or failing helper denies an engaged operation and requires reinstall or repair. Outside engagement, the probe exits silently. Cursor's exact `MainThreadShellExec not initialized` error occurs before the Boatstack hook starts; reload the Cursor window and retry before diagnosing an engaged installation.
-
-## User-facing response contract
-
-Helper commands and state labels are internal control machinery. Every normal response uses
-the structure below, with a host-compatible rendering for **Technical details**.
-
-### Boatstack banner
-
-Begin every Boatstack response with the status banner, so the reader can tell Boatstack's
-output apart from ordinary prose and see where their work stands at a glance. Emit the exact
-output of `.product-loop/boatstack next-status --repo . --render` verbatim (a fenced code block or as
-plain lines), above the `## ` heading. The banner is presentation only:
-it does not replace the single `### Next step`, does not add a second action, and never
-introduces machine codes or internal stage names (the renderer already hides them). The `--json`
-projection remains the source of truth for decisions and belongs in **Technical details**, not
-the banner. Skip the banner only for replies that are not about a Boatstack operation.
-
-Cursor and Claude Code use a collapsed disclosure:
-
-```markdown
-##
-
-
-
-
-
-### Next step
-
-
-
-
-Technical details
-
-Machine status, helper output, fingerprints, paths, receipts, and locks.
-
-
-```
-
-Codex and any host without verified HTML disclosure support use portable Markdown instead:
-
-```markdown
-##
-
-
-
-
-
-### Next step
-
-
-
-### Technical details
-
-Machine status, helper output, fingerprints, paths, receipts, and locks.
-```
-
-Never emit raw `` or `` tags in Codex. Unknown hosts default to the portable Markdown form; rich disclosure is an explicit host capability, not
-an assumption about generic Markdown support. This presentation difference must
-not change the information, ordering, gate semantics, or one-action boundary.
-
-Lead with a plain outcome, never a machine code such as `PASS`, `PLAN_APPROVED`, `BLOCKED`, `READY_FOR_BUILD`, `PASS_WITH_GAPS`, or `WAITING_FOR_INPUT`. Keep approval-relevant scope, non-goals, decisions, risks, and gaps visible. Move internal operations (`check-plan`, `record-approval`, `activate-plan`), hashes, paths, tables, receipts, locks, and raw output into **Technical details**. **Exactly one primary action:** end with the action that advances or unblocks the current state; a secondary option gets one short sentence. Never route past a blocked state.
-
-### The operator frontier
-
-Every next step belongs to one actor: the operator or the agent. A step belongs to the operator only when it needs operator knowledge or authority — an approval (`a`), a publish decision (`o`/`u`), a cleanup decision (`c`/`k`), a feature choice, a source-plan path, or a correction fact. Every other step belongs to the agent, including steps whose evidence the agent produces by doing the work: build sub-actions, plan checks, test runs, and the review protocol. The helper computes the actor (`next_actor` in `flow next --json`) and marks agent-owned steps in the rendered response with "This step is mine to do."
-
-End a working response only at the operator frontier: the final `### Next step` must belong to the operator, or state that no action is required. Never end a working response by describing work the agent still has to do — do the work, re-render `next-status --repo . --format response`, and continue until the next step belongs to the operator. Presenting the agent's own pending work as the operator's next step is a contract violation.
-
-The read-only `next` status query is the one exception, because a status question must not mutate anything. When the rendered step is marked as the agent's, the one next action is the delegation reply: reply `g`, and the agent executes the marked step and continues to the operator frontier under the same bounds as the foreground run coordinator. Stop immediately when executing a step does not change the prescribed next step — repetition without progress is a stall, never a loop. Report the block plainly and hand the turn to the operator.
-
-**Write in Simplified Technical English.** Use short sentences, the active voice, and the present tense. State one idea per sentence, put the condition first, and choose the simple, common word. Keep a term consistent, and write positively. This applies to every operation, including the review findings and the PR brief. It does not change the fixed outcome labels, the single `### Next step`, the collapsed **Technical details**, or the reply keys.
+Event families:
-| State | Outcome -> one next action |
-|---|---|
-| `next`, `/boatstack-next`, `$boatstack next` not started / active / complete / ambiguous | **Start a Boatstack feature** -> save a Plan-mode file or run `auto-plan`; **Next Boatstack stage** -> run the one repository-backed operation (when the rendered step is marked as the agent's, reply `g` to have the agent do it and continue to the operator frontier); **Feature complete** -> no action required; **Boatstack state needs attention** -> resolve the named ambiguity (address the invalid evidence, or, when the block names only past deliveries, ignore a named past delivery after explicit user confirmation) |
-| `run`, `/boatstack-run`, `$boatstack run` not started / complete / paused / blocked | **Start a Boatstack feature** -> save a Plan-mode file; **Feature ready for review** -> review the published PRs; **Boatstack run paused** -> provide the one required approval, confirmation, or product answer; **Boatstack run needs attention** -> resolve the named freshness, safety, state, or repair blocker |
-| `insight-capture` | **Insight ready to save** -> reply `s` to save the exact fingerprint-bound Value Map preview as a tracked `docs/insights//` repository diff; **Insight saved as a repository diff** -> review or publish that intake artifact separately; no delivery is created |
-| `insight-frontier` | **Insight frontier ready** -> review the independent captures needing classification, delivery, evidence, or human completion; this read-only view never replaces the delivery frontier |
-| `root-cause`, `/root-cause`, `$boatstack root-cause` | **Root cause found** -> save the diagnosis as a source plan and run `auto-plan` with it via `--plan`; the operation is read-only and never edits code or advances a gate |
-| `auto-plan` ready / needs answers | **Plan ready** -> run `/plan-gate`; **I need your input** -> answer with the displayed choice keys or `r` for all recommendations |
-| `plan-gate` pending / approved | **Ready for your approval** -> reply `a` to approve; **Approved — ready to build** -> enter execution mode and run `/build` |
-| `build` success / paused | **Build complete** -> run `/test-gate`; **Build needs a decision** -> answer the blocking question |
-| `repair`, `/repair`, `$boatstack repair` not started / pre-build / same intent / amendment | **No active delivery to repair** -> run `auto-plan` or the verified pre-build gate; **Repair recorded** -> perform the reported resume stage; **Plan amendment required** -> review the proposed intent delta |
-| `test-gate` pass / blocked | **Tests passed** -> run `/review-gate`; **Testing found a problem** -> perform or authorize the repair |
-| `review-gate` pass / blocked | **Review passed** -> run `/ship-gate`; **Changes required** -> address the blocking finding |
-| `ship-gate` preview / published | **PR ready** -> reply `o` to open or `u` to update the previewed PR; **PR opened** -> review the PR; never imply merge authorization |
-| `boatstack-update` current / postponed / prepared / published / blocked | **Boatstack is current** -> no action required; **Update postponed** -> finish feature work and rerun from the clean default branch; **Boatstack update ready** -> reply `o` to open the update PR; **Update PR opened** -> review the PR; **Update needs attention** -> address the one reported collision or health failure |
-| `retro` | **Improvement proposed** -> review or authorize the experiment |
-| `workspace-cut` (surfaced after plan validation and before approval or autonomy) | **Fresh workspace ready** -> continue every later command from the returned destination; **Workspace already current** -> continue there |
-| `workspace-cleanup` (surfaced by `boatstack-next` after publication) | **Workspace ready to clean up** -> reply `c` to remove the worktree and branch, or `k` to keep; **Workspace kept** -> no action required; **Workspace still open** -> the PR is not merged yet, keep waiting or override explicitly |
+- invocation and engagement;
+- installation, runtime, and configuration;
+- goal and plan;
+- workspace;
+- delivery gates and evidence;
+- publication;
+- recovery;
+- observed external plant changes.
-### Foreground run coordinator
+The supervisor returns one of `PRESCRIBED`, `TERMINAL`, `FRONTIER`,
+`BLOCKED`, `REFUSED`, or `UNRESOLVED`. Only `PRESCRIBED` can produce an
+admission. Only an independently verified postcondition can produce a receipt.
-`run` is an opt-in foreground coordinator over the existing operations, not a second state machine. It accepts `--to plan|verified|pr`; when the request names no target, the host asks once. The target is recorded in a fingerprinted `autonomy.md` receipt bound to the plan, repository, branch, eligible policy decisions, and, for `pr`, one open or update action. `plan` stops at a valid reviewable plan. `verified` uses policy activation and stops after test and review gates. `pr` continues through exact preview validation and one normal publication without a second confirmation. Receipt drift fails closed. Human-driven runs without an autonomy receipt retain the existing approval and publication confirmations. The coordinator never merges, rebases, switches or creates constrained branches, discards changes, force-pushes, merges a PR, or deploys.
+A normal verified-delivery path is:
-After preflight, resolve the repository-backed next operation, execute exactly that canonical operation, verify the resulting state, and resolve again through all declared delivery slices. When the resolved block names only past deliveries, the coordinator may offer to ignore a named past delivery (adding its slug to `workflow.ignored_deliveries`) only after explicit user confirmation; any new, unlisted ambiguous delivery still pauses. Pause for `a`, a material product answer, and `o` or `u`; after the valid state-scoped reply, continue in the current host session. The invocation does not replace either human authorization. Automatically record and repair same-intent test or review failures for at most three complete repair-and-gate cycles per active slice per invocation. A requirement amendment pauses product edits, makes the composite amendment lifecycle authoritative over the slice status, and routes through the owned plan-write, approval, and activation transitions before delivery resumes. Stop immediately for ambiguous or stale state, unsafe capability, unsupported recovery, branch mismatch, or exhausted repairs. Store no durable run/autopilot mode; re-invocation reconstructs progress from canonical repository state.
-
-### Reply shortcuts
-
-The exact reply `s` saves only the currently displayed insight preview. It must match the source, Product Value Map, topics, nonce, and preview fingerprint checked in the same host state. It does not approve a plan, bind a delivery, complete an insight, or grant PR authority.
-
-Finite input uses one global, state-scoped reply grammar:
-
-| Reply | Valid pending state | Meaning | Compatible full reply |
-|---|---|---|---|
-| `a` | Reviewed plan awaiting approval | Approve the exact plan fingerprint | `approve` |
-| `o` | New feature, ad-hoc, or Boatstack-update PR preview | Open the exact previewed PR | `open PR` or `open update PR` |
-| `u` | Existing PR preview | Update the exact previewed PR | `update PR` |
-| `r` | One or more finite questions with exactly one marked recommendation each | Accept every recommendation displayed in that response | Explicitly name the recommended choices |
-| `c` | Published feature whose merged workspace can be reclaimed | Clean up the feature's worktree and branch | `clean up` |
-| `k` | Published feature whose workspace can be reclaimed | Keep the workspace for now | `keep` |
-
-Trim surrounding whitespace and match shortcuts case-insensitively against the complete reply. Bracketed forms such as `[o]`, embedded letters, and shortcuts from another state are ordinary text. Continue accepting the full replies for compatibility, but do not advertise them in user-facing responses.
-
-Before `c` removes a workspace, confirm the merge and safety gates in the `WORKSPACE_CLEANUP` contract. `c` never discards uncommitted or unmerged work and never deletes remote branches or merges anything; it only reclaims the local worktree and branch of an already-published feature.
-
-Shortcuts never bypass gate prerequisites. Before `o` or `u` mutates GitHub, recheck the preview fingerprint, committed diff, evidence, authentication, and any required manual commit or push. Never interpret `r` as plan approval, PR publication, identity, secret input, permission escalation, policy bypass, destructive recovery authorization, or another exceptional safety decision. Free-text and operation-command prompts remain explicit.
-
-For each finite product question, show 2-3 mutually exclusive choices with compact inline-code keys and exactly one label suffixed `(Recommended)`. With one question, use `1a`, `1b`, and `1c`; with multiple questions, continue with `2a`, `2b`, and so on. End with one reply hint using the keys and `r`. A standalone `r` is valid only when every displayed question has exactly one recommendation; echo the question-to-answer mapping before recording each answer as `ANSWERED` with explicit human provenance. Otherwise ask again without choosing.
-
-For plan approval, resolve `approved_by` from (1) an identity supplied with approval, (2) the authenticated GitHub login from `gh api user --jq .login` when available, or (3) one short identity follow-up. Never invent a placeholder name (e.g., Sam, Eve). Never infer the approver from a filesystem username, commit history, or the coding agent. If identity is missing after approval, preserve the current fingerprint and approval intent, create no receipt, and ask only for identity; once resolved against the unchanged plan, do not require approval again. Keep identity and receipt data inside **Technical details**.
-
-## State contracts
-
-### `INTENT -> SOURCE_PLAN`
-
-Begin in the active coding host's Plan mode. Explore the ordinary product intent without editing implementation files, then save that host-generated plan as a durable file. Invoke `auto-plan` with the plan's path.
-
-For bug-shaped intent (a crash, stack trace, or failing signal), the read-only `root-cause` operation is the optional on-ramp to this state: it classifies the failure against [failure-moves.md](failure-moves.md), produces a cited root-cause chain, and proposes the structural change that eliminates the failure class rather than patching the instance, formatted as the source plan you then save and pass to `auto-plan --plan`. It never edits code, writes artifacts, or advances a gate.
-
-Before repository inspection, run:
-
-```bash
-.product-loop/boatstack check-source-plan --repo . --plan
+```text
+installation.initialize
+goal.configure
+engagement.begin
+plan.create -> plan.validate -> plan.approve -> plan.activate
+gate.build.record -> gate.test.record -> gate.review.record
+TERMINAL(verified-implementation)
```
-Boatstack never scans directories for plans, so `--plan` is required and no unshipped saved plan becomes ambient context. If no plan path is supplied, or the file is missing, empty, or unreadable, `auto-plan` is `BLOCKED` and must request the plan to build. It must not manufacture the missing input. Because the file's hash is recorded and re-checked through `BUILD`, `--plan` must point at a durable in-repo path that stays present and unchanged; a path outside the repository is rejected. This source plan is an initial proposal rather than human approval.
-
-### `SOURCE_PLAN -> PROJECT`
-
-Define the request as:
-
-- domain;
-- affected actor;
-- input and output;
-- user-visible outcome;
-- next operator;
-- verification boundary.
-
-Reject a scope definition that combines unrelated domains or cannot name an observable outcome.
-
-### `PROJECT -> QUESTIONS`
-
-Inspect the minimal code paths and durable project context. Classify every uncertainty:
-
-- **discoverable fact:** answer through repository or runtime inspection;
-- **product decision:** ask the developer or stakeholder;
-- **technical decision:** propose options and record the accepted rationale;
-- **deferrable gap:** record it with impact and trigger;
-- **irrelevant:** exclude it from the slice.
-
-Questions are required when different answers change an external contract, data model, safety boundary, user experience, acceptance criterion, or irreversible implementation choice.
-
-### `QUESTIONS -> SPEC`
-
-The spec must contain:
-
-- problem and target user;
-- desired outcome and metrics;
-- non-goals;
-- user stories or scenarios;
-- acceptance criteria;
-- current and proposed interfaces;
-- invariants and trust boundaries;
-- failure, empty, loading, and recovery behavior;
-- observability;
-- migration, rollout, and rollback;
-- linked questions, ADRs, and gaps.
-
-Do not encode guessed answers as facts. Mark a reversible assumption and give it an expiry trigger.
-
-### `SPEC -> PLAN`
-
-Create tasks in dependency order. Each task names:
-
-- files or components likely affected;
-- contract or acceptance criteria served;
-- validation procedure, its origin, its oracle, and its independence;
-- rollback boundary;
-- unknowns that would stop implementation.
-
-Tasks describe implementation, never publication authority. Internal phases remain
-tasks inside one delivery slice. If the accepted product change intentionally needs
-multiple PRs, `plan.md` declares ordered `delivery_slices`. Every task belongs to
-exactly one slice; dependencies may point within the slice or to an earlier slice,
-never forward. Optional base/head branch names are constraints, not permission to
-create or push those branches. Approval accepts the delivery structure but does not
-authorize any PR mutation.
-
-When `workflow.maintain_changelog` is enabled, every delivery slice includes
-`CHANGELOG.md` in its affected paths. This is product-owned reader documentation,
-not a generated Boatstack artifact.
-
-An external-write task also names `affected_paths` and a compact `side_effects` record: operation kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Ambiguous targets such as “local database” and rollback text such as “reset local DB” block approval. Ordinary tasks do not need side-effect ceremony.
-
-Run only relevant review lenses:
-
-- product/taste: value, scope, user journey, non-goals;
-- design: states, accessibility, responsive behavior, content;
-- engineering: boundaries, data flow, state, failure modes, security, migrations;
-- developer experience: APIs, naming, discoverability, operability.
-
-If gstack is installed, its review skills can execute these lenses. If Spec Kit is installed, it can generate and cross-check the spec, plan, tasks, and checklists. Their output is normalized into this artifact contract.
-
-`plan.md` is the canonical structured plan. Its human-readable prose and one marked JSON block are a single approval surface. Until `BUILD_ACTIVATION`, feature artifacts are Markdown only; no compiled task graph, machine lock, or executable state exists.
-
-### Literal planning transport
-
-Feature artifacts are authored only from the read-only, mode-aware `flow bootstrap` oracle. Supply the selected feature, durable in-repo source-plan path, artifact name, target shell, and complete Markdown through the current Boatstack operation entrypoint. For an active amendment, the oracle also binds the current lifecycle fingerprint, observation, and prior plan lock. The writer rejects any drift before mutation. The oracle verifies the worktree and source-plan digest and returns a `planning_envelope` bound to the exact embedded launcher or detached helper. Execute that envelope unchanged.
-
-The oracle emits a non-colliding single-quoted heredoc for Bash, zsh, and Git Bash. For Windows PowerShell it emits a UTF-8-scoped single-quoted here-string. A document containing a PowerShell closing marker must use `--shell posix` with Git Bash. Do not select, append, or rewrite an executable path yourself.
-
-Send the complete Markdown to `flow bootstrap` in one literal stdin envelope, then send its returned `planning_envelope` in one shell-tool call. Do not run a bare `planning-write`, construct a helper path from this document, split either envelope across calls, prepend or append another command, or paste Markdown at an interactive prompt. After `workspace-cut`, discard every earlier prescription and resolve again from `destination_repository`. `PLANNING_TRANSPORT_INVALID` means nothing ran; obtain a fresh prescription instead of replaying the body manually. The host's own Markdown writer is permitted only where the host allows it; arbitrary redirection to a feature path never is.
-
-Validation must be derived before implementation. Each check records:
-
-- `run`: an executable command or a specific human/external procedure;
-- `criteria`: only the acceptance claims this procedure can actually support;
-- `origin`: the acceptance criterion, repository invariant, human decision, risk, or external contract that requires it;
-- `oracle`: the fixture, schema, threshold, rubric, external fact, or authorized judgment capable of falsifying the claim;
-- `independence`: whether the oracle is pre-existing, contract-derived, external, human, or implementation-authored.
-
-Subjective work is not exempt from validation. Convert ambiguity into an approved reference, rubric, scenario, threshold, and evidence owner. If materially different interpretations remain or no defensible oracle exists, keep the plan `BLOCKED` at `PLAN_GATE`.
-
-When `workflow.pr_visual_evidence` is `suggest` or `require`, every managed plan also records a `pr_visual_evidence` decision. A relevant decision defines one to three scenarios with an entry surface, required state, viewport, and expected visible outcomes. A not-relevant decision records its reason. Planning may discover repository-owned visual tooling but must not require Storybook, Playwright, or another framework-specific dependency.
-
-### `PLAN -> PLAN_GATE`
-
-Run `.product-loop/boatstack check-plan --plan /plan.md` and present the full draft, plan fingerprint, and product baseline returned by the check. A non-empty baseline includes its exact diff, changed paths, and SHA-256 so edits that existed when managed planning began remain visible and preserved. When `workflow.human_plan_approval` is `true`, require an exact standalone `a`, the compatible full reply `approve`, or a change request, and end the pending response with: Reply `a` to approve. When it is `false`, report that Build will create a policy-activation lock and do not create or imply human approval. The check is read-only.
-
-### `PLAN_GATE -> PLAN_APPROVED`
-
-When human approval is enabled, invoke `record-approval` with the named human, RFC3339 timestamp, exact plan fingerprint, and displayed baseline-diff fingerprint (omitted only for a clean baseline); it recomputes both and creates only `approval.md`. When disabled, skip that operation and preserve the checked plan and baseline for policy activation. Remain in the host's Plan mode; do not compile machine artifacts or edit product code.
-
-Ask 1-3 finite questions using the global keyed-choice format whether the host renders them through a structured question tool or plain text, then return `WAITING_FOR_INPUT`. Never convert an unavailable question UI into permission to choose a default. A standalone `r` is an explicit human acceptance of all recommendations displayed in that response, not an agent-selected default. Authoritative repository facts are `DISCOVERED`; agent suggestions and repository-derived product choices are `PROPOSED`; only explicit human responses are `ANSWERED`. Every material proposal remains in `blocking_questions` until answered.
-
-### `PLAN_APPROVED -> BUILD_ACTIVATION -> PLAN_LOCKED`
-
-At the host's normal Build transition, first confirm the host is in an execution-capable mode. If the transition is rejected or product-code writes remain unavailable, return `READY_FOR_BUILD` without compiling or writing a lock. Once execution is available and before the first product-code edit, `activate-plan` deterministically:
-
-1. parse and validate the marked structured block in `plan.md`;
-2. hash the complete source plan, spec, `plan.md`, and pre-activation product baseline, matching them to `approval.md` when human approval is enabled;
-3. compile the task graph, requirement-test traceability rows, and evidence skeleton without adding semantics;
-4. record authorization mode, timestamp, source commit, artifact hashes (the compiled task-graph hash bound from the in-memory candidate), readiness fingerprint, and baseline diff/path provenance in plan-lock schema v3, plus approver provenance only for human authorization;
-5. promote all five artifacts (`compiled/tasks.json`, `compiled/test-matrix.json`, `compiled/evidence.md`, `compiled/journey-oracles.json`, and `plan.lock.json`) through the transactional mutation boundary as one mutation, whose post-write check re-validates the compiled JSON, asserts the evidence ledger is non-empty, and rechecks the promoted lock before permitting implementation — so all five land all-or-nothing and the reversible receipt can undo the whole activation.
-
-Activation also initializes ignored, worktree-local Git delivery state bound to the lock.
-One implicit `delivery` slice preserves the ordinary one-feature/one-PR flow. An
-explicit multi-slice plan starts only its first slice in `BUILD`; later slices remain
-`PENDING`.
-
-Because delivery state is keyed to the lock hash, re-activating an amended plan (a
-widened tail slice, a new phase, any edit that changes the lock) **reconciles** rather
-than resets: the already-published prefix is preserved verbatim — its status, PR, and
-branch bookkeeping intact — the active pointer holds, and only the recomputable tail is
-re-derived (the active slice restarts at `BUILD`, the rest `PENDING`), recording the
-superseded lock. An amendment that would drop, reorder, rename, or change an
-already-published slice, or any edit to a fully-published (immutable) delivery, is
-refused before the transactional promote — nothing half-applies — and routes to a
-corrective child delivery (see "A published delivery cannot be reset"). Published status
-is read from the pointer and slice status, never from `pr_state`.
-
-Missing required human approval, unresolved `blocking_questions`, or any change to the source plan, spec, complete `plan.md`, or displayed product baseline blocks activation and returns the feature to `PLAN_GATE`. Existing schema-v1 approval receipts remain valid only with a clean product baseline. A failed or partial compilation never creates a valid lock. Existing schema-v1 human locks remain valid; policy activation always writes schema v2.
-
-After `auto-plan` successfully saves a feature plan, managed authority is latched before activation. Reads and bounded Markdown planning transitions remain available — `planning-write` is the channel that stays open for authoring planning Markdown while the latch holds — but native edits, mutation-capable MCP tools, and shell commands not proven read-only are denied until activation creates a current lock. Approval itself does not authorize product edits. Ambiguous, stale, malformed, or unverifiable phase state fails closed with one recovery operation; repositories with no saved managed plan retain ordinary unmanaged behavior.
-
-### `PLAN_LOCKED -> BUILD`
-
-Read the active delivery state and implement only that slice's `task_ids`. Within it,
-implement one coherent task slice at a time. After each task slice:
-
-1. run the cheapest relevant check;
-2. compare the diff to the task contract;
-3. preserve the known-good state;
-4. record deviations or new unknowns;
-5. continue, ask, or re-plan explicitly.
-
-Commits are allowed during build. Direct `git push`, `gh pr create/edit/ready/merge`,
-and equivalent GitHub mutations are not implementation tactics: the host hook denies
-them while managed delivery is active. Do not route a managed branch through the
-ad-hoc PR path.
-
-Scan operational changes and configured `high_risk_paths` before activation and after relevant edits. A dangerous capability may remain visible as source for review, but it cannot execute and blocks progression until removed or isolated behind the operator boundary.
-
-When `workflow.maintain_changelog` is enabled, update `CHANGELOG.md` before
-recording test evidence. Add a concise bullet under the current Unreleased heading and one of
-`Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Documentation`, or
-`Maintenance`. Describe the actual reader-visible outcome, not the commit, PR,
-Boatstack artifacts, or test commands. Add only the category needed by the entry;
-do not add empty category headings. If the file does not exist, create the
-documented minimal skeleton with `## [Unreleased] - YYYY-MM-DD` and its first
-entry. If it exists, add to the current file without rewriting its released
-history or existing layout.
-
-### `BUILD -> TEST_GATE`
-
-Crossing this boundary ends the requirement to keep loading or checking the source Plan-mode file. Its recorded path and hash preserve provenance. Subsequent gates judge the approved intent against the actual diff and evidence.
-
-Create requirement-to-evidence traceability. Use this evidence ladder:
-
-1. syntax, schema, and load/collect checks;
-2. unit and contract tests;
-3. integration and end-to-end tests;
-4. differential, property, or mutation checks where useful;
-5. staging/runtime verification;
-6. human acceptance for product behavior.
-
-The riskier the slice, the less acceptable same-model, self-authored tests are as the only oracle.
-
-For relevant visual scenarios, resolve capture capability in this order: repository-owned visual tooling, a host browser against the existing development server, one human-supplied launch instruction, then explicit machine-local runtime setup. Capture must not edit source, dependency manifests, lockfiles, or test configuration. Bind each PNG to the current commit, product diff, scenario, viewport, SHA-256, and a `clean` or `human-reviewed` privacy receipt. `suggest` records unavailable capture as a visible gap; `require` retains a ship blocker.
-
-External-write evidence must establish immutable target identity, transactional or fix-forward behavior, and an independent safety oracle. A dry run that only prints the intended command does not prove the live target or failure behavior.
-
-Before passing the gate, commit the intentional active-slice product and evidence diff
-and invoke the deterministic delivery-gate recorder for `test`. It captures the slice,
-base/head branches, HEAD, product-diff hash, and evidence hash. A `PASS` string edited
-into Markdown is evidence content, not a state transition.
-
-### `TEST_GATE -> REVIEW_GATE`
-
-Review only after required mechanical checks pass, unless reviewing a failure is the goal. The reviewer inspects the actual diff and reports findings by severity with file/line evidence, consequence, and correction. Write each finding in Simplified Technical English.
-
-On pass, invoke the same recorder for `review`. It accepts only the active slice and
-only when the test receipt matches the current diff. Any product or evidence change
-afterward makes the receipts stale and routes back through test and review.
-
-With changelog maintenance enabled, the review recorder also compares the merge-base
-and current `CHANGELOG.md`. It requires a new categorized `Unreleased` bullet and the
-reviewer checks that its wording is supported by the actual diff.
-
-### `REVIEW_GATE -> SHIP_GATE`
-
-Require:
-
-- all critical findings resolved;
-- acceptance criteria traced to evidence;
-- required commands passed;
-- docs and durable decisions updated;
-- gaps explicit;
-- deployment and rollback understood;
-- secrets and unintended artifacts excluded.
-
-### `SHIP_GATE -> PR_OPEN`
-
-Project the approved feature and actual committed diff into a reviewer-ready title and body. Write the brief in Simplified Technical English:
-
-- why the change exists;
-- what changed, grouped by reviewer concern;
-- the shortest useful review order;
-- decisions that materially shaped the diff;
-- acceptance and check evidence with source references;
-- known gaps, risks, rollout, and rollback;
-- collapsed approval, evidence, and coding-host provenance.
-
-Store the exact preview at `.product-loop/features//pr.md`. Its non-rendered frontmatter records the title, base/head branches, managed feature, and context fingerprint; the remaining Markdown is the exact GitHub body. The preview artifact itself is excluded from the product-diff fingerprint so committing it does not create a self-referential hash.
-
-PR schema v4 always records `pr_visual_evidence_policy`, `pr_visual_evidence_status`, `pr_visual_evidence_count`, `pr_visual_evidence_fingerprint`, `pr_visual_privacy_status`, and `pr_visual_privacy_receipt_fingerprint`. Relevant or unresolved PRs contain a structured **Visual evidence** section. Show the exact local images and external-host privacy warning before confirmation. Automated `clean` capture requires a separate human receipt from `review-pr-visual-evidence` that binds the exact manifest fingerprint and PNG hashes; changed pixels invalidate it. The state-scoped `o` or `u` authorizes the fingerprinted PR package: title, body, and one Boatstack-owned visual-evidence comment. After human privacy review, Boatstack uploads to the configured external host (Litterbox for 72 hours by default), verifies every returned URL, and writes only hosted Markdown image links. If upload, URL verification, or comment mutation fails, preserve the PR and comment identity, record `visual_pending`, and retry the same fingerprint. Never attach PNG files directly or commit them to a branch. Under `require`, do not mark managed delivery published until the hosted comment is observed.
-
-Before publication, show the exact title and rendered body. Use **PR ready** and exactly one action. When no PR exists, render: Reply `o` to open PR. When one exists, render: Reply `u` to update PR. Only the corresponding state-scoped shortcut or compatible full reply authorizes opening or updating the PR. After confirmation, commit only the reviewed `pr.md`, recheck the same preview fingerprint, committed product diff, plan approval, build lock, test evidence, and review evidence, then let the deterministic publisher perform a normal push and the selected GitHub action. It records the fingerprinted package before execution. A lost response enters reconciliation against the exact remote branch and PR; it never opens another PR blindly. Any package drift blocks publication and requires a new preview; never force-push.
-
-For managed work, publication also requires current test and review receipts for the
-active delivery slice. Successful publication marks only that slice `PUBLISHED` and
-activates the next slice as `BUILD`. No parent-plan approval, prior phase receipt, or
-context summary can skip these transitions.
-
-Advancing the `BUILD` pointer does not revoke correctability of the slice just
-published. While its PR is not terminal (not merged or closed), a `PUBLISHED` slice
-**remains re-gateable and updatable in place**: `record-delivery-gate --slice `
-and `pr-context --slice ` redirect to that slice, and `publish-pr --action update`
-re-targets its still-open PR without advancing the pointer a second time. Correctability
-ends only when the PR reaches a terminal state, observed by the recovery/next resolver
-and cached on the slice; from there correction routes to a corrective child delivery
-(see "A published delivery cannot be reset"). This keeps multi-slice deliveries flowing
-while never stranding a slice whose postcondition has not yet been observed.
-
-Opening or updating a PR does not authorize merge or deployment.
-
-After successful publication only, the publisher may use the ignored 24-hour release cache to report an available stable Boatstack version. The primary response and next action remain **PR opened -> Review the PR**. Put the maintenance notice in collapsed details, state that no files changed, and direct the user to run `/boatstack-update` from the clean default branch after the feature PR merges. Suppress repeated notices for seven days unless a different release appears. Release lookup failure never changes the ship result.
-
-## Boatstack updates
-
-`boatstack-update` is an infrastructure operation, not part of a feature plan. It first forces release discovery and inspects the current installation. If the repository is not on its current default branch, or contains changes outside verified Boatstack-owned repair paths, it changes nothing and returns **Update postponed**.
-
-For an available version, create `chore/update-boatstack-v` and download and checksum-verify the target helper before consulting the installed runtime. The target helper classifies hook fragments, generated locks, helper provenance, and marker-bounded interceptors. Exact installed state migrates automatically. Recoverable owned drift is fingerprinted and, interactively, offered as **Repair Boatstack-owned state and continue the update? [y/N]**; noninteractive updates stop with one `--repair` retry. Repair backs up the exact paths in Git-common state and remains in the same update PR. User-owned, mixed, malformed, symlinked, or product state stays blocked. Downgrades require both `--repair` and `--allow-downgrade`.
-
-If the ignored local install lock is missing, malformed, or carries a development identity, the verified target helper derives the prior stable version only from `HEAD:.product-loop/generated.lock.json`. That committed pin makes the local provenance path repairable without trusting drifted worktree bytes. Every mutable controller target is paired with its owning storage boundary: embedded worktree state uses the worktree Git directory, embedded shared state uses the Git common directory, and detached state uses the external Boatstack control root. Effectful callers validate the paired boundary and never reconstruct it from the repository path.
-
-The detached helper and its generated feature paths necessarily live inside that protected external root. Host hooks admit those paths only when a literal command uses the exact regular, non-symlink helper bound by the current repository's verified workspace context, every controller operand stays inside the same root, and the requested transition belongs to the resolved workflow position. Read-only helper observations remain read-only. A sibling helper, mixed controller roots, raw file operation, malformed command, or stage-invalid transition remains denied without changing controller state.
-
-A terminal update receipt is consumed only while its target postcondition still holds. Before returning success for a prior `install-update`, Boatstack checks the target generated bundle, host hooks, execution interceptors, committed runtime pin, shared and local runtime identity, and preserved integrations. If an operator restored the old committed pin or otherwise removed that local atomic result, Boatstack records `POSTCONDITION_MISSING`, reopens only that `ATOMIC_LOCAL` update, and performs a fresh bounded attempt. PR publication and other external operations retain terminal replay suppression and are never reopened by this rule.
-
-`update -binary ` installs the passed binary's **own self-reported version**, not the running helper's. Because each helper embeds its own version-bound generated bundle and compile-time constants, an older helper cannot correctly install a newer one in-process; when the passed binary self-reports a different identity, the whole update is re-executed by that binary so it installs itself — its bundle, constants, version-keyed shared-runtime slot, and durable receipt are then authoritative by construction, and the hand-off terminates in a single hop. The write boundary refuses to install a `-binary` whose self-report disagrees with the process running it, and re-hashes the freshly written slot against its manifest, rolling back on mismatch — so a runtime can never be labeled one version while carrying another's bytes.
-
-The runtime bytes never travel through Git. A dormant engagement probe never loads or hydrates them. When a verified active lease exists, the guard may **auto-hydrate** an absent slot using the tag-pinned, checksum-verified installer in branch-free `hydrate` mode, serialized clone-wide and bounded by a timeout. The existing missing, symlink, manifest, and checksum gates remain fail-closed during engagement.
-
-Before a durable update attempt is created, Boatstack verifies the dedicated branch, base commit, repair classification, and current diff. Invalid workspace state consumes no retry budget. The update transaction then reuses one semantic ownership projection for admission, mutation, final verification, staging, and preview. Generated files must match their prepared bytes, host-hook files must preserve their non-Boatstack JSON, and `.cursorrules`, `CLAUDE.md`, and `GEMINI.md` must preserve everything outside their single Boatstack marker boundary.
-
-The update transaction is a durable atomic-local operation. It preserves repository configuration, adapters, integrations, and unrelated host settings, then runs `doctor`. After installation, `prepare-update-pr` verifies that every changed path is Boatstack-owned and atomically stores the exact non-empty publication package in Git-common runtime state. Show release and repair provenance, the exact generated diff, checksums, changed paths, integration state, rollout, and rollback.
-
-Use **Boatstack update ready** and exactly one action: Reply `o` to open update PR. Only the state-scoped `o` or compatible full reply authorizes `publish-update-pr` with that preview fingerprint. The publisher stages only the approved paths, reuses or creates the exact update commit, pushes normally, and reconciles the head branch before opening at most one PR. The PR body records release provenance, changed generated files, verification, rollout, and revert instructions. If a response is lost after GitHub accepted the request, the next invocation observes and returns the existing PR. If publication is unavailable, retain the prepared branch and provide one manual action. Never merge automatically.
-
-## Durable operation boundary
-
-During an active managed delivery, every mutation-capable host call receives a
-single-use lease bound to its tool, target, argument fingerprint, plan authority,
-and persistent attempt number. Post-tool events complete that attempt. Identical
-active work reports **wait**; an already successful fingerprint is not relaunched.
-Unknown completion reports **reconcile** and checks the expected Git, GitHub,
-filesystem, browser, or MCP postcondition before any retry.
-
-`operation-status --repo . --json` is read-only. An omitted operation ID resolves
-only when at most one unfinished operation matches the current branch; ambiguity
-is explicit and never resolved by recency. Receipts are ignored Git-common state
-shared by linked worktrees. They store hashes and bounded facts, not commands,
-secrets, user content, or autonomous execution intent.
-
-`boatstack-run` consults this state before advancing. Its three-cycle repair
-budgets are the delivery state's durable schema-v2 counters for implementation,
-verification, and review failures, not a counter reset by a new conversation,
-process, host, or async notification. Every repair records its mechanism. An
-identical class, evidence, and mechanism retry is denied; requirement amendments
-and readiness recovery consume no counter.
-
-## Existing and ad-hoc PRs
-
-There is no public `/pr-brief` operation. When the user asks in natural language for Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package:
-
-1. project the committed branch diff, commits, observed checks, and minimal relevant repository context;
-2. store the exact preview at `.product-loop/pr-briefs//pr.md`;
-3. use the same reviewer-first format, but mark unavailable approval and gate evidence `NOT_VERIFIED`;
-4. never claim that Boatstack approved the work or that an unrun gate passed;
-5. when `workflow.maintain_changelog` is enabled, require a new categorized
- `CHANGELOG.md` entry under `## Unreleased`;
-6. preview first, then require `o` to open or `u` to update the PR and recheck the diff before publication.
-
-Adaptive sections for security/privacy, migrations, UI evidence, or operations appear only when relevant. Model attribution belongs inside collapsed provenance. If GitHub CLI authentication is unavailable, keep the validated preview and provide one manual publication action instead of losing the work.
-
-### `DRAFT_PLAN -> WORKSPACE_CUT -> APPROVAL OR AUTONOMY`
-
-When `workspace.enabled` is set and a draft plan passes validation, `boatstack-next` routes to `workspace-cut` before human approval or autonomy is recorded. The operation fetches `origin` and selects the feature branch from that exact base. It creates a branch or worktree, adopts an unowned exact-base branch, or reuses a matching current worktree. It returns the destination repository, branch, base commit, plan fingerprint, controller mode, and outcome. The host continues every later command from that destination, so approval, autonomy, readiness, and activation bind the final feature branch.
-
-In embedded mode, the complete planning package moves transactionally. Boatstack verifies the destination fingerprint before removing the source. In detached mode, the destination registers against the same repository controller identity. A divergent, dirty, owned, or conflicting destination fails closed before plan authority moves. A failed copy, registration, cleanup, or postcondition check restores the source package and removes partial branch, worktree, and controller authority. Workspace-disabled repositories keep their manual branch policy.
-
-### `PR_OPEN -> WORKSPACE_CLEANUP`
-
-When `workspace.enabled` is set, `boatstack-next` surfaces `workspace-cleanup` for a published feature whose managed worktree still exists locally. The `workspace-cleanup` operation checks the pull request's merge state (GitHub CLI, falling back to local ancestry) and reports it. When `workspace.cleanup_after` is `merge`, cleanup is offered only once the PR is confirmed merged; while it is still open, the workspace is kept and the human may keep waiting or override explicitly. Cleanup never removes a workspace with uncommitted or unmerged work without an explicit forced override, and it reclaims only the local worktree and branch — it never deletes a remote branch or merges anything. In `confirm` mode the human reclaims the workspace with the exact reply `c` (or keeps it with `k`); `auto` mode reclaims a merged workspace without a prompt; `off` disables cleanup. A fresh feature workspace is likewise cut from the up-to-date default branch when a new feature begins, so work never starts on a stale branch.
-
-### `PR_OPEN -> MERGED` (only when `delivery.terminal` is `merged`)
-
-With the default `published` terminal, the flow ends at an open PR, exactly as before. With `delivery.terminal: merged`, the read-only advisors keep prescribing until the PR is observed merged, from the live PR observation (never from anyone's claim):
-
-- Checks running: the advisor prescribes `flow watch` (agent). The watch exits on change; resolve again.
-- Checks failing: the advisor prescribes `record-change --source-stage ci` (agent). The failing check names ride along; the exact message and classification are derived from the check logs, then the correction re-passes its gates and republishes with `publish-pr --action update`.
-- Merge eligible (checks green, reviews satisfied, clean merge state): the advisor prescribes the exact `gh pr merge --squash` command (agent). This is prescribe-only: the command carries a foreign program, which the execute driver refuses categorically, so Boatstack can never merge — the agent runs `gh` under the host's own authority, and only as rendered.
-- Review required, changes requested, PR closed, or an unverifiable position: nothing is prescribed; the step is the operator's.
-
-The merged observation ends the flow (`FEATURE_COMPLETE`), which is also the workspace-cleanup/reap checkpoint.
-
-### `PR_OPEN -> WATCH`
-
-A published pull request changes asynchronously: checks finish, reviews land, merges happen. `flow watch` is the bounded waiting primitive for that interval. It re-observes the read-only frontier on an interval and exits when a row's position or owner changes, when nothing on the frontier can move, or when its timeout passes (distinct exit code). It performs no writes and executes no operation — observation and actuation stay separate, so waiting can never become acting. When the watch exits, resolve `next-status` again and continue from the fresh state.
-
-### `PR_OPEN -> RETRO`
-
-Record unexpected friction and outcomes. A retro may propose a loop move, but it may not mutate durable instructions automatically.
-
-## Gate semantics
-
-- `PASS`: required evidence is present; no gate-blocking gap remains.
-- `PASS_WITH_GAPS`: no critical gap remains; each accepted gap has impact, owner, and trigger, and `workflow.allow_pass_with_gaps` is enabled.
-- `BLOCKED`: required evidence failed or a critical unknown/gap remains.
-
-## State routing
-
-The workflow never branches on model provider, model name, price, or presumed capability. Route only from observed state:
-
-- unresolved product choice -> ask the human;
-- undiscovered code fact -> inspect the minimal relevant slice;
-- high-risk boundary -> require independent evidence and the configured reviewer;
-- repeated tactic without new evidence -> stop and re-diagnose;
-- converging work at a budget boundary -> resume from checkpoint if policy permits;
-- weak or circular oracle -> add an independent verification source;
-- changed approved intent -> invalidate the plan lock and return to `PLAN_GATE`.
+PR delivery continues through `publication.preview`,
+`publication.execute`, and `publication.observe`. Merged delivery also needs
+the exact workspace to become `landed`.
-The same state contract applies whether the repository uses a local model, a cheap API model, or a frontier model.
+Recovery outranks ordinary progress. Goal reconfiguration is explicit and may
+change an active delivery only with human or autonomy authority.
diff --git a/boatstack/release.go b/boatstack/release.go
deleted file mode 100644
index 962d6be..0000000
--- a/boatstack/release.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os/exec"
- "path/filepath"
- "regexp"
- "sort"
- "strconv"
- "strings"
-)
-
-var stableReleaseVersion = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`)
-
-// ReleaseClassification separates a documentation-only change from a change
-// that alters the installed Boatstack delivery harness.
-type ReleaseClassification struct {
- Required bool
- Paths []string
-}
-
-func normalizedReleasePath(value string) string {
- return filepath.ToSlash(filepath.Clean(strings.TrimSpace(value)))
-}
-
-func isReleaseBearingPath(value string) bool {
- path := normalizedReleasePath(value)
- if path == "." || path == "" {
- return false
- }
- for _, exact := range []string{
- ".gitignore", "CONTRIBUTING.md", "README.md",
- "project.example.json",
- } {
- if path == exact {
- return false
- }
- }
- for _, prefix := range []string{
- ".github/", "assets/", "automation/", "docs/", "examples/", "release-notes/",
- } {
- if strings.HasPrefix(path, prefix) {
- return false
- }
- }
- if strings.HasPrefix(path, "boatstack/testdata/") || strings.HasSuffix(path, "_test.go") {
- return false
- }
- return true
-}
-
-// ClassifyReleasePaths is conservative: unknown projected product paths are
-// release-bearing, while known presentation, provenance, test, and control
-// plane paths are not.
-func ClassifyReleasePaths(paths []string) ReleaseClassification {
- releasePaths := make([]string, 0, len(paths))
- seen := map[string]bool{}
- for _, value := range paths {
- path := normalizedReleasePath(value)
- if !seen[path] && isReleaseBearingPath(path) {
- seen[path] = true
- releasePaths = append(releasePaths, path)
- }
- }
- sort.Strings(releasePaths)
- return ReleaseClassification{Required: len(releasePaths) > 0, Paths: releasePaths}
-}
-
-// ClassifyReleaseDiff reads the exact Boatstack Git diff used by the release
-// workflow and applies the same deterministic path policy as unit tests.
-func ClassifyReleaseDiff(repo, base, head string) (ReleaseClassification, error) {
- if strings.TrimSpace(base) == "" || strings.TrimSpace(head) == "" {
- return ReleaseClassification{}, fmt.Errorf("release classification requires base and head revisions")
- }
- command := exec.Command("git", "-C", repo, "diff", "--name-only", "--no-renames", base, head)
- channels, err := runCommandChannels(command)
- if err != nil {
- return ReleaseClassification{}, fmt.Errorf("release diff failed: %w", commandFailure(channels, err))
- }
- return ClassifyReleasePaths(strings.Split(strings.TrimSpace(string(channels.Stdout)), "\n")), nil
-}
-
-// NextPatchVersion returns the next stable patch version. Minor and major
-// releases remain deliberate changes rather than being inferred from commits.
-func NextPatchVersion(current string) (string, error) {
- matches := stableReleaseVersion.FindStringSubmatch(strings.TrimSpace(current))
- if matches == nil {
- return "", fmt.Errorf("release version must match vMAJOR.MINOR.PATCH: %s", current)
- }
- major, _ := strconv.Atoi(matches[1])
- minor, _ := strconv.Atoi(matches[2])
- patch, _ := strconv.Atoi(matches[3])
- return fmt.Sprintf("v%d.%d.%d", major, minor, patch+1), nil
-}
diff --git a/boatstack/release_test.go b/boatstack/release_test.go
deleted file mode 100644
index 7ee3d6e..0000000
--- a/boatstack/release_test.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "reflect"
- "testing"
-)
-
-func TestClassifyReleasePaths(t *testing.T) {
- documentation := []string{
- "README.md", "docs/getting-started.md", "assets/boatstack-mark.svg",
- "release-notes/2026-07-18-copy.md",
- "boatstack/export_test.go", "boatstack/testdata/example.txt",
- ".github/workflows/sync-upstream.yml", "automation/release-policy.md",
- }
- if got := ClassifyReleasePaths(documentation); got.Required || len(got.Paths) != 0 {
- t.Fatalf("documentation-only projection requested a release: %#v", got)
- }
-
- runtime := append(documentation,
- "boatstack/safety.go", "boatstack/SKILL.md", "install.sh", "new-runtime-path",
- )
- want := []string{"boatstack/SKILL.md", "boatstack/safety.go", "install.sh", "new-runtime-path"}
- got := ClassifyReleasePaths(runtime)
- if !got.Required || !reflect.DeepEqual(got.Paths, want) {
- t.Fatalf("runtime classification = %#v, want %#v", got, want)
- }
-}
-
-func TestClassifyReleaseDiff(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("one\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- base := runGit(t, repo, "rev-parse", "HEAD")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("two\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "docs")
- docsHead := runGit(t, repo, "rev-parse", "HEAD")
- if got, err := ClassifyReleaseDiff(repo, base, docsHead); err != nil || got.Required {
- t.Fatalf("documentation diff = %#v, %v", got, err)
- }
- if err := os.MkdirAll(filepath.Join(repo, "boatstack"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "boatstack", "runtime.go"), []byte("package boatstack\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "runtime")
- runtimeHead := runGit(t, repo, "rev-parse", "HEAD")
- if got, err := ClassifyReleaseDiff(repo, docsHead, runtimeHead); err != nil || !got.Required {
- t.Fatalf("runtime diff = %#v, %v", got, err)
- }
-}
-
-func TestNextPatchVersion(t *testing.T) {
- if got, err := NextPatchVersion("v0.7.0"); err != nil || got != "v0.7.1" {
- t.Fatalf("next patch = %q, %v", got, err)
- }
- if _, err := NextPatchVersion("latest"); err == nil {
- t.Fatal("invalid release version was accepted")
- }
-}
diff --git a/boatstack/repair_budget_conformance_test.go b/boatstack/repair_budget_conformance_test.go
deleted file mode 100644
index 5a2dc2b..0000000
--- a/boatstack/repair_budget_conformance_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "strings"
- "testing"
-)
-
-// control-law: repair-authority-is-class-specific-and-mechanism-sensitive
-func TestTypedRepairBudgetsAndDuplicateMechanisms(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- for _, class := range []string{"implementation_repair", "verification_repair"} {
- for attempt := 1; attempt <= 3; attempt++ {
- _, state, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "repair", SourceStage: "test",
- Classification: class, Evidence: "failure", Mechanism: class + "-" + string(rune('0'+attempt)),
- })
- if err != nil {
- t.Fatalf("%s attempt %d rejected: %v", class, attempt, err)
- }
- if state.RepairCounters[class] != attempt {
- t.Fatalf("%s counter=%d, want %d", class, state.RepairCounters[class], attempt)
- }
- }
- }
- _, before, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "duplicate", SourceStage: "test",
- Classification: "review_repair", Evidence: "same", Mechanism: "same mechanism",
- })
- if err != nil {
- t.Fatal(err)
- }
- _, _, err = RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "duplicate", SourceStage: "test",
- Classification: "review_repair", Evidence: "same", Mechanism: "same mechanism",
- })
- if err == nil || !strings.Contains(err.Error(), "identical") {
- t.Fatalf("duplicate mechanism must be denied as friction: %v", err)
- }
- after, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if after.RepairCounters["review_repair"] != before.RepairCounters["review_repair"] {
- t.Fatal("denied duplicate must not consume repair authority")
- }
- _, changed, err := RecordChangeObservation(ChangeObservationOptions{
- Repo: repo, Feature: feature, Message: "changed mechanism", SourceStage: "test",
- Classification: "review_repair", Evidence: "same", Mechanism: "different mechanism",
- })
- if err != nil || changed.RepairCounters["review_repair"] != 2 {
- t.Fatalf("changed mechanism must consume the next class-specific attempt: state=%+v err=%v", changed.RepairCounters, err)
- }
-}
-
-// control-law: legacy-exhaustion-cannot-gain-new-retry-authority
-func TestLegacyRepairAttemptMigratesToEveryClass(t *testing.T) {
- raw := []byte(`{"schema_version":1,"repair_attempt":3}`)
- upgraded, changed, err := migrateDeliveryStateBytes(raw)
- if err != nil || !changed {
- t.Fatalf("legacy delivery state migration failed: changed=%v err=%v", changed, err)
- }
- var value map[string]any
- if err := json.Unmarshal(upgraded, &value); err != nil {
- t.Fatal(err)
- }
- counters := value["repair_counters"].(map[string]any)
- for _, class := range []string{"implementation_repair", "verification_repair", "review_repair"} {
- if intValue(counters[class]) != 3 {
- t.Fatalf("%s inherited %v, want 3", class, counters[class])
- }
- }
-}
diff --git a/boatstack/repair_state_test.go b/boatstack/repair_state_test.go
deleted file mode 100644
index 0437afb..0000000
--- a/boatstack/repair_state_test.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// writeMalformedDraft models a legacy failure: an agent hand-authored a
-// feature directory with a prose plan.md but never let the helper register it, so
-// there is no plan.lock.json and no delivery state. CheckPlan fails on it, but
-// the draft remains observational until that feature is explicitly selected.
-func writeMalformedDraft(t *testing.T, repo, feature string) string {
- t.Helper()
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- planPath := filepath.Join(directory, "plan.md")
- body := "# " + feature + "\n\nHand-written prose with no structured, marked plan block.\n"
- if err := os.WriteFile(planPath, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := CheckPlan(planPath); err == nil {
- t.Fatalf("fixture is not malformed: CheckPlan unexpectedly passed for %s", feature)
- }
- return directory
-}
-
-func TestControlledPhaseTransitionAllowsRepairStateAcrossStages(t *testing.T) {
- command := ".product-loop/boatstack repair-state --repo . --feature stuck"
- for _, stage := range []string{"", "INVALID_STATE", "DRAFT_PLAN", "APPROVED"} {
- if !controlledPhaseTransition(command, stage) {
- t.Fatalf("repair-state was denied at stage %q; recovery must be reachable", stage)
- }
- }
- // The escape hatch must not widen the surface for real transitions or metachars.
- if controlledPhaseTransition(".product-loop/boatstack activate-plan", "INVALID_STATE") {
- t.Fatal("activate-plan escaped the INVALID_STATE interlock")
- }
- if controlledPhaseTransition(".product-loop/boatstack repair-state; rm -rf .", "INVALID_STATE") {
- t.Fatal("chained destruction was allowed to ride on repair-state")
- }
- if controlledPhaseTransition("python scripts/migrate.py", "INVALID_STATE") {
- t.Fatal("an ordinary mutation was allowed at INVALID_STATE")
- }
-}
-
-// TestRepairStateRemainsExplicitWithoutAmbientLockout is the end-to-end contract: with a
-// malformed unregistered draft on disk, ordinary product work remains outside
-// managed scope while explicit feature resolution reaches plan validation and
-// the bounded repair command remains available.
-func TestRepairStateRemainsExplicitWithoutAmbientLockout(t *testing.T) {
- repo := nextTestRepo(t)
- writeMalformedDraft(t, repo, "stuck-feature")
-
- findings := ClassifyCommand(repo, "python scripts/migrate.py")
- if len(findings) != 0 {
- t.Fatalf("unselected malformed draft controlled ordinary mutation: %#v", findings)
- }
- status, err := ResolveNext(repo, "stuck-feature")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "DRAFT_PLAN" || status.NextOperation != "plan-gate" {
- t.Fatalf("selected draft did not route through plan validation: %#v", status)
- }
- if denied := ClassifyCommand(repo, ".product-loop/boatstack repair-state --repo . --feature stuck-feature"); len(denied) != 0 {
- t.Fatalf("the prescribed recovery was itself denied: %#v", denied)
- }
-}
-
-func TestRepairStateQuarantinesUnregisteredMalformedDraft(t *testing.T) {
- repo := nextTestRepo(t)
- directory := writeMalformedDraft(t, repo, "stuck-feature")
-
- result, err := RepairState(repo, "stuck-feature")
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || result.Action != "quarantined" || result.NextOperation != "auto-plan" {
- t.Fatalf("unexpected repair outcome: %#v", result)
- }
- if dirExists(directory) {
- t.Fatal("the malformed draft directory was not removed from features/")
- }
- if result.QuarantinePath == "" || !dirExists(filepath.Join(repo, filepath.FromSlash(result.QuarantinePath))) {
- t.Fatalf("quarantine copy is missing at %q", result.QuarantinePath)
- }
- if !fileExists(filepath.Join(repo, filepath.FromSlash(result.QuarantinePath), "plan.md")) {
- t.Fatal("quarantine did not preserve the draft plan.md")
- }
- // After recovery the workflow must be able to plan again from a clean slate.
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "NOT_STARTED" {
- t.Fatalf("workflow did not return to a plannable state: %q", status.ObservedStage)
- }
-}
-
-func TestRepairStateResolvesSoleCandidateWithoutFeature(t *testing.T) {
- repo := nextTestRepo(t)
- writeMalformedDraft(t, repo, "only-stuck")
- result, err := RepairState(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || result.Feature != "only-stuck" {
- t.Fatalf("sole malformed candidate was not resolved: %#v", result)
- }
-}
-
-func TestRepairStateBlocksAmbiguousDrafts(t *testing.T) {
- repo := nextTestRepo(t)
- writeMalformedDraft(t, repo, "stuck-one")
- writeMalformedDraft(t, repo, "stuck-two")
- result, err := RepairState(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "BLOCKED" || len(result.Blockers) != 2 {
- t.Fatalf("ambiguous drafts were not refused with both candidates: %#v", result)
- }
-}
-
-func TestRepairStateRefusesRegisteredAndPublishedFeatures(t *testing.T) {
- t.Run("valid saved plan", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeValidSavedFeaturePlan(t, repo, "good-feature")
- assertRefused(t, repo, "good-feature")
- })
- t.Run("locked plan", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeMalformedDraft(t, repo, "locked-feature")
- writeDraftFile(t, repo, "locked-feature", "plan.lock.json", "lock\n")
- assertRefused(t, repo, "locked-feature")
- })
- t.Run("published pr", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeMalformedDraft(t, repo, "shipped-feature")
- writeDraftFile(t, repo, "shipped-feature", "pr.md", "# PR\n")
- assertRefused(t, repo, "shipped-feature")
- })
- t.Run("active delivery state", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeNextDelivery(t, repo, "active-feature", "BUILD", 0)
- // Overwrite the registered plan with a malformed one to prove the refusal
- // comes from the delivery state, not from CheckPlan.
- planPath := filepath.Join(repo, ".product-loop", "features", "active-feature", "plan.md")
- if err := os.WriteFile(planPath, []byte("# prose only\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- assertRefused(t, repo, "active-feature")
- })
- t.Run("tracked directory", func(t *testing.T) {
- repo := nextTestRepo(t)
- writeMalformedDraft(t, repo, "tracked-feature")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- runGit(t, repo, "add", ".product-loop/features/tracked-feature/plan.md")
- assertRefused(t, repo, "tracked-feature")
- })
-}
-
-func assertRefused(t *testing.T, repo, feature string) {
- t.Helper()
- result, err := RepairState(repo, feature)
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "BLOCKED" || result.Action != "refused" {
- t.Fatalf("repair-state should have refused %s but returned %#v", feature, result)
- }
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if !dirExists(directory) {
- t.Fatalf("refused feature %s was mutated on disk", feature)
- }
-}
-
-func writeDraftFile(t *testing.T, repo, feature, name, body string) {
- t.Helper()
- path := filepath.Join(repo, ".product-loop", "features", feature, name)
- if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-// TestRepairStateDoesNotBypassRegisteredMalformedPlan guards against a silent
-// escape: a feature with a plan.lock.json is registered even if its plan.md is
-// malformed, so it is excluded from candidate resolution and never eligible for
-// quarantine by an empty --feature.
-func TestRepairStateDoesNotBypassRegisteredMalformedPlan(t *testing.T) {
- repo := nextTestRepo(t)
- writeMalformedDraft(t, repo, "registered-broken")
- writeDraftFile(t, repo, "registered-broken", "plan.lock.json", "lock\n")
- candidates, err := featurePlanCandidates(repo)
- if err != nil {
- t.Fatal(err)
- }
- for _, candidate := range candidates {
- if candidate == "registered-broken" {
- t.Fatal("a locked feature was offered as a repair candidate")
- }
- }
- result, err := RepairState(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if result.Action == "quarantined" || strings.Contains(result.QuarantinePath, "registered-broken") {
- t.Fatalf("repair-state quarantined a registered feature: %#v", result)
- }
-}
diff --git a/boatstack/retro.go b/boatstack/retro.go
deleted file mode 100644
index 8309766..0000000
--- a/boatstack/retro.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "strings"
-
- "github.com/operatorstack/boatstack/boatstack/internal/retromine"
-)
-
-// RetroInput is one transcript handed to the retro derivation: a name (for
-// evidence references and per-file session identity) and its raw content.
-// The derivation layer takes bytes, never paths — every capability the miner
-// lacks (filesystem, network, subprocess, clock) stays lacking here; only
-// the CLI boundary reads files, from operator-supplied paths only.
-// control-law: retro-derivation-is-offline-and-deterministic
-type RetroInput struct {
- Name string
- Content []byte
-}
-
-// RetroDerive parses every input with the named adapter format ("" sniffs
-// per file: events | claudecode | plaintext) and mines the combined events
-// for recurring operator instructions, classified into typed-gap proposals.
-// It proposes only: no file is written, no state is touched, no command is
-// run, and nothing is enforced — promotion is always a reviewed change made
-// by hand. control-law: retro-proposes-never-enforces
-func RetroDerive(format string, inputs []RetroInput) (retromine.Report, error) {
- events := []retromine.Event{}
- for _, input := range inputs {
- parsed, err := retromine.ParseTranscript(format, input.Name, input.Content)
- if err != nil {
- return retromine.Report{}, err
- }
- events = append(events, parsed...)
- }
- return retromine.BuildReport(events), nil
-}
-
-// FormatRetroReport renders the derivation for a human reviewer.
-func FormatRetroReport(report retromine.Report) string {
- var b strings.Builder
- fmt.Fprintf(&b, "Retro derivation: %d event(s) scanned, %d from the operator.\n",
- report.EventsScanned, report.OperatorEvents)
- if len(report.Proposals) == 0 && len(report.Unclassified) == 0 {
- b.WriteString("No recurring operator instruction found across sessions. Nothing to promote.\n")
- return b.String()
- }
- for i, proposal := range report.Proposals {
- fmt.Fprintf(&b, "\n%d. [%s] seen %d time(s) across %d session(s)\n", i+1,
- proposal.GapType, proposal.Occurrences, len(proposal.Sessions))
- fmt.Fprintf(&b, " Instruction: %q\n", proposal.Exemplar)
- fmt.Fprintf(&b, " Promote it: %s\n", proposal.SuggestedShape)
- }
- for _, cluster := range report.Unclassified {
- fmt.Fprintf(&b, "\n?. [unclassified] seen %d time(s) across %d session(s): %q\n",
- cluster.Occurrences, len(cluster.Sessions), cluster.Exemplar)
- b.WriteString(" Recurs, but no gap type matched; review it by hand. No proposal is generated.\n")
- }
- b.WriteString("\nDerivation proposes; it never enforces. Promote a proposal by hand through the normal reviewed delivery flow.\n")
- return b.String()
-}
diff --git a/boatstack/retro_conformance_test.go b/boatstack/retro_conformance_test.go
deleted file mode 100644
index 8979e60..0000000
--- a/boatstack/retro_conformance_test.go
+++ /dev/null
@@ -1,140 +0,0 @@
-package boatstack
-
-// control-law: retro-proposes-never-enforces
-//
-// `retro derive` closes the loop the whole program serves: a recurring
-// operator instruction is steady-state error, and the remedy is a TYPED
-// promotion — an observation, verb, setpoint, or guard — never a saved
-// prompt and never an automatic change. The derivation therefore only ever
-// produces a report: it writes no file, mutates no state, runs no command,
-// and an unclassifiable recurrence is surfaced without a proposal
-// (fail-closed). Below the CLI's read-only file loading, the pipeline is
-// capability-free (pinned structurally in the retromine conformance suite).
-//
-// Test classes: positive (each gap type classifies from planted recurring
-// phrasing, with a suggested typed shape), negative (an unmatched recurrence
-// lands in unclassified with zero proposals), bypass (derivation leaves the
-// filesystem byte-identical), failure-state (empty input → empty report;
-// a malformed transcript is a typed error, not a partial report).
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func neutralTranscript(instruction string) []byte {
- var b strings.Builder
- for _, session := range []string{"s1", "s2", "s3"} {
- fmt.Fprintf(&b, `{"session_id":%q,"role":"operator","text":%q}`+"\n", session, instruction)
- fmt.Fprintf(&b, `{"session_id":%q,"role":"agent","text":"done"}`+"\n", session)
- }
- return []byte(b.String())
-}
-
-// Positive: each gap type classifies from its phrasing and carries a
-// suggested typed shape.
-func TestRetroDeriveClassifiesEachGapType(t *testing.T) {
- for _, test := range []struct {
- instruction string
- wantGap string
- }{
- {"never force push to the main branch", "missing_guard"},
- {"watch the checks until every one passes then merge", "missing_setpoint"},
- {"check the status of the deployment pipeline", "missing_observation"},
- {"run the full test suite again please", "missing_verb"},
- } {
- t.Run(test.wantGap, func(t *testing.T) {
- report, err := RetroDerive("events", []RetroInput{{Name: "t.jsonl", Content: neutralTranscript(test.instruction)}})
- if err != nil {
- t.Fatal(err)
- }
- if len(report.Proposals) != 1 {
- t.Fatalf("proposals = %#v, want exactly one", report.Proposals)
- }
- proposal := report.Proposals[0]
- if proposal.GapType != test.wantGap {
- t.Fatalf("gap = %q, want %q", proposal.GapType, test.wantGap)
- }
- if proposal.Occurrences != 3 || len(proposal.Sessions) != 3 {
- t.Fatalf("unexpected recurrence evidence: %#v", proposal)
- }
- if proposal.SuggestedShape == "" {
- t.Fatal("proposal carries no suggested typed shape")
- }
- rendered := FormatRetroReport(report)
- if !strings.Contains(rendered, test.wantGap) || !strings.Contains(rendered, "never enforces") {
- t.Fatalf("rendering incomplete:\n%s", rendered)
- }
- })
- }
-}
-
-// Negative: a recurrence the lexicon cannot place is surfaced as
-// unclassified and generates zero proposals.
-func TestUnclassifiedRecurrenceGeneratesNoProposal(t *testing.T) {
- report, err := RetroDerive("events", []RetroInput{{Name: "t.jsonl", Content: neutralTranscript("the quarterly numbers look pretty good overall")}})
- if err != nil {
- t.Fatal(err)
- }
- if len(report.Proposals) != 0 {
- t.Fatalf("unclassified recurrence produced proposals: %#v", report.Proposals)
- }
- if len(report.Unclassified) != 1 {
- t.Fatalf("unclassified recurrence not surfaced: %#v", report)
- }
- if rendered := FormatRetroReport(report); !strings.Contains(rendered, "No proposal is generated") {
- t.Fatalf("unclassified recurrence not explained:\n%s", rendered)
- }
-}
-
-// Bypass: derivation leaves the filesystem byte-identical — it consumes
-// bytes and produces a report, nothing else.
-func TestRetroDeriveWritesNothing(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "transcript.jsonl")
- if err := os.WriteFile(path, neutralTranscript("never force push to the main branch"), 0o644); err != nil {
- t.Fatal(err)
- }
- content, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := RetroDerive("", []RetroInput{{Name: path, Content: content}}); err != nil {
- t.Fatal(err)
- }
- entries, err := os.ReadDir(dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(entries) != 1 {
- t.Fatalf("derivation changed the directory: %v", entries)
- }
- after, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- if string(after) != string(content) {
- t.Fatal("derivation modified its input")
- }
-}
-
-// Failure-state: empty input yields an empty report; a malformed transcript
-// is a typed error, never a partial report.
-func TestRetroDeriveFailureStates(t *testing.T) {
- report, err := RetroDerive("events", nil)
- if err != nil {
- t.Fatal(err)
- }
- if report.EventsScanned != 0 || len(report.Proposals) != 0 {
- t.Fatalf("empty input produced content: %#v", report)
- }
- if rendered := FormatRetroReport(report); !strings.Contains(rendered, "Nothing to promote") {
- t.Fatalf("empty report not explained:\n%s", rendered)
- }
- if _, err := RetroDerive("events", []RetroInput{{Name: "bad.jsonl", Content: []byte("not json\n")}}); err == nil {
- t.Fatal("malformed transcript accepted")
- }
-}
diff --git a/boatstack/run.go b/boatstack/run.go
deleted file mode 100644
index 2602635..0000000
--- a/boatstack/run.go
+++ /dev/null
@@ -1,211 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "strconv"
- "strings"
-)
-
-const runPreflightSchemaVersion = 2
-
-var runGitCommand = gitCommand
-
-// Tests may replace this seam. Nil selects the production pure health check
-// without creating an initialization cycle through plan/readiness guards.
-var runInstallationHealth func(string) error
-
-// RunPreflight is the deterministic Git freshness boundary used before the
-// host-driven run operation is allowed to mutate workflow or product state.
-type RunPreflight struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- BaseBranch string `json:"base_branch,omitempty"`
- HeadBranch string `json:"head_branch,omitempty"`
- Upstream string `json:"upstream,omitempty"`
- Relation string `json:"relation,omitempty"`
- AuthorityStatus string `json:"authority_status"`
- AuthorityReason string `json:"authority_reason"`
- Reason string `json:"reason"`
-}
-
-func blockedRunPreflight(base, head, upstream, relation, reason string) RunPreflight {
- return RunPreflight{
- SchemaVersion: runPreflightSchemaVersion, VerificationStatus: "BLOCKED",
- BaseBranch: base, HeadBranch: head, Upstream: upstream, Relation: relation,
- AuthorityStatus: AuthorityHookGuarded,
- AuthorityReason: "Boatstack hooks guard known irreversible operations; cloud authority is not externally attested.",
- Reason: reason,
- }
-}
-
-func blockedRunPreflightWithAuthority(base, head, upstream, relation, reason, authorityStatus, authorityReason string) RunPreflight {
- status := blockedRunPreflight(base, head, upstream, relation, reason)
- status.AuthorityStatus = authorityStatus
- status.AuthorityReason = authorityReason
- return status
-}
-
-func runBranches(repo, explicitFeature string) (string, string, error) {
- base := defaultPRBase(repo)
- head, err := runGitCommand(repo, "branch", "--show-current")
- if err != nil || strings.TrimSpace(head) == "" {
- return "", "", fmt.Errorf("boatstack run requires a named current branch")
- }
-
- active, err := ActiveManagedDeliveries(repo)
- if err != nil {
- return "", "", err
- }
- // Scope the ambiguity check to un-ignored deliveries so the foreground
- // coordinator matches ResolveNext. A config that fails to load leaves active
- // unfiltered, preserving the prior >1-active behavior.
- if config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()); configErr == nil {
- active = withoutIgnoredDeliveries(active, config.Workflow.IgnoredDeliveries)
- }
-
- if explicitFeature != "" {
- found := false
- for _, f := range active {
- if f == explicitFeature {
- found = true
- break
- }
- }
- if found {
- active = []string{explicitFeature}
- } else {
- return base, head, fmt.Errorf("feature %s is not currently an active managed delivery", explicitFeature)
- }
- }
-
- if len(active) > 1 {
- return base, head, fmt.Errorf("more than one managed delivery is active; Boatstack run will not choose by recency")
- }
- if len(active) == 1 {
- state, stateErr := CurrentDeliveryState(repo, active[0])
- if stateErr != nil {
- return "", "", stateErr
- }
- slice, sliceErr := activeDeliverySlice(state)
- if sliceErr != nil {
- return "", "", sliceErr
- }
- if slice.BaseBranch != "" {
- base = slice.BaseBranch
- }
- if slice.HeadBranch != "" && slice.HeadBranch != head {
- return base, head, fmt.Errorf("active delivery slice %s requires head branch %s; current branch is %s", slice.ID, slice.HeadBranch, head)
- }
- }
- base, err = canonicalPRBaseName(base)
- if err != nil {
- return "", head, err
- }
- if head == base {
- return base, head, fmt.Errorf("Boatstack run requires a feature branch; current branch %s is the configured base branch", head)
- }
- return base, head, nil
-}
-
-// CheckInstallationPreflight is the pure planning/readiness cut. It performs no
-// fetch, repair, dependency installation, feature write, or bookkeeping write.
-func CheckInstallationPreflight(repoPath string) RunPreflight {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return blockedRunPreflight("", "", "", "INVALID_REPOSITORY", err.Error())
- }
- if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil {
- return blockedRunPreflight("", "", "", "DETACHED_CONFIG_DRIFT", workspaceErr.Error())
- }
- if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) {
- return blockedRunPreflight("", "", "", "NOT_INITIALIZED", "This repository has no Boatstack project installation to run.")
- }
- if _, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()); configErr != nil {
- return blockedRunPreflight("", "", "", "INVALID_CONFIG", "Boatstack could not validate the project configuration.")
- }
- healthCheck := CheckInstallationHealth
- if runInstallationHealth != nil {
- healthCheck = runInstallationHealth
- }
- if healthErr := healthCheck(repo); healthErr != nil {
- return blockedRunPreflight("", "", "", "INSTALLATION_UNHEALTHY", DoctorRepairHint(healthErr).Error())
- }
- return RunPreflight{
- SchemaVersion: runPreflightSchemaVersion, VerificationStatus: "VERIFIED", Relation: "INSTALLATION_HEALTHY",
- AuthorityStatus: AuthorityHookGuarded,
- AuthorityReason: "Installation/generated-state health is verified; external authority is evaluated by delivery preflight.",
- Reason: "Boatstack installation and generated state are healthy.",
- }
-}
-
-// CheckRunPreflight fetches origin and proves that the current branch contains
-// the fetched base and is not behind or diverged from its configured upstream.
-// It never merges, rebases, switches branches, discards changes, or pushes.
-func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight {
- health := CheckInstallationPreflight(repoPath)
- if health.VerificationStatus != "VERIFIED" {
- return health
- }
- repo, _ := ResolveRepository(repoPath)
- config, _, _ := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- authorityStatus, authorityReason := verifyAuthorityBoundary(repo, config.Workflow.ExternalAuthority)
- block := func(base, head, upstream, relation, reason string) RunPreflight {
- return blockedRunPreflightWithAuthority(base, head, upstream, relation, reason, authorityStatus, authorityReason)
- }
- if normalizedAuthorityMode(config.Workflow.ExternalAuthority) == "credential-enforced" && authorityStatus != AuthorityCredentialEnforced {
- return block("", "", "", "AUTHORITY_BOUNDARY", authorityReason)
- }
- if _, err := runGitCommand(repo, "remote", "get-url", "origin"); err != nil {
- return block("", "", "", "MISSING_ORIGIN", "Boatstack run requires a usable origin remote.")
- }
- if _, err := runGitCommand(repo, "fetch", "origin"); err != nil {
- return block("", "", "", "FETCH_FAILED", "Boatstack could not fetch origin: "+err.Error())
- }
-
- base, head, err := runBranches(repo, explicitFeature)
- if err != nil {
- return block(base, head, "", "BRANCH_MISMATCH", err.Error())
- }
- remoteBase := "refs/remotes/origin/" + base
- if _, err := runGitCommand(repo, "rev-parse", "--verify", remoteBase+"^{commit}"); err != nil {
- return block(base, head, "", "MISSING_REMOTE_BASE", fmt.Sprintf("Fetched origin does not contain base branch %s.", base))
- }
- if _, err := runGitCommand(repo, "merge-base", "--is-ancestor", remoteBase, "HEAD"); err != nil {
- return block(base, head, "", "STALE_BASE", fmt.Sprintf("Current branch %s does not contain fetched origin/%s; synchronize it outside Boatstack run.", head, base))
- }
-
- upstream, upstreamErr := runGitCommand(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
- relation := "UNPUBLISHED"
- if upstreamErr == nil && upstream != "" {
- counts, countErr := runGitCommand(repo, "rev-list", "--left-right", "--count", "HEAD...@{upstream}")
- if countErr != nil {
- return block(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack could not compare the current branch with its upstream: "+countErr.Error())
- }
- fields := strings.Fields(counts)
- if len(fields) != 2 {
- return block(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack received an invalid Git upstream comparison.")
- }
- ahead, aheadErr := strconv.Atoi(fields[0])
- behind, behindErr := strconv.Atoi(fields[1])
- if aheadErr != nil || behindErr != nil {
- return block(base, head, upstream, "UPSTREAM_UNKNOWN", "Boatstack received an invalid Git upstream comparison.")
- }
- switch {
- case ahead > 0 && behind > 0:
- return block(base, head, upstream, "DIVERGED", fmt.Sprintf("Current branch %s has diverged from %s; synchronize it outside Boatstack run.", head, upstream))
- case behind > 0:
- return block(base, head, upstream, "BEHIND", fmt.Sprintf("Current branch %s is behind %s; synchronize it outside Boatstack run.", head, upstream))
- case ahead > 0:
- relation = "AHEAD"
- default:
- relation = "CURRENT"
- }
- }
-
- return RunPreflight{
- SchemaVersion: runPreflightSchemaVersion, VerificationStatus: "VERIFIED",
- BaseBranch: base, HeadBranch: head, Upstream: upstream, Relation: relation,
- AuthorityStatus: authorityStatus, AuthorityReason: authorityReason,
- Reason: "Origin was fetched and the current branch is fresh enough to run Boatstack.",
- }
-}
diff --git a/boatstack/run_test.go b/boatstack/run_test.go
deleted file mode 100644
index 254c84c..0000000
--- a/boatstack/run_test.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "reflect"
- "strings"
- "testing"
-)
-
-func runTestRepo(t *testing.T) string {
- t.Helper()
- previousHealth := runInstallationHealth
- runInstallationHealth = func(string) error { return nil }
- t.Cleanup(func() { runInstallationHealth = previousHealth })
- repo := t.TempDir()
- for _, args := range [][]string{
- {"init", "-b", "main"},
- {"config", "user.name", "Boatstack Test"},
- {"config", "user.email", "boatstack@example.test"},
- } {
- if output, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput(); err != nil {
- t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output)
- }
- }
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("test\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- for _, args := range [][]string{{"add", "README.md"}, {"commit", "-m", "initial"}} {
- if output, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput(); err != nil {
- t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output)
- }
- }
- configDirectory := filepath.Join(repo, ".product-loop")
- if err := os.MkdirAll(configDirectory, 0o755); err != nil {
- t.Fatal(err)
- }
- config := `{"schema_version":1,"project":{"name":"test","default_branch":"main","commands":{"test":"go test ./..."}},"workflow":{"human_plan_approval":true,"independent_review_for_high_risk":true,"allow_pass_with_gaps":false},"adapters":["cursor"]}`
- if err := os.WriteFile(filepath.Join(configDirectory, "project.json"), []byte(config), 0o644); err != nil {
- t.Fatal(err)
- }
- return repo
-}
-
-// control-law: run-mutation-requires-pure-installation-health
-func TestCheckRunPreflightBlocksUnhealthyInstallationBeforeGitEffects(t *testing.T) {
- repo := runTestRepo(t)
- called := false
- runInstallationHealth = func(string) error {
- called = true
- return fmt.Errorf("generated adapter is missing")
- }
- withRunGit(t, map[string]struct {
- value string
- err error
- }{})
- status := CheckRunPreflight(repo, "")
- if !called || status.VerificationStatus != "BLOCKED" || status.Relation != "INSTALLATION_UNHEALTHY" {
- t.Fatalf("unexpected preflight: %+v", status)
- }
-}
-
-func withRunGit(t *testing.T, responses map[string]struct {
- value string
- err error
-}) {
- t.Helper()
- old := runGitCommand
- runGitCommand = func(_ string, arguments ...string) (string, error) {
- key := strings.Join(arguments, " ")
- if response, ok := responses[key]; ok {
- return response.value, response.err
- }
- return "", fmt.Errorf("unexpected git command: %s", key)
- }
- t.Cleanup(func() { runGitCommand = old })
-}
-
-func writeRunConfig(t *testing.T, repo string, ignored ...string) {
- t.Helper()
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Adapters = []string{"cursor"}
- config.Workflow.IgnoredDeliveries = ignored
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestRunBranchesIgnoredActiveDeliveryClearsAmbiguity(t *testing.T) {
- repo := runTestRepo(t)
- writeNextDelivery(t, repo, "first", "BUILD", 0)
- writeNextDelivery(t, repo, "second", "BUILD", 0)
- writeRunConfig(t, repo, "first")
- withRunGit(t, map[string]struct {
- value string
- err error
- }{"branch --show-current": {value: "feature"}})
-
- if _, _, err := runBranches(repo, ""); err != nil {
- t.Fatalf("ignored active delivery should clear run ambiguity: %v", err)
- }
-}
-
-func TestRunBranchesNewUnignoredActiveDeliveryStillBlocks(t *testing.T) {
- repo := runTestRepo(t)
- writeNextDelivery(t, repo, "first", "BUILD", 0)
- writeNextDelivery(t, repo, "second", "BUILD", 0)
- writeRunConfig(t, repo, "unrelated")
- withRunGit(t, map[string]struct {
- value string
- err error
- }{"branch --show-current": {value: "feature"}})
-
- _, _, err := runBranches(repo, "")
- if err == nil || !strings.Contains(err.Error(), "more than one managed delivery is active") {
- t.Fatalf("un-ignored ambiguous deliveries should still block run: %v", err)
- }
-}
-
-func TestCheckRunPreflightRequiresOriginBeforeMutation(t *testing.T) {
- repo := runTestRepo(t)
- before, err := os.ReadFile(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- status := CheckRunPreflight(repo, "")
- after, err := os.ReadFile(filepath.Join(repo, ".product-loop", "project.json"))
- if err != nil {
- t.Fatal(err)
- }
- if status.VerificationStatus != "BLOCKED" || status.Relation != "MISSING_ORIGIN" {
- t.Fatalf("unexpected preflight: %+v", status)
- }
- if !reflect.DeepEqual(before, after) {
- t.Fatal("blocked preflight changed Boatstack state")
- }
-}
-
-func TestCheckRunPreflightFetchesAndAcceptsFreshUnpublishedBranch(t *testing.T) {
- repo := runTestRepo(t)
- remote := filepath.Join(t.TempDir(), "origin.git")
- if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
- t.Fatalf("git init --bare: %v: %s", err, output)
- }
- for _, args := range [][]string{
- {"remote", "add", "origin", remote},
- {"push", "-u", "origin", "main"},
- {"switch", "-c", "feature"},
- } {
- if output, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput(); err != nil {
- t.Fatalf("git %s: %v: %s", strings.Join(args, " "), err, output)
- }
- }
- status := CheckRunPreflight(repo, "")
- if status.VerificationStatus != "VERIFIED" || status.Relation != "UNPUBLISHED" || status.BaseBranch != "main" || status.HeadBranch != "feature" {
- t.Fatalf("unexpected preflight: %+v", status)
- }
-}
-
-func TestCheckRunPreflightBlocksFetchAndFreshnessFailures(t *testing.T) {
- tests := []struct {
- name string
- responses map[string]struct {
- value string
- err error
- }
- relation string
- }{
- {
- name: "fetch failure",
- responses: map[string]struct {
- value string
- err error
- }{
- "remote get-url origin": {value: "git@example.test/repo.git"},
- "fetch origin": {err: fmt.Errorf("authentication failed")},
- },
- relation: "FETCH_FAILED",
- },
- {
- name: "stale base",
- responses: map[string]struct {
- value string
- err error
- }{
- "remote get-url origin": {value: "git@example.test/repo.git"},
- "fetch origin": {},
- "branch --show-current": {value: "feature"},
- "rev-parse --verify refs/remotes/origin/main^{commit}": {value: "abc"},
- "merge-base --is-ancestor refs/remotes/origin/main HEAD": {err: fmt.Errorf("not ancestor")},
- },
- relation: "STALE_BASE",
- },
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- repo := runTestRepo(t)
- withRunGit(t, test.responses)
- status := CheckRunPreflight(repo, "")
- if status.VerificationStatus != "BLOCKED" || status.Relation != test.relation {
- t.Fatalf("unexpected preflight: %+v", status)
- }
- })
- }
-}
-
-func TestCheckRunPreflightClassifiesUpstreamRelations(t *testing.T) {
- for _, test := range []struct {
- counts, relation, verification string
- }{
- {counts: "0 0", relation: "CURRENT", verification: "VERIFIED"},
- {counts: "2 0", relation: "AHEAD", verification: "VERIFIED"},
- {counts: "0 1", relation: "BEHIND", verification: "BLOCKED"},
- {counts: "2 1", relation: "DIVERGED", verification: "BLOCKED"},
- } {
- t.Run(test.relation, func(t *testing.T) {
- repo := runTestRepo(t)
- withRunGit(t, map[string]struct {
- value string
- err error
- }{
- "remote get-url origin": {value: "git@example.test/repo.git"},
- "fetch origin": {},
- "branch --show-current": {value: "feature"},
- "rev-parse --verify refs/remotes/origin/main^{commit}": {value: "abc"},
- "merge-base --is-ancestor refs/remotes/origin/main HEAD": {},
- "rev-parse --abbrev-ref --symbolic-full-name @{upstream}": {value: "origin/feature"},
- "rev-list --left-right --count HEAD...@{upstream}": {value: test.counts},
- })
- status := CheckRunPreflight(repo, "")
- if status.VerificationStatus != test.verification || status.Relation != test.relation {
- t.Fatalf("unexpected preflight: %+v", status)
- }
- })
- }
-}
-
-func TestCheckRunPreflightBlocksConstrainedDeliveryBranchMismatch(t *testing.T) {
- repo := runTestRepo(t)
- directory := filepath.Join(repo, ".product-loop", "features", "bounded-run")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(directory, "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion,
- Feature: "bounded-run",
- PlanLockHash: lockHash,
- ActiveIndex: 0,
- Slices: []DeliverySlice{{
- ID: "delivery", Title: "Delivery", Status: "BUILD",
- BaseBranch: "main", HeadBranch: "expected-feature",
- }},
- }); err != nil {
- t.Fatal(err)
- }
- withRunGit(t, map[string]struct {
- value string
- err error
- }{
- "remote get-url origin": {value: "git@example.test/repo.git"},
- "fetch origin": {},
- "branch --show-current": {value: "wrong-feature"},
- })
- status := CheckRunPreflight(repo, "")
- if status.VerificationStatus != "BLOCKED" || status.Relation != "BRANCH_MISMATCH" || !strings.Contains(status.Reason, "expected-feature") {
- t.Fatalf("unexpected preflight: %+v", status)
- }
-}
diff --git a/boatstack/runtime.go b/boatstack/runtime.go
deleted file mode 100644
index c5bd2e1..0000000
--- a/boatstack/runtime.go
+++ /dev/null
@@ -1,269 +0,0 @@
-package boatstack
-
-import (
- "crypto/sha256"
- "embed"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "sort"
- "strings"
-)
-
-var (
- Version = "dev"
- SourceCommit = "unknown"
- ChecksumsSHA256 = "development"
- GStackRef = "a3259400a366593e0c909dd9ac3e59752efd2488"
- SpecKitVersion = "v0.12.16"
-)
-
-const (
- Generator = "boatstack-exporter"
- Marker = "Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json."
-)
-
-//go:embed references/*.md assets/templates/*
-var canonical embed.FS
-
-type ProjectConfig struct {
- SchemaVersion int `json:"schema_version"`
- Project Project `json:"project"`
- Workflow Workflow `json:"workflow"`
- Workspace Workspace `json:"workspace,omitempty"`
- Delivery *DeliveryPolicy `json:"delivery,omitempty"`
- Insights *InsightPolicy `json:"insights,omitempty"`
- Adapters []string `json:"adapters"`
- Integrations map[string]IntegrationState `json:"integrations,omitempty"`
-}
-
-// InsightPolicy controls the repository-backed insight capture surface. The nil zero
-// value disables the entire capability so existing projects retain byte-for-byte
-// behavior. Captures are intentionally manual and human-confirmed in v1; the
-// confirmed capture and every event are tracked below docs/insights, while the
-// remaining switches control read-only enrichment and evaluation surfaces.
-type InsightPolicy struct {
- Enabled bool `json:"enabled"`
- CaptureMode string `json:"capture_mode,omitempty"` // "" | "manual"
- ValueMap string `json:"value_map,omitempty"` // "" | "required"
- SuggestFeatures bool `json:"suggest_features,omitempty"`
- EvaluateOnPR bool `json:"evaluate_on_pr,omitempty"`
- PendingFrontier bool `json:"pending_frontier,omitempty"`
- CompletionMode string `json:"completion_mode,omitempty"` // "" | "human_confirmed"
-}
-
-// DeliveryPolicy declares the standing goal of the delivery flow. Terminal
-// names the state a delivery pursues before the flow reports "nothing left to
-// do": "published" (default — the flow ends when the slice's PR is open) or
-// "merged" (the flow keeps naming read-only post-publish steps until the PR
-// is observed merged). The nil zero value preserves the prior behavior
-// exactly: a goal this standing is widened only by an explicit operator
-// choice, never by an upgrade.
-// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
-type DeliveryPolicy struct {
- Terminal string `json:"terminal,omitempty"` // "" | "published" | "merged"
-}
-
-type Project struct {
- Name string `json:"name"`
- DefaultBranch string `json:"default_branch,omitempty"`
- Context []string `json:"context,omitempty"`
- Commands map[string]string `json:"commands"`
- HighRiskPaths []string `json:"high_risk_paths,omitempty"`
- VisualSurfaces []VisualSurface `json:"visual_surfaces,omitempty"`
- Migration MigrationConfig `json:"migration,omitempty"`
-}
-
-type VisualSurface struct {
- ID string `json:"id"`
- Paths []string `json:"paths"`
-}
-
-// MigrationConfig declares how a project APPLIES and VERIFIES its migrations against
-// a disposable database, so their EFFECT can be graded by executing them
-// (GradeMigrationEffect) rather than approximated from their SQL text. This is the
-// Sandboxed-Effect law: the guard treats a committed migration as data (the
-// data-artifact exemption), and the deploy pipeline — modelled here by a disposable
-// database and these commands — is the controlled executor that observes the real
-// effect. Both commands run via `sh -c` with the disposable database coordinate in
-// the environment as BOATSTACK_MIGRATE_DB. When apply_command is absent, grading is
-// skipped, so a repository without a database is unaffected.
-type MigrationConfig struct {
- ApplyCommand string `json:"apply_command,omitempty"`
- VerifyCommand string `json:"verify_command,omitempty"`
-}
-
-type Workflow struct {
- HumanPlanApproval bool `json:"human_plan_approval"`
- IndependentReviewForHighRisk bool `json:"independent_review_for_high_risk"`
- AllowPassWithGaps bool `json:"allow_pass_with_gaps"`
- MaintainChangelog bool `json:"maintain_changelog"`
- BoundaryAnalysis bool `json:"boundary_analysis,omitempty"`
- PRVisualEvidence string `json:"pr_visual_evidence,omitempty"`
- // ExternalAuthority distinguishes hook-only interception from an externally
- // attested repository-only credential boundary. Omission preserves existing
- // installs as HOOK_GUARDED and never implies credential enforcement.
- ExternalAuthority *ExternalAuthorityPolicy `json:"external_authority,omitempty"`
- // VisualEvidencePublish selects how programmatic visual evidence reaches a PR.
- // The nil zero value uses Litterbox with a 72-hour expiry. Publication still
- // requires explicit human privacy review for every screenshot.
- VisualEvidencePublish *VisualEvidencePublish `json:"visual_evidence_publish,omitempty"`
- // IgnoredDeliveries lists feature slugs of past deliveries to exclude from
- // delivery-ambiguity resolution. New, unlisted ambiguous deliveries still
- // pause the workflow. Persisted via the LoadConfig -> GeneratedJSON round-trip.
- IgnoredDeliveries []string `json:"ignored_deliveries,omitempty"`
-}
-
-// VisualEvidencePublish configures the opt-in external-host publish mode. The empty
-// zero value of each field resolves to a default at use, so a partially-specified
-// block (mode only) still works.
-type VisualEvidencePublish struct {
- Mode string `json:"mode,omitempty"` // "" | "external-host" (external hosting is always used)
- Host string `json:"host,omitempty"` // external-host only: "litterbox" (default) | "catbox"
- Expiry string `json:"expiry,omitempty"` // expiring host only: "1h" | "12h" | "24h" | "72h" (default "72h")
-}
-
-type IntegrationState struct {
- Requested bool `json:"requested"`
- Status string `json:"status,omitempty"`
- Version string `json:"version,omitempty"`
- Detail string `json:"detail,omitempty"`
-}
-
-// Workspace declares how Boatstack manages the per-feature working area: a fresh
-// cut from the up-to-date default branch when a feature starts, and cleanup once
-// the feature ships. The managed unit is a git worktree or an in-place branch;
-// the empty zero value (Enabled=false) preserves Boatstack's prior behavior of
-// never creating or removing worktrees or branches.
-type Workspace struct {
- Enabled bool `json:"enabled,omitempty"` // master switch; false = Boatstack touches no worktrees/branches
- Mode string `json:"mode,omitempty"` // "worktree" | "branch" (default "worktree")
- Cleanup string `json:"cleanup,omitempty"` // "confirm" | "auto" | "off" (default "confirm")
- CleanupAfter string `json:"cleanup_after,omitempty"` // "merge" | "ship" (default "merge")
- Reap string `json:"reap,omitempty"` // "confirm" | "auto" | "off" (default "confirm")
-}
-
-func ReadCanonical(path string) ([]byte, error) {
- return canonical.ReadFile(path)
-}
-
-func ReadCanonicalDir(path string) ([]fs.DirEntry, error) {
- return canonical.ReadDir(path)
-}
-
-func SHA256Bytes(value []byte) string {
- digest := sha256.Sum256(value)
- return hex.EncodeToString(digest[:])
-}
-
-func SHA256File(path string) (string, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- return "", err
- }
- return SHA256Bytes(value), nil
-}
-
-func repositoryRelativePath(repo, target string) (string, error) {
- repo = canonicalizeExistingAncestor(repo)
- target = canonicalizeExistingAncestor(target)
- relative, err := filepath.Rel(repo, target)
- if err != nil {
- return "", fmt.Errorf("cannot make path repository-relative: %w", err)
- }
- if _, err := resolveRepositoryRelativePath(repo, filepath.ToSlash(relative)); err != nil {
- return "", err
- }
- return filepath.ToSlash(filepath.Clean(relative)), nil
-}
-
-func resolveRepositoryRelativePath(repo, relative string) (string, error) {
- relative = strings.TrimSpace(relative)
- if relative == "" {
- return "", fmt.Errorf("repository-relative path is empty")
- }
- native := filepath.FromSlash(relative)
- if filepath.IsAbs(native) || filepath.VolumeName(native) != "" {
- return "", fmt.Errorf("path must be repository-relative: %s", relative)
- }
- native = filepath.Clean(native)
- if native == "." || native == ".." || strings.HasPrefix(native, ".."+string(filepath.Separator)) {
- return "", fmt.Errorf("path escapes repository boundary: %s", relative)
- }
- return filepath.Join(repo, native), nil
-}
-
-func MarshalJSON(value any) ([]byte, error) {
- data, err := json.MarshalIndent(value, "", " ")
- if err != nil {
- return nil, err
- }
- return append(data, '\n'), nil
-}
-
-// DecodeJSON is the single diagnostic boundary for JSON consumed by Boatstack.
-// source must name a filesystem path, embedded asset, generated destination, or
-// remote response so malformed input is actionable without a debugger.
-func DecodeJSON(operation, source string, raw []byte, destination any) error {
- if err := json.Unmarshal(raw, destination); err != nil {
- return fmt.Errorf("operation %s: parse JSON %s: %w", operation, source, err)
- }
- return nil
-}
-
-func ValidateJSON(operation, source string, raw []byte) error {
- var decoded any
- return DecodeJSON(operation, source, raw, &decoded)
-}
-
-func GeneratedJSON(value any) ([]byte, error) {
- raw, err := json.Marshal(value)
- if err != nil {
- return nil, err
- }
- data := map[string]any{}
- if err := DecodeJSON("generate JSON metadata", "marshaled generated value", raw, &data); err != nil {
- return nil, err
- }
- data["_generated_by"] = Generator
- data["_boatstack_version"] = Version
- return MarshalJSON(data)
-}
-
-func GeneratedMarkdown(body string) []byte {
- body = strings.ReplaceAll(body, "\r\n", "\n")
- body = strings.ReplaceAll(body, "\r", "\n")
- return []byte(fmt.Sprintf("\n\n%s\n", Marker, strings.TrimSpace(body)))
-}
-
-func GeneratedFrontmatter(body string) ([]byte, error) {
- if !strings.HasPrefix(body, "---\n") {
- return nil, fmt.Errorf("frontmatter adapter must start with ---")
- }
- closing := strings.Index(body[4:], "\n---\n")
- if closing < 0 {
- return nil, fmt.Errorf("frontmatter adapter is missing its closing ---")
- }
- insertAt := 4 + closing + len("\n---\n")
- marked := body[:insertAt] + "\n\n" + body[insertAt:]
- return []byte(strings.TrimSpace(marked) + "\n"), nil
-}
-
-func sortedKeys[T any](values map[string]T) []string {
- keys := make([]string, 0, len(values))
- for key := range values {
- keys = append(keys, key)
- }
- sort.Strings(keys)
- return keys
-}
-
-func writeFile(path string, value []byte, mode fs.FileMode) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- return os.WriteFile(path, value, mode)
-}
diff --git a/boatstack/runtime_cache.go b/boatstack/runtime_cache.go
deleted file mode 100644
index b3d4519..0000000
--- a/boatstack/runtime_cache.go
+++ /dev/null
@@ -1,481 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "time"
-)
-
-type runtimeManifest struct {
- SchemaVersion int `json:"schema_version"`
- BoatstackVersion string `json:"boatstack_version"`
- SourceCommit string `json:"source_commit"`
- Platform string `json:"platform"`
- BinarySHA256 string `json:"binary_sha256"`
- ReleaseChecksumsSHA256 string `json:"release_checksums_sha256"`
- Integrations map[string]IntegrationState `json:"integrations,omitempty"`
-}
-
-type generatedRuntimeLock struct {
- BoatstackVersion string `json:"boatstack_version"`
- Runtime struct {
- SourceCommit string `json:"source_commit"`
- } `json:"runtime"`
-}
-
-func helperName() string {
- name := "boatstack-helper"
- if runtime.GOOS == "windows" {
- name += ".exe"
- }
- return name
-}
-
-func platformKey() string { return runtime.GOOS + "-" + runtime.GOARCH }
-
-func safeCacheSegment(value, label string) (string, error) {
- value = strings.TrimSpace(value)
- if value == "" || value == "." || value == ".." || filepath.Base(value) != value ||
- strings.ContainsAny(value, `/\\`) {
- return "", fmt.Errorf("invalid %s for shared runtime cache", label)
- }
- return value, nil
-}
-
-func gitCommonDir(repo string) (string, error) {
- value := gitOutput(repo, "rev-parse", "--path-format=absolute", "--git-common-dir")
- if value == "" {
- value = gitOutput(repo, "rev-parse", "--git-common-dir")
- }
- if value == "" {
- return "", fmt.Errorf("cannot resolve the Git common directory")
- }
- if !filepath.IsAbs(value) {
- value = filepath.Join(repo, value)
- }
- absolute, err := filepath.Abs(value)
- if err != nil {
- return "", err
- }
- return filepath.Clean(absolute), nil
-}
-
-// worktreeGitDir resolves the per-worktree Git directory (".git" for the main
-// worktree, ".git/worktrees/" for a linked worktree). Per-worktree mutable
-// state (operations, repair backups, deliveries) lives here so one worktree never
-// reads or writes another worktree's record-of-work, and a removed worktree takes
-// its state with it. Contrast gitCommonDir, which is shared clone-wide and is the
-// right home only for immutable, version-namespaced content (runtime binaries,
-// staged update packages).
-func worktreeGitDir(repo string) (string, error) {
- value := gitOutput(repo, "rev-parse", "--path-format=absolute", "--git-dir")
- if value == "" {
- value = gitOutput(repo, "rev-parse", "--git-dir")
- }
- if value == "" {
- return "", fmt.Errorf("cannot resolve the Git worktree directory")
- }
- if !filepath.IsAbs(value) {
- value = filepath.Join(repo, value)
- }
- absolute, err := filepath.Abs(value)
- if err != nil {
- return "", err
- }
- return filepath.Clean(absolute), nil
-}
-
-func sharedRuntimeDirectory(repo, version, sourceCommit string) (string, error) {
- return WorkspaceFor(repo).RuntimeDir(version, sourceCommit)
-}
-
-func sharedRuntimePaths(repo, version, sourceCommit string) (string, string, error) {
- binary, manifest, err := sharedRuntimeOwnedPaths(repo, version, sourceCommit)
- return binary.path, manifest.path, err
-}
-
-func sharedRuntimeOwnedPaths(repo, version, sourceCommit string) (controllerPath, controllerPath, error) {
- ctx := WorkspaceFor(repo)
- directory, err := sharedRuntimeDirectory(repo, version, sourceCommit)
- if err != nil {
- return controllerPath{}, controllerPath{}, err
- }
- binary, err := ctx.sharedOwnedPath(filepath.Join(directory, helperName()))
- if err != nil {
- return controllerPath{}, controllerPath{}, err
- }
- manifest, err := ctx.sharedOwnedPath(filepath.Join(directory, "runtime.lock.json"))
- return binary, manifest, err
-}
-
-func bootstrapRuntimePaths(repo, version, sourceCommit string) (string, string, error) {
- binary, manifest, err := bootstrapRuntimeOwnedPaths(repo, version, sourceCommit)
- return binary.path, manifest.path, err
-}
-
-func bootstrapRuntimeOwnedPaths(repo, version, sourceCommit string) (controllerPath, controllerPath, error) {
- ctx := WorkspaceFor(repo)
- directory, err := ctx.BootstrapRuntimeDir(version, sourceCommit)
- if err != nil {
- return controllerPath{}, controllerPath{}, err
- }
- common, err := gitCommonDir(repo)
- if err != nil {
- return controllerPath{}, controllerPath{}, err
- }
- binary, err := newControllerPath(common, filepath.Join(directory, helperName()))
- if err != nil {
- return controllerPath{}, controllerPath{}, err
- }
- manifest, err := newControllerPath(common, filepath.Join(directory, "runtime.lock.json"))
- return binary, manifest, err
-}
-
-func atomicWriteMode(path string, content []byte, mode fs.FileMode) error {
- directory := filepath.Dir(path)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- return err
- }
- if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("refusing symlinked runtime path: %s", path)
- } else if err != nil && !os.IsNotExist(err) {
- return err
- }
- temporary, err := os.CreateTemp(directory, ".boatstack-runtime-*")
- if err != nil {
- return err
- }
- temporaryPath := temporary.Name()
- defer os.Remove(temporaryPath)
- if err := temporary.Chmod(mode); err != nil {
- temporary.Close()
- return err
- }
- if _, err := temporary.Write(content); err != nil {
- temporary.Close()
- return err
- }
- if err := temporary.Sync(); err != nil {
- temporary.Close()
- return err
- }
- if err := temporary.Close(); err != nil {
- return err
- }
- return replaceFile(temporaryPath, path)
-}
-
-func installSharedRuntime(source, repo string, integrations map[string]IntegrationState) (runtimeManifest, error) {
- binaryPath, manifestPath, err := sharedRuntimeOwnedPaths(repo, Version, SourceCommit)
- if err != nil {
- return runtimeManifest{}, err
- }
- return writeRuntimeSlot(source, binaryPath, manifestPath, integrations)
-}
-
-// installCommandRuntime publishes the exact runtime needed by both sides of
-// tracked command activation. The mode-aware shared slot is installed first so
-// the Git-common bootstrap is never made admissible before it can activate the
-// worktree-local helper. Embedded mode uses one physical slot; detached mode
-// deliberately uses an external shared slot plus a Git-common bootstrap slot.
-// control-law: tracked-launcher-selects-only-the-pinned-runtime
-func installCommandRuntime(source, repo string, integrations map[string]IntegrationState) (runtimeManifest, error) {
- sharedManifest, err := installSharedRuntime(source, repo, integrations)
- if err != nil {
- return runtimeManifest{}, err
- }
- sharedBinary, _, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- return runtimeManifest{}, err
- }
- bootstrapBinary, bootstrapLock, err := bootstrapRuntimeOwnedPaths(repo, Version, SourceCommit)
- if err != nil {
- return runtimeManifest{}, err
- }
- if filepath.Clean(sharedBinary) == bootstrapBinary.path {
- return sharedManifest, nil
- }
- return writeRuntimeSlot(source, bootstrapBinary, bootstrapLock, integrations)
-}
-
-// installDetachedRuntime populates a detached repository's external shared-runtime
-// slot from the running helper, so the developer-level engagement probe has a stable
-// helper to invoke. Unlike installSharedRuntime it scopes the symlink check to the
-// external control root and does not consult any in-repo generated lock. It
-// requires WorkspaceFor(repo) to already resolve detached (attach writes the
-// binding and invalidates the cache first). It is idempotent.
-func installDetachedRuntime(repo, source string) (runtimeManifest, error) {
- ctx := WorkspaceFor(repo)
- if ctx.Mode != SupervisionDetached {
- return runtimeManifest{}, fmt.Errorf("installDetachedRuntime requires an attached detached repository")
- }
- binaryPath, manifestPath, err := sharedRuntimeOwnedPaths(repo, Version, SourceCommit)
- if err != nil {
- return runtimeManifest{}, err
- }
- return writeRuntimeSlot(source, binaryPath, manifestPath, nil)
-}
-
-// writeRuntimeSlot copies a helper binary and its manifest into a version-labeled
-// runtime slot atomically, rejecting symlinked components under each path's
-// owning controller boundary and
-// verifying the written bytes against the manifest checksum. It is the shared core
-// of the embedded and detached runtime installers.
-func writeRuntimeSlot(source string, binaryPath, manifestPath controllerPath, integrations map[string]IntegrationState) (runtimeManifest, error) {
- value, err := os.ReadFile(source)
- if err != nil {
- return runtimeManifest{}, err
- }
- manifest := runtimeManifest{
- SchemaVersion: 1, BoatstackVersion: Version, SourceCommit: SourceCommit,
- Platform: platformKey(), BinarySHA256: SHA256Bytes(value),
- ReleaseChecksumsSHA256: ChecksumsSHA256, Integrations: integrations,
- }
- for _, path := range []controllerPath{binaryPath, manifestPath} {
- if err := path.Validate(); err != nil {
- return runtimeManifest{}, err
- }
- }
- // This exact provenance path is Boatstack-owned. A verified installer is the
- // repair surface for an interrupted or corrupted cache population, so it may
- // atomically replace the cached bytes after the symlink checks above.
- if err := atomicWriteMode(binaryPath.path, value, 0o755); err != nil {
- return runtimeManifest{}, err
- }
- encoded, err := MarshalJSON(manifest)
- if err != nil {
- return runtimeManifest{}, err
- }
- if err := atomicWriteMode(manifestPath.path, encoded, 0o644); err != nil {
- return runtimeManifest{}, err
- }
- // Post-write integrity: the bytes that landed in the version-labeled slot must
- // be exactly what the manifest attests. A mismatch means the atomic replace
- // raced or the slot was tampered mid-install; remove the slot rather than leave
- // a mislabeled runtime that would pass the checksum gate but drift at hydration.
- writtenHash, err := SHA256File(binaryPath.path)
- if err != nil {
- return runtimeManifest{}, err
- }
- if writtenHash != manifest.BinarySHA256 {
- _ = os.Remove(binaryPath.path)
- _ = os.Remove(manifestPath.path)
- return runtimeManifest{}, fmt.Errorf("installed runtime failed post-write verification: %s does not match its manifest checksum", binaryPath.path)
- }
- return manifest, nil
-}
-
-func loadSharedRuntime(repo string) (runtimeManifest, string, error) {
- binaryOwned, manifestOwned, err := sharedRuntimeOwnedPaths(repo, Version, SourceCommit)
- if err != nil {
- return runtimeManifest{}, "", err
- }
- for _, path := range []controllerPath{binaryOwned, manifestOwned} {
- if err := path.Validate(); err != nil {
- return runtimeManifest{}, "", err
- }
- }
- binaryPath, manifestPath := binaryOwned.path, manifestOwned.path
- value, err := os.ReadFile(manifestPath)
- if err != nil {
- return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime is missing; run the verified installer once from any checkout in this Git clone: %w", err)
- }
- var manifest runtimeManifest
- if err := DecodeJSON("load shared Boatstack runtime manifest", manifestPath, value, &manifest); err != nil {
- return runtimeManifest{}, "", err
- }
- if manifest.SchemaVersion != 1 || manifest.BoatstackVersion != Version ||
- manifest.SourceCommit != SourceCommit || manifest.Platform != platformKey() {
- return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime provenance does not match this worktree; re-run the verified installer from any checkout in this Git clone to repopulate it")
- }
- if info, err := os.Lstat(binaryPath); err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime is missing or unsafe: %s", binaryPath)
- }
- hash, err := SHA256File(binaryPath)
- if err != nil {
- return runtimeManifest{}, "", err
- }
- if hash != manifest.BinarySHA256 {
- return runtimeManifest{}, "", fmt.Errorf("shared Boatstack runtime checksum does not match its manifest; the cached runtime is corrupted — re-run the verified installer to repair it")
- }
- return manifest, binaryPath, nil
-}
-
-func verifyGeneratedRuntime(repo string) error {
- value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "generated.lock.json"))
- if err != nil {
- return fmt.Errorf("missing generated Boatstack runtime provenance: %w", err)
- }
- var lock generatedRuntimeLock
- lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json")
- if err := DecodeJSON("verify generated Boatstack runtime provenance", lockPath, value, &lock); err != nil {
- return err
- }
- if lock.BoatstackVersion != Version || lock.Runtime.SourceCommit != SourceCommit {
- return fmt.Errorf("this worktree expects Boatstack %s (%s), but the runtime is %s (%s); update or rebase its Boatstack infrastructure",
- lock.BoatstackVersion, lock.Runtime.SourceCommit, Version, SourceCommit)
- }
- return nil
-}
-
-func acquireHydrationLock(repo string) (func(), error) {
- lockPath := filepath.Join(repo, ".product-loop", "bin", ".hydrate.lock")
- if err := rejectSymlinkComponents(repo, lockPath); err != nil {
- return nil, err
- }
- if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
- return nil, err
- }
- for attempt := 0; attempt < 100; attempt++ {
- file, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if err == nil {
- if _, writeErr := fmt.Fprintf(file, "%d\n", os.Getpid()); writeErr != nil {
- file.Close()
- os.Remove(lockPath)
- return nil, writeErr
- }
- if closeErr := file.Close(); closeErr != nil {
- os.Remove(lockPath)
- return nil, closeErr
- }
- return func() { _ = os.Remove(lockPath) }, nil
- }
- if !os.IsExist(err) {
- return nil, err
- }
- // Another process may have completed hydration while this process
- // waited. Avoid acquiring and rewriting state that is already valid.
- if verifyErr := verifyLocalRuntime(repo); verifyErr == nil {
- return func() {}, nil
- }
- if info, statErr := os.Lstat(lockPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second {
- _ = os.Remove(lockPath)
- continue
- }
- time.Sleep(25 * time.Millisecond)
- }
- return nil, fmt.Errorf("timed out waiting for another Boatstack worktree activation; retry the original command")
-}
-
-func HydrateWorktree(repoPath string) error {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return err
- }
- if err := verifyGeneratedRuntime(repo); err != nil {
- return err
- }
- manifest, sharedBinary, err := loadSharedRuntime(repo)
- if err != nil {
- return err
- }
- // Guards call this operation for every agent event. Once the local runtime is
- // current and intact, verification is enough; avoid rewriting ignored state
- // on every safe command. A missing, stale, or tampered local runtime falls
- // through to verified shared-cache hydration.
- if err := verifyLocalRuntime(repo); err == nil {
- return nil
- }
- release, err := acquireHydrationLock(repo)
- if err != nil {
- return err
- }
- defer release()
- if err := verifyLocalRuntime(repo); err == nil {
- return nil
- }
- value, err := os.ReadFile(sharedBinary)
- if err != nil {
- return err
- }
- localBinary := filepath.Join(repo, ".product-loop", "bin", helperName())
- if err := rejectSymlinkComponents(repo, localBinary); err != nil {
- return err
- }
- if err := atomicWriteMode(localBinary, value, 0o755); err != nil {
- return err
- }
- if err := writeInstallLock(repo, localBinary, manifest.BinarySHA256, manifest.Integrations); err != nil {
- return err
- }
- return verifyLocalRuntime(repo)
-}
-
-// RunHydrateRuntime populates the shared runtime slot (and this worktree's
-// ignored bin/) from the RUNNING binary, without touching any committed
-// generated files and without requiring a dedicated update branch. It is the
-// slot-only primitive the safety guard invokes — via the verified installer's
-// hydrate mode — when a version-pinned slot is absent on a teammate's clone or
-// after a version bump. It is the cross-clone cousin of HydrateWorktree: that
-// copies an existing slot into a worktree; this creates the slot itself.
-//
-// Because the guard downloads and runs the exact pinned release before calling
-// this, the running binary equals the repo's committed pin by construction. The
-// verifyGeneratedRuntime gate refuses to populate a slot for any other version,
-// so hydration can never write a mislabeled runtime (the original incident's
-// invariant), and installCommandRuntime's own post-write verify+rollback is the
-// backstop. The operation is idempotent and safe under concurrent first use.
-func RunHydrateRuntime(repoPath string) error {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return err
- }
- if err := verifyGeneratedRuntime(repo); err != nil {
- return fmt.Errorf("refusing to hydrate a runtime that does not match this worktree's pin: %w", err)
- }
- source, err := os.Executable()
- if err != nil {
- return err
- }
- config, _, err := LoadConfig(WorkspaceFor(repo).SourceConfigPath())
- if err != nil {
- return fmt.Errorf("load project configuration for runtime hydration: %w", err)
- }
- if _, err := installCommandRuntime(source, repo, config.Integrations); err != nil {
- return fmt.Errorf("populate the Boatstack command runtime: %w", err)
- }
- return HydrateWorktree(repo)
-}
-
-func verifyLocalRuntime(repo string) error {
- lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- if err := rejectSymlinkComponents(repo, lockPath); err != nil {
- return err
- }
- value, err := os.ReadFile(lockPath)
- if err != nil {
- return fmt.Errorf("missing local install lock: %w", err)
- }
- var lock installLock
- if err := DecodeJSON("verify local Boatstack runtime", lockPath, value, &lock); err != nil {
- return err
- }
- if lock.BoatstackVersion != Version || lock.SourceCommit != SourceCommit {
- return fmt.Errorf("helper version drift: installed %s (%s), expected %s (%s)", lock.BoatstackVersion, lock.SourceCommit, Version, SourceCommit)
- }
- if lock.Platform != runtime.GOOS+"/"+runtime.GOARCH {
- return fmt.Errorf("helper platform drift: installed %s, expected %s/%s", lock.Platform, runtime.GOOS, runtime.GOARCH)
- }
- binaryPath, err := resolveRepositoryRelativePath(repo, lock.BinaryPath)
- if err != nil {
- return fmt.Errorf("invalid Boatstack helper path in install lock: %w", err)
- }
- if err := rejectSymlinkComponents(repo, binaryPath); err != nil {
- return err
- }
- if err := checkNonEmptyFile(binaryPath, "Boatstack helper"); err != nil {
- return err
- }
- hash, err := SHA256File(binaryPath)
- if err != nil {
- return err
- }
- if hash != lock.BinarySHA256 {
- return fmt.Errorf("Boatstack helper checksum does not match the install lock")
- }
- return nil
-}
diff --git a/boatstack/runtime_cache_test.go b/boatstack/runtime_cache_test.go
deleted file mode 100644
index 0c84286..0000000
--- a/boatstack/runtime_cache_test.go
+++ /dev/null
@@ -1,263 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-func runtimeTestRepo(t *testing.T) string {
- t.Helper()
- repo := planningRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true}); err != nil {
- t.Fatal(err)
- }
- engageHookFixture(t, repo)
- return repo
-}
-
-func TestHydrateWorktreeRestoresIgnoredRuntime(t *testing.T) {
- repo := runtimeTestRepo(t)
- localDirectory := filepath.Join(repo, ".product-loop", "bin")
- if err := os.RemoveAll(localDirectory); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestHydrationPrecedesEveryHostContractDecision(t *testing.T) {
- repo := runtimeTestRepo(t)
- inputs := map[string][]byte{
- "cursor": []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`),
- "claude": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`),
- "codex": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`),
- }
- for _, host := range []string{"cursor", "claude", "codex"} {
- t.Run(host, func(t *testing.T) {
- if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err != nil {
- t.Fatal(err)
- }
- if _, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: inputs[host]}); denied {
- t.Fatalf("%s denied its safe first hydrated event", host)
- }
- if err := verifyLocalRuntime(repo); err != nil {
- t.Fatalf("%s did not leave a verified local runtime: %v", host, err)
- }
- })
- }
-}
-
-func TestHydrateWorktreeIsSafeUnderConcurrentFirstUse(t *testing.T) {
- repo := runtimeTestRepo(t)
- if err := os.RemoveAll(filepath.Join(repo, ".product-loop", "bin")); err != nil {
- t.Fatal(err)
- }
- const workers = 8
- errors := make(chan error, workers)
- var group sync.WaitGroup
- for index := 0; index < workers; index++ {
- group.Add(1)
- go func() {
- defer group.Done()
- errors <- HydrateWorktree(repo)
- }()
- }
- group.Wait()
- close(errors)
- for err := range errors {
- if err != nil {
- t.Fatal(err)
- }
- }
- if err := Doctor(repo); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestHydrateWorktreeRecoversInterruptedActivationLock(t *testing.T) {
- repo := runtimeTestRepo(t)
- localDirectory := filepath.Join(repo, ".product-loop", "bin")
- if err := os.RemoveAll(localDirectory); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(localDirectory, 0o755); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(localDirectory, ".hydrate.lock")
- if err := os.WriteFile(lockPath, []byte("interrupted\n"), 0o600); err != nil {
- t.Fatal(err)
- }
- stale := time.Now().Add(-time.Minute)
- if err := os.Chtimes(lockPath, stale, stale); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Lstat(lockPath); !os.IsNotExist(err) {
- t.Fatalf("stale activation lock was not removed: %v", err)
- }
-}
-
-func TestSharedRuntimeTamperingAndWorktreeVersionDriftFailClosed(t *testing.T) {
- repo := runtimeTestRepo(t)
- binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(binaryPath, []byte("tampered"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "checksum") {
- t.Fatalf("expected shared checksum failure, got %v", err)
- }
- if _, err := installSharedRuntime(os.Args[0], repo, nil); err != nil {
- t.Fatalf("verified installer could not repair the corrupt cache: %v", err)
- }
- if err := HydrateWorktree(repo); err != nil {
- t.Fatalf("repaired shared cache did not hydrate: %v", err)
- }
-
- repo = runtimeTestRepo(t)
- lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json")
- value, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- var lock map[string]any
- if err := json.Unmarshal(value, &lock); err != nil {
- t.Fatal(err)
- }
- lock["boatstack_version"] = "v99.0.0"
- value, err = MarshalJSON(lock)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(lockPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "expects Boatstack") {
- t.Fatalf("expected generated-runtime drift failure, got %v", err)
- }
-}
-
-func TestLocalRuntimePlatformDriftFailsClosed(t *testing.T) {
- repo := runtimeTestRepo(t)
- lockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- value, err := os.ReadFile(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- var lock map[string]any
- if err := json.Unmarshal(value, &lock); err != nil {
- t.Fatal(err)
- }
- lock["platform"] = "different/architecture"
- value, err = MarshalJSON(lock)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(lockPath, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := verifyLocalRuntime(repo); err == nil || !strings.Contains(err.Error(), "platform drift") {
- t.Fatalf("expected local platform drift failure, got %v", err)
- }
- if err := HydrateWorktree(repo); err != nil {
- t.Fatalf("shared cache did not repair platform-drifted local state: %v", err)
- }
-}
-
-func TestSharedRuntimeRejectsSymlinkedBinary(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink creation needs elevated Windows permissions")
- }
- repo := runtimeTestRepo(t)
- binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit)
- if err != nil {
- t.Fatal(err)
- }
- target := filepath.Join(t.TempDir(), "replacement")
- if err := os.WriteFile(target, []byte("replacement"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.Remove(binaryPath); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(target, binaryPath); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "symlinked path") {
- t.Fatalf("expected symlink rejection, got %v", err)
- }
-}
-
-func TestHydrateWorktreeRejectsSymlinkedRuntimeDirectories(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink creation needs elevated Windows permissions")
- }
- repo := runtimeTestRepo(t)
- localDirectory := filepath.Join(repo, ".product-loop", "bin")
- if err := os.RemoveAll(localDirectory); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(t.TempDir(), localDirectory); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "symlinked path") {
- t.Fatalf("expected local runtime-directory symlink rejection, got %v", err)
- }
-
- repo = runtimeTestRepo(t)
- common, err := gitCommonDir(repo)
- if err != nil {
- t.Fatal(err)
- }
- cacheRoot := filepath.Join(common, "boatstack")
- if err := os.RemoveAll(cacheRoot); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(t.TempDir(), cacheRoot); err != nil {
- t.Fatal(err)
- }
- if err := HydrateWorktree(repo); err == nil || !strings.Contains(err.Error(), "symlinked path") {
- t.Fatalf("expected shared runtime-directory symlink rejection, got %v", err)
- }
-}
-
-func TestSharedRuntimePathsKeepVersionsAndSourcesSeparate(t *testing.T) {
- repo := planningRepo(t)
- current, _, err := sharedRuntimePaths(repo, "v0.6.0", "current-source")
- if err != nil {
- t.Fatal(err)
- }
- olderVersion, _, err := sharedRuntimePaths(repo, "v0.5.0", "older-source")
- if err != nil {
- t.Fatal(err)
- }
- if current == olderVersion {
- t.Fatal("versioned worktrees must select separate shared runtimes")
- }
- if !strings.Contains(current, filepath.Join("v0.6.0", "current-source", platformKey())) {
- t.Fatalf("current runtime path lacks provenance: %s", current)
- }
- if !strings.Contains(olderVersion, filepath.Join("v0.5.0", "older-source", platformKey())) {
- t.Fatalf("older runtime path lacks provenance: %s", olderVersion)
- }
-}
diff --git a/boatstack/runtime_provenance_test.go b/boatstack/runtime_provenance_test.go
deleted file mode 100644
index 3cf3d21..0000000
--- a/boatstack/runtime_provenance_test.go
+++ /dev/null
@@ -1,257 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// These tests pin the two provenance boundaries introduced to close the
-// "cross-origin identity/checksum split" failure mode: the update hand-off
-// boundary (RunUpdate) and the install write boundary (RunInit). They override
-// the stubbable package vars readBinaryIdentity/reexecProcess so no real
-// multi-version helper binaries are required. Globals are mutated, so none of
-// these run in parallel.
-
-// stubIdentity replaces readBinaryIdentity for the duration of a test and
-// restores it afterwards. The returned identity is independent of the path so a
-// single stub covers both the update and install boundaries.
-func stubIdentity(t *testing.T, version, sourceCommit string) {
- t.Helper()
- original := readBinaryIdentity
- readBinaryIdentity = func(string) (string, string, error) { return version, sourceCommit, nil }
- t.Cleanup(func() { readBinaryIdentity = original })
-}
-
-// captureReexec replaces reexecProcess with a recorder that never replaces the
-// test process. It returns pointers the caller can inspect after the exercised
-// call. The recorder returns nil so RunUpdate treats the hand-off as complete.
-func captureReexec(t *testing.T, called *bool, gotPath *string, gotArgs *[]string) {
- t.Helper()
- original := reexecProcess
- reexecProcess = func(path string, args []string, _ []string) error {
- *called = true
- *gotPath = path
- *gotArgs = args
- return nil
- }
- t.Cleanup(func() { reexecProcess = original })
-}
-
-func candidateBinary(t *testing.T) string {
- t.Helper()
- path := filepath.Join(t.TempDir(), "boatstack-helper-candidate")
- if err := os.WriteFile(path, []byte("candidate bytes"), 0o755); err != nil {
- t.Fatal(err)
- }
- return path
-}
-
-// TestRunUpdateHandsOffAcrossVersionInsteadOfInProcessInstall proves that a
-// -binary self-reporting a different identity is re-executed rather than
-// installed in-process by the running (old) helper. This is the exact hand-off
-// that lets the candidate stamp its own version, closing the incident's root.
-func TestRunUpdateHandsOffAcrossVersionInsteadOfInProcessInstall(t *testing.T) {
- repo := runtimeTestRepo(t)
- candidate := candidateBinary(t)
- stubIdentity(t, "v9.9.9-candidate", "candidate-commit")
-
- var called bool
- var gotPath string
- var gotArgs []string
- captureReexec(t, &called, &gotPath, &gotArgs)
-
- if err := RunUpdate(InitOptions{Repo: repo, BinaryPath: candidate, Yes: true, Output: &bytes.Buffer{}}); err != nil {
- t.Fatalf("cross-version update should defer to the candidate, got error: %v", err)
- }
- if !called {
- t.Fatal("cross-version update did not re-exec the candidate binary")
- }
- absoluteCandidate, err := filepath.Abs(candidate)
- if err != nil {
- t.Fatal(err)
- }
- if gotPath != absoluteCandidate {
- t.Fatalf("re-exec target = %q, want the candidate %q", gotPath, absoluteCandidate)
- }
- joined := strings.Join(gotArgs, " ")
- if !strings.Contains(joined, "update") || !strings.Contains(joined, "-binary "+absoluteCandidate) {
- t.Fatalf("re-exec args did not re-issue update against the candidate: %v", gotArgs)
- }
-}
-
-// TestRunUpdateSameVersionDoesNotHandOff proves the hand-off only fires on an
-// identity mismatch: a -binary self-reporting the running identity proceeds
-// in-process (no re-exec), so ordinary same-version updates are unchanged.
-func TestRunUpdateSameVersionDoesNotHandOff(t *testing.T) {
- repo := runtimeTestRepo(t)
- stubIdentity(t, Version, SourceCommit)
-
- var called bool
- var gotPath string
- var gotArgs []string
- captureReexec(t, &called, &gotPath, &gotArgs)
-
- // os.Executable() is the running test binary; a matching identity must be
- // handled in-process. The provenance guard decides the hand-off before the
- // normal update path runs (which independently rejects the test binary's
- // non-semver "dev" version), so the property under test is precisely that no
- // re-exec occurs — the downstream update outcome is irrelevant here.
- self, err := os.Executable()
- if err != nil {
- t.Fatal(err)
- }
- _ = RunUpdate(InitOptions{Repo: repo, BinaryPath: self, Yes: true, Output: &bytes.Buffer{}})
- if called {
- t.Fatal("same-version update should not re-exec")
- }
-}
-
-// TestRunInitRefusesVersionMismatchedBinary proves the write boundary refuses to
-// stamp the running process's version onto a foreign binary and writes nothing.
-func TestRunInitRefusesVersionMismatchedBinary(t *testing.T) {
- repo := planningRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- candidate := candidateBinary(t)
- stubIdentity(t, "v9.9.9-candidate", "candidate-commit")
-
- err := RunInit(InitOptions{Repo: repo, BinaryPath: candidate, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}})
- if err == nil {
- t.Fatal("install of a version-mismatched -binary should be refused")
- }
- if !strings.Contains(err.Error(), "version-mismatched") {
- t.Fatalf("refusal did not name the provenance mismatch: %v", err)
- }
- if _, statErr := os.Stat(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")); !os.IsNotExist(statErr) {
- t.Fatalf("refused install must leave no install lock behind, stat err = %v", statErr)
- }
-}
-
-// TestRunInitAdoptsMatchingBinaryIdentity proves an explicit -binary whose
-// self-report matches the running identity installs and records that identity.
-func TestRunInitAdoptsMatchingBinaryIdentity(t *testing.T) {
- repo := planningRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- self, err := os.Executable()
- if err != nil {
- t.Fatal(err)
- }
- stubIdentity(t, Version, SourceCommit)
-
- if err := RunInit(InitOptions{Repo: repo, BinaryPath: self, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}); err != nil {
- t.Fatalf("matching -binary install failed: %v", err)
- }
- lock := readInstallLock(t, repo)
- if lock.BoatstackVersion != Version || lock.SourceCommit != SourceCommit {
- t.Fatalf("install lock recorded %s (%s), want the verified identity %s (%s)", lock.BoatstackVersion, lock.SourceCommit, Version, SourceCommit)
- }
-}
-
-// TestSelfInstallBypassesProvenanceGuard proves a normal install (no -binary)
-// is untouched by the guard: readBinaryIdentity is set to a poison stub that
-// would fail any check, yet the self-install succeeds because the guard only
-// runs for an explicit -binary.
-func TestSelfInstallBypassesProvenanceGuard(t *testing.T) {
- repo := planningRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- original := readBinaryIdentity
- readBinaryIdentity = func(string) (string, string, error) { return "poison", "poison", nil }
- t.Cleanup(func() { readBinaryIdentity = original })
-
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}); err != nil {
- t.Fatalf("self-install should not consult the provenance guard: %v", err)
- }
- lock := readInstallLock(t, repo)
- if lock.BoatstackVersion != Version {
- t.Fatalf("self-install recorded %s, want the running version %s", lock.BoatstackVersion, Version)
- }
-}
-
-// TestReexecUpdatePreservesUpdateFlags proves the hand-off re-issues the update
-// against the candidate itself and forwards the operator's flags verbatim, so a
-// re-exec is a faithful continuation and terminates in a single hop.
-func TestReexecUpdatePreservesUpdateFlags(t *testing.T) {
- candidate := candidateBinary(t)
- var called bool
- var gotPath string
- var gotArgs []string
- captureReexec(t, &called, &gotPath, &gotArgs)
-
- if err := reexecUpdate(candidate, InitOptions{Repo: "some/repo", Yes: true, Repair: true, AllowDowngrade: true}); err != nil {
- t.Fatal(err)
- }
- if !called {
- t.Fatal("reexecUpdate did not invoke the process replacement")
- }
- absoluteCandidate, err := filepath.Abs(candidate)
- if err != nil {
- t.Fatal(err)
- }
- joined := strings.Join(gotArgs, " ")
- for _, want := range []string{"update", "-repo some/repo", "-binary " + absoluteCandidate, "-yes", "-repair", "-allow-downgrade"} {
- if !strings.Contains(joined, want) {
- t.Fatalf("re-exec args missing %q: %v", want, gotArgs)
- }
- }
- if gotArgs[0] != absoluteCandidate {
- t.Fatalf("argv[0] = %q, want the candidate %q so it proceeds in-process", gotArgs[0], absoluteCandidate)
- }
-}
-
-func TestParseVersionOutput(t *testing.T) {
- cases := []struct {
- name string
- input string
- wantVer string
- wantCommit string
- wantErr bool
- }{
- {name: "canonical", input: "Boatstack v0.7.57 (abc1234)\n", wantVer: "v0.7.57", wantCommit: "abc1234"},
- {name: "surrounding whitespace", input: " Boatstack v1.2.3 (deadbeef) ", wantVer: "v1.2.3", wantCommit: "deadbeef"},
- {name: "commit with spaces preserved by last-paren split", input: "Boatstack dev (unknown)", wantVer: "dev", wantCommit: "unknown"},
- {name: "missing prefix", input: "v0.7.57 (abc1234)", wantErr: true},
- {name: "missing parens", input: "Boatstack v0.7.57", wantErr: true},
- {name: "empty version", input: "Boatstack (abc1234)", wantErr: true},
- {name: "empty commit", input: "Boatstack v0.7.57 ()", wantErr: true},
- {name: "garbage", input: "not a version line", wantErr: true},
- }
- for _, testCase := range cases {
- t.Run(testCase.name, func(t *testing.T) {
- version, commit, err := parseVersionOutput(testCase.input)
- if testCase.wantErr {
- if err == nil {
- t.Fatalf("expected an error for %q, got (%q, %q)", testCase.input, version, commit)
- }
- return
- }
- if err != nil {
- t.Fatalf("unexpected error for %q: %v", testCase.input, err)
- }
- if version != testCase.wantVer || commit != testCase.wantCommit {
- t.Fatalf("parseVersionOutput(%q) = (%q, %q), want (%q, %q)", testCase.input, version, commit, testCase.wantVer, testCase.wantCommit)
- }
- })
- }
-}
-
-func readInstallLock(t *testing.T, repo string) installLock {
- t.Helper()
- value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"))
- if err != nil {
- t.Fatal(err)
- }
- var lock installLock
- if err := json.Unmarshal(value, &lock); err != nil {
- t.Fatal(err)
- }
- return lock
-}
diff --git a/boatstack/safety.go b/boatstack/safety.go
deleted file mode 100644
index 6efc5ed..0000000
--- a/boatstack/safety.go
+++ /dev/null
@@ -1,1780 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "errors"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
-)
-
-// SafetyFinding is intentionally small and secret-free. The guard reports the
-// class and a stable explanation, never the full command or tool arguments.
-type SafetyFinding struct {
- Category string `json:"category"`
- Reason string `json:"reason"`
- Source string `json:"source,omitempty"`
- BlockingFeature string `json:"blocking_feature,omitempty"`
- BlockingSlice string `json:"blocking_slice,omitempty"`
- BranchRelation string `json:"branch_relation,omitempty"`
- NextOperation string `json:"next_operation,omitempty"`
- ParentDelivery string `json:"parent_delivery,omitempty"`
- WorkflowStage string `json:"workflow_stage,omitempty"`
- AttemptedPath string `json:"attempted_path,omitempty"`
- OperationID string `json:"operation_id,omitempty"`
- OperationState string `json:"operation_state,omitempty"`
- // PolicySource explains a policy-derived denial: "plan-escalated" when a
- // plan-approved visual decision lifts suggest to require semantics.
- PolicySource string `json:"policy_source,omitempty"`
- AttemptNumber int `json:"attempt_number,omitempty"`
- ReconciliationRequired bool `json:"reconciliation_required,omitempty"`
- // RepeatCount is how many times this same denial (category at stage) has
- // fired consecutively in this worktree, from the guard's denial ledger.
- // At the escalation threshold the rendering lifts its solution-set cap and
- // prescribes a fresh diagnostic — more information, never more severity.
- // control-law: repeated-denials-escalate-to-solutions
- RepeatCount int `json:"repeat_count,omitempty"`
-}
-
-type SafetyReport struct {
- Status string `json:"status"`
- Findings []SafetyFinding `json:"findings"`
-}
-
-type SafetyHookOptions struct {
- Host string
- Repo string
- Input []byte
-}
-
-// ownedCommandAdmission is the typed exception to lexical managed-path
-// protection. It never makes a command safe by basename or path text alone: an
-// admitted command must use the exact helper from the repository's verified
-// WorkspaceContext, stay inside that same controller root, and be legal at the
-// resolved workflow position.
-// control-law: guard-never-denies-an-owned-transition
-type ownedCommandAdmission struct {
- Allowed bool
- ReadOnly bool
- Finding *SafetyFinding
-}
-
-type hookDecodeError struct {
- code string
-}
-
-func (err hookDecodeError) Error() string { return err.code }
-
-func malformedHookInput(code string) error {
- return hookDecodeError{code: code}
-}
-
-// readOnlyStage recognizes one stage of a pipeline that is read-only by EFFECT: a
-// reader (rg/grep/git-read/cat/…), a read-only Boatstack status helper, or a pure
-// stdin→stdout inspection filter (wc/awk/sort/…). Effect-Typed Allowlist: a
-// pipeline is admitted iff EVERY stage is effect-read-only, so ordinary inspection
-// idioms — recovery-status | jq, git diff | wc -l, … | sort | uniq -c — compose
-// freely. Effect-CHANGING syntax (redirection > <, command substitution $()) is
-// still banned in isPureReadOnlyCommand, so no filter can be turned into a writer.
-var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|wc|awk|sort|uniq|cut|tr|jq|column|nl|comm|rev|fold|find\s+[^\n]*-(?:print|ls)|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+(?:recovery-status|mutation-status|operation-status|delivery-status|next-status|workspace-status|repair-status|check-plan|check-source-plan|check-safety|diagnose-hook|authority-context|doctor|version)\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+insight\s+(?:check|list|show|frontier|evaluate)\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+flow\s+(?:bootstrap|check|next|tasks|frontier|watch|report)\b)`)
-
-// Constitutional/Optimization split. These destruction rules are CONSTITUTIONAL:
-// they define the real boundary (destroying a live resource) and are never traded
-// for convenience — no project config knob disables them; config may only ADD
-// scope (HighRiskPaths). The executor-gating around them (the scanSQL argument to
-// classifySafetyText; the executed-vs-data file distinction; the artifact exemption
-// on committed diffs) is the OPTIMIZATION surface: it narrows WHEN a rule is
-// observed to cut false positives, but it may never disable the boundary — when the
-// executor is live, a constitutional rule must still fire. That floor is enforced by
-// TestExecutorGatingNeverDisablesTheBoundary.
-//
-// irreversiblePatterns classify destruction by text. Rules whose regex names its
-// own EXECUTOR (rm, git, terraform, supabase db reset, …) are self-executing:
-// matching the text is sound because the text IS the command. Rules marked
-// sqlEffect match bare SQL grammar (DROP TABLE, TRUNCATE) that is inert as data
-// and destructive only when a live database client runs it — so they are applied
-// only in an executor context (see classifySafetyText's scanSQL argument). This
-// is what stops `git add staging.sql` or a committed migration from being read as
-// a live drop while `psql -c "DROP TABLE"` is still denied.
-var irreversiblePatterns = []struct {
- category string
- reason string
- pattern *regexp.Regexp
- sqlEffect bool
-}{
- {"database-destruction", "database or schema destruction is operator-only", regexp.MustCompile(`(?is)\bdrop\s+(?:database|schema|table)\b|\balter\s+table\b[^;\n]*\bdrop\s+(?:column|constraint)\b|\btruncate(?:\s+table)?\b|\bdrop\s+schema\b[^;\n]*\bcascade\b`), true},
- {"database-reset", "database reset, flush, or destructive downgrade is operator-only", regexp.MustCompile(`(?i)(?:--reset-public\b|\b(?:supabase\s+db\s+reset|prisma\s+migrate\s+reset|rails\s+db:(?:drop|reset)|django-admin\s+flush|manage\.py\s+flush|alembic\s+downgrade\s+base|pg_restore\b[^\n]*\s--clean\b))`), false},
- {"external-resource-destruction", "external resource destruction is operator-only", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+delete\b`), false},
- {"external-lifecycle-weakening", "weakening external resource lifecycle protection is operator-only", regexp.MustCompile(`(?i)\bsupabase[\s"',()]+branches?[\s"',()]+update\b[^\n;&|]*(?:--persistent(?:=|[\s"',()]+)false\b|--no-persistent\b)`), false},
- {"external-public-exposure", "public or unauthenticated service exposure is operator-only", regexp.MustCompile(`(?i)(?:\bgcloud\s+run\s+(?:deploy|services\s+update)\b[^\n;&|]*--allow-unauthenticated\b|\bgcloud\s+(?:projects|run\s+services)\s+(?:add-iam-policy-binding|set-iam-policy)\b[^\n;&|]*(?:allUsers|roles/run\.invoker)|\bkubectl\s+(?:create|expose|patch|apply)\b[^\n;&|]*(?:type[=:](?:LoadBalancer|NodePort)|0\.0\.0\.0/0)|\baws\s+[^\n;&|]*(?:authorize-security-group-ingress|put-public-access-block)\b[^\n;&|]*(?:0\.0\.0\.0/0|block-public-acls\s+false))`), false},
- {"filesystem-destruction", "recursive deletion of a broad or protected path is denied", regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\s+(?:["']?(?:/|~|\$home|\$HOME|\.|\.\.)["']?\s*(?:;|&&|\|\||$)|[^\s;]*\*[^\s;]*)`), false},
- {"git-history-destruction", "destructive Git cleanup or history replacement is denied", regexp.MustCompile(`(?i)\bgit\s+(?:reset\s+--hard\b|clean\s+-[^\s]*(?:f[^\s]*d|d[^\s]*f|x)[^\s]*|push\b[^\n]*(?:--force(?:-with-lease)?|-f\b))`), false},
- {"infrastructure-destruction", "cloud or infrastructure destruction is operator-only", regexp.MustCompile(`(?i)\b(?:terraform|tofu|pulumi)\s+destroy\b|\bkubectl\s+delete\s+(?:namespace|cluster|persistentvolume|persistentvolumeclaim|pvc)\b|\bdocker\s+volume\s+(?:rm|prune)\b|\bgcloud\s+(?:projects|sql\s+instances|compute\s+(?:instances|disks))\s+delete\b|\baws\s+[^\n]*(?:delete-cluster|delete-db-instance|terminate-instances|delete-volume|delete-bucket)\b`), false},
- {"recovery-destruction", "backup deletion or recovery disablement is operator-only", regexp.MustCompile(`(?i)\b(?:delete|remove|disable)\b[^\n;]*(?:backup|snapshot|point-in-time|pitr|recovery)\b`), false},
-}
-
-// liveSQLClientPattern matches the executable of a command that runs SQL against a
-// live database connection. Reading, committing, or diffing a file that CONTAINS
-// SQL is not such a command; only these executors actually apply DDL/DML.
-var liveSQLClientPattern = regexp.MustCompile(`(?i)^(?:psql|mysql|mariadb|mongo|mongosh|cockroach|sqlcmd|usql|clickhouse-client)$`)
-
-// fileRunnerPattern matches an executable that EXECUTES a file argument, so that
-// file's contents are a live capability — unlike git/cp/cat, which treat a named
-// file as data. SQL clients are added at the use site (they run a file via -f/<).
-var fileRunnerPattern = regexp.MustCompile(`(?i)^(?:python[0-9.]*|sh|bash|zsh|ksh|dash|ruby|node|deno|bun|perl|php|pwsh|powershell)$`)
-
-var operationalPathPattern = regexp.MustCompile(`(?i)(?:^|/)(?:scripts?|migrations?|schema|database|db|deploy|infra|ops|terraform|k8s)(?:/|$)|\.(?:sql|ps1|sh|bash|py)$`)
-
-// Match SQL mutation grammar rather than isolated English or command tokens.
-// Requiring DELETE FROM or UPDATE SET keeps executable SQL visible
-// without treating names such as check-update or API method labels as queries.
-var mutationStatementPattern = regexp.MustCompile(`(?is)\b(?:delete\s+from\s+(?:[a-z_][a-z0-9_$.-]*|"[^"]+")|update\s+(?:[a-z_][a-z0-9_$.-]*|"[^"]+")\s+set\b)[^;]*`)
-var directPublicationPattern = regexp.MustCompile(`(?i)(?:\bgit\b[^\n;&|]*\bpush\b|\bgh\s+pr\s+(?:create|edit|ready|merge)\b|\bgh\s+api\b[^\n;&|]*(?:/pulls\b|/pull-requests\b)|\bhub\s+pull-request\b|\bcurl\b[^\n;&|]*(?:api\.github\.com|/pulls\b)[^\n;&|]*(?:\s-X\s*(?:POST|PATCH)|--request\s+(?:POST|PATCH)))`)
-var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:&\s+)?['"]?\.product-loop[/\\]boatstack(?:\.ps1)?['"]?\s+publish-pr\b[^\n;&|]*$`)
-
-// approvedUpdatePublisherPattern recognizes the sanctioned Boatstack version-update
-// publisher. That command must be passed the update preview path, which lives under
-// /boatstack/updates//pr-preview.json, so the command line
-// always names a path inside the .git/boatstack/ subtree. Without this exemption the
-// deliveryStatePathPattern check below denies the publish as workflow-state-tamper,
-// even though the path is a read argument to the trusted helper, not a direct edit.
-// Like approvedPublisherPattern it is anchored end to end so no second command can be
-// chained after it, and it tolerates the platform-suffixed helper binary (for example
-// boatstack-helper_darwin_arm64) that a running update may invoke after the installed
-// helper is swapped or removed.
-var approvedUpdatePublisherPattern = regexp.MustCompile(`(?i)^\s*(?:&\s+)?['"]?\.product-loop[/\\]boatstack(?:\.ps1)?['"]?\s+publish-update-pr\b[^\n;&|]*$`)
-
-// deliveryStatePathPattern matches Boatstack's managed runtime/control state so
-// the guard denies direct model mutation of it. It covers the embedded homes
-// (boatstack/deliveries and any .git/.../boatstack subtree) and the Detached
-// Supervision external control root (boatstack/{repositories,registry.json} and
-// the version-namespaced boatstack/runtimes). Only Boatstack transitions and the
-// sanctioned publisher (approvedUpdatePublisherPattern) may name these paths.
-var deliveryStatePathPattern = regexp.MustCompile(`(?i)(?:boatstack[/\\](?:deliveries|operations|flow|repositories|runtimes|registry\.json)|\.git[/\\](?:worktrees[/\\][^/\\]+[/\\])?boatstack(?:[/\\]|$))`)
-
-// insightArtifactPathPattern protects the tracked insight inbox from raw edits.
-// The insight helper owns content mutation; ordinary Git staging remains allowed
-// so the resulting artifact can cross the review boundary as a PR.
-// control-law: confirmed-insight-becomes-reviewable-repository-diff
-var insightArtifactPathPattern = regexp.MustCompile(`(?i)(?:^|[/\\\s"'=])docs[/\\]insights(?:[/\\]|$)`)
-
-var insightGitStagingPattern = regexp.MustCompile(`(?i)^\s*git\s+(?:add|diff|status)\b`)
-var insightInPlaceMutationPattern = regexp.MustCompile(`(?i)\bsed\s+-[^\s]*i(?:\.[^\s]+)?\b`)
-var mutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create|delete|remove|move|rename|update|insert|upload|install)`)
-var planningMutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create)`)
-var externalReadOnlyToolPattern = regexp.MustCompile(`(?i)(?:^|[_-])(?:get|list|read|search|find|status|inspect|fetch|open)(?:[_-]|$)`)
-
-// featuresCommandPathPattern extracts a .product-loop/features/… operand from a
-// shell command so the first-write latch can see raw shell writes (cp, tee, >)
-// the same way ClassifyTool sees a Write tool's file_path. Mirrors the
-// deliveryStatePathPattern law for .git/boatstack: only owned channels may name
-// managed planning paths in a mutating command.
-// control-law: first-planning-write-uses-the-owned-channel
-var featuresCommandPathPattern = regexp.MustCompile(`(?i)(?:^|[\s"'=(])((?:\./)?\.product-loop[/\\]features[/\\][^\s"';&|)]+)`)
-
-// The guard's admissible-verb knowledge for the pre-activation interlock lives
-// in the three tables below so that the guard decision (controlledPhaseTransition)
-// and the solution-set enumerator (flow_solutions.go) read ONE declaration. A
-// verb admitted here is exactly a verb the enumerator may present as a legal
-// pick — the tables ARE the interlock law's computable solution set, and the
-// closure conformance sweep holds the two consumers to the same rows.
-// control-law: solution-set-derives-from-guard-declarations
-
-// readOnlyHelperVerbs never mutate workflow state and are admitted at every
-// stage.
-var readOnlyHelperVerbs = map[string]bool{
- "check-plan": true, "check-source-plan": true, "next-status": true, "delivery-status": true,
- "recovery-status": true, "repair-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true,
- "doctor": true, "version": true, "mutation-status": true, "authority-context": true,
-}
-
-// stageIndependentRecoveryVerbs mutate but self-guard, and are admitted at
-// every stage — the Coreachability invariant: the states that prescribe a
-// recovery verb must be a subset of the states that verb accepts, and the verb
-// must be reachable in-tool.
-// - repair-state is the guard-prescribed recovery for a workflow stuck at
-// INVALID_STATE because of an unregistered malformed draft. Those findings
-// carry an empty stage, so it is stage-independent. It quarantines the
-// draft; RepairState self-guards, refusing any registered, published, or
-// tracked directory.
-// - undo is the bounded actuator that reverses a Boatstack-generated managed
-// artifact by re-applying its receipt's inverse through the transactional
-// mutation boundary. It self-guards (UndoManagedMutation refuses to strand
-// delivery state; the boundary's stale-base precondition refuses to clobber
-// later work).
-// - workspace-reap and workspace-cleanup reclaim finished managed worktrees
-// and branches. They mutate only Boatstack-owned workspace bookkeeping and
-// self-guard (refusing the base branch, the current worktree, and unmerged
-// or dirty work without an explicit force). Without them the pre-activation
-// interlock would deny post-merge cleanup and force raw, denied Git.
-// - discard-delivery clears stuck or unverifiable managed delivery state (and
-// orphaned feature artifacts). It is the verb the resolver prescribes for
-// those causes, so it must be admitted wherever it is prescribed. It
-// self-guards (DiscardDelivery archives rather than deletes and refuses
-// published state without --force). Without this admission the resolver
-// could name discard-delivery while the guard denied it — a fail-closed
-// state with no reachable exit.
-var stageIndependentRecoveryVerbs = map[string]bool{
- "repair-state": true, "undo": true, "workspace-reap": true, "workspace-cleanup": true, "discard-delivery": true,
-}
-
-// stageMutationVerbs maps each pre-activation stage to the mutation verbs the
-// interlock admits there. NOT_STARTED deliberately omits record-approval —
-// there is no plan to approve yet; the first-write latch denies raw writes into
-// .product-loop/features/ before any candidate exists and prescribes
-// planning-write, and Coreachability requires the guard to admit that verb at
-// the very stage that names it.
-var stageMutationVerbs = map[string][]string{
- // No pre-activation finding carries NOT_INITIALIZED today (the interlock has
- // nothing to protect before init), but the prescription layer names init
- // there — declaring the row keeps the admission tables total over every
- // stage the solution set can emit (guard-never-prescribes-what-it-would-deny).
- "NOT_INITIALIZED": {"init"},
- "DRAFT_PLAN": {"planning-write", "record-approval", "record-autonomy", "workspace-cut"},
- "INVALID_STATE": {"planning-write", "record-approval", "record-autonomy"},
- "APPROVED": {"activate-plan", "workspace-cut"},
- "POLICY_READY": {"activate-plan", "workspace-cut"},
- "NOT_STARTED": {"planning-write"},
- "AMENDMENT_REQUIRED": {"planning-write"},
- "AMENDMENT_DRAFTED": {"planning-write", "record-approval"},
- "AMENDMENT_APPROVED": {"planning-write", "record-approval", "activate-plan"},
- "PLAN_INVALID": {"planning-write"},
-}
-
-func controlledPhaseTransition(command, stage string) bool {
- if transport := inspectPlanningWriteTransport(command); transport.Matched {
- if transport.InvalidReason != "" && transport.InvalidReason != "missing-literal-input" {
- return false
- }
- if transport.Header != "" {
- command = transport.Header
- }
- }
- fields, complete := literalCommandWords(command)
- if !complete || len(fields) < 2 {
- return false
- }
- executable := portableExecutableBase(fields[0])
- if executable != "boatstack" && executable != "boatstack-helper" {
- return false
- }
- if ownedReadOnlyHelperCommand(fields) {
- return true
- }
- if stageIndependentRecoveryVerbs[fields[1]] {
- return true
- }
- for _, verb := range stageMutationVerbs[stage] {
- if fields[1] == verb {
- return true
- }
- }
- return false
-}
-
-func commandFlagValue(words []string, name string) (string, bool) {
- value := ""
- seen := false
- for index := 2; index < len(words); index++ {
- candidate := ""
- if words[index] == name {
- if index+1 >= len(words) {
- return "", false
- }
- candidate = words[index+1]
- if strings.HasPrefix(candidate, "-") {
- return "", false
- }
- index++
- } else if strings.HasPrefix(words[index], name+"=") {
- candidate = strings.TrimPrefix(words[index], name+"=")
- } else {
- continue
- }
- if strings.TrimSpace(candidate) == "" || (seen && candidate != value) {
- return "", false
- }
- value = candidate
- seen = true
- }
- return value, true
-}
-
-func mergeCommandFeature(current, candidate string) (string, bool) {
- if candidate == "" {
- return current, true
- }
- if !featureSlugPattern.MatchString(candidate) || (current != "" && current != candidate) {
- return "", false
- }
- return candidate, true
-}
-
-func ownedCommandFeature(workspace WorkspaceContext, words []string) (string, bool) {
- explicit, flagsOK := commandFlagValue(words, "--feature")
- if !flagsOK {
- return "", false
- }
- feature, ok := mergeCommandFeature("", explicit)
- if !ok {
- return "", false
- }
- featureRoot := workspace.FeatureRoot()
- for _, word := range words[2:] {
- if !filepath.IsAbs(word) || !pathWithin(featureRoot, word) {
- continue
- }
- relative, err := filepath.Rel(featureRoot, word)
- if err != nil {
- return "", false
- }
- parts := strings.Split(filepath.ToSlash(relative), "/")
- if len(parts) == 0 {
- return "", false
- }
- feature, ok = mergeCommandFeature(feature, parts[0])
- if !ok {
- return "", false
- }
- }
- return feature, true
-}
-
-func ownedReadOnlyHelperCommand(words []string) bool {
- if len(words) < 2 {
- return false
- }
- if readOnlyHelperVerbs[words[1]] {
- return true
- }
- if len(words) < 3 {
- return false
- }
- switch words[1] {
- case "insight":
- return map[string]bool{"check": true, "list": true, "show": true, "frontier": true, "evaluate": true}[words[2]]
- case "flow":
- if !map[string]bool{"bootstrap": true, "check": true, "next": true, "tasks": true, "frontier": true, "watch": true, "report": true}[words[2]] {
- return false
- }
- for _, word := range words[3:] {
- if word == "--execute" || strings.HasPrefix(word, "--execute=") {
- return false
- }
- }
- return true
- }
- return false
-}
-
-func knownOwnedMutationVerb(verb string) bool {
- if stageIndependentRecoveryVerbs[verb] {
- return true
- }
- for _, verbs := range stageMutationVerbs {
- for _, candidate := range verbs {
- if candidate == verb {
- return true
- }
- }
- }
- for _, candidate := range []string{"record-delivery-gate", "record-change", "publish-pr", "attach-evidence", "flow"} {
- if candidate == verb {
- return true
- }
- }
- return false
-}
-
-func commandMatchesSolutionVerb(next FlowNext, verb string) bool {
- options := append([]PrescribedCommand{}, next.Alternatives...)
- if next.Prescribed != nil {
- options = append(options, *next.Prescribed)
- }
- for _, option := range options {
- if option.Program == "gh" {
- continue
- }
- if option.Verb == verb {
- return true
- }
- }
- return false
-}
-
-func ownedFlowExecuteCoordinator(words []string, feature string) bool {
- if len(words) < 4 || words[1] != "flow" || words[2] != "next" || feature == "" {
- return false
- }
- execute := false
- for index := 3; index < len(words); index++ {
- word := words[index]
- switch {
- case word == "--repo" || word == "--feature":
- if index+1 >= len(words) || strings.TrimSpace(words[index+1]) == "" {
- return false
- }
- index++
- case strings.HasPrefix(word, "--repo=") || strings.HasPrefix(word, "--feature="):
- if strings.TrimSpace(strings.SplitN(word, "=", 2)[1]) == "" {
- return false
- }
- case word == "--json" || word == "--json=true" || word == "--json=false":
- case word == "--execute" || word == "--execute=true":
- if execute {
- return false
- }
- execute = true
- default:
- return false
- }
- }
- return execute
-}
-
-func ownedBoatstackCommand(repo, command string) ownedCommandAdmission {
- if !deliveryStatePathPattern.MatchString(command) {
- return ownedCommandAdmission{}
- }
- trimmed := strings.TrimSpace(command)
- if strings.HasPrefix(trimmed, "& ") {
- trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "& "))
- }
- words, complete := literalCommandWords(trimmed)
- if !complete || len(words) < 2 {
- return ownedCommandAdmission{}
- }
- workspace, err := ResolveWorkspaceContext(repo)
- if err != nil || workspace.Mode != SupervisionDetached || !filepath.IsAbs(words[0]) {
- return ownedCommandAdmission{}
- }
- executable, err := filepath.Abs(words[0])
- if err != nil || canonicalizeExistingAncestor(executable) != canonicalizeExistingAncestor(workspace.HelperPath()) {
- return ownedCommandAdmission{}
- }
- info, err := os.Lstat(executable)
- if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return ownedCommandAdmission{}
- }
- repoArg, repoFlagOK := commandFlagValue(words, "--repo")
- if !repoFlagOK {
- return ownedCommandAdmission{}
- }
- if repoArg != "" {
- candidate := repoArg
- if !filepath.IsAbs(candidate) {
- candidate = filepath.Join(repo, filepath.FromSlash(candidate))
- }
- absolute, absErr := filepath.Abs(candidate)
- if absErr != nil || canonicalizeExistingAncestor(absolute) != canonicalizeExistingAncestor(workspace.RepoRoot) {
- return ownedCommandAdmission{}
- }
- }
- for _, word := range words[2:] {
- if !deliveryStatePathPattern.MatchString(word) {
- continue
- }
- if !filepath.IsAbs(word) || !pathWithin(workspace.controlRoot, word) {
- return ownedCommandAdmission{}
- }
- }
- if ownedReadOnlyHelperCommand(words) {
- return ownedCommandAdmission{Allowed: true, ReadOnly: true}
- }
- if stageIndependentRecoveryVerbs[words[1]] {
- return ownedCommandAdmission{Allowed: true}
- }
- feature, featureOK := ownedCommandFeature(workspace, words)
- if featureOK && feature != "" {
- status, resolveErr := ResolveNext(repo, feature)
- if resolveErr == nil {
- // flow next --execute is an exact, feature-scoped coordinator. Its
- // driver re-resolves this same oracle and executes only a prescribed,
- // AutoDerivable, explicitly allowlisted helper transition. The outer
- // coordinator therefore belongs to the owned boundary even though its
- // verb is not itself the current transition.
- // control-law: guard-never-denies-an-owned-transition
- if ownedFlowExecuteCoordinator(words, feature) {
- return ownedCommandAdmission{Allowed: true}
- }
- if controlledPhaseTransition(trimmed, status.ObservedStage) {
- return ownedCommandAdmission{Allowed: true}
- }
- if next, nextErr := nextControlFromStatus(repo, status); nextErr == nil && commandMatchesSolutionVerb(next, words[1]) {
- return ownedCommandAdmission{Allowed: true}
- }
- if knownOwnedMutationVerb(words[1]) {
- finding := SafetyFinding{
- Category: "workflow-phase-bypass", Reason: "the Boatstack transition is not owned by the current workflow stage", Source: "workflow-stage",
- BlockingFeature: feature, WorkflowStage: status.ObservedStage, NextOperation: status.NextOperation,
- }
- return ownedCommandAdmission{Finding: &finding}
- }
- }
- }
- return ownedCommandAdmission{}
-}
-
-func isPureReadOnlyCommandForRepo(repo, command string) bool {
- if isPureReadOnlyCommand(command) {
- return true
- }
- if !deliveryStatePathPattern.MatchString(command) {
- return false
- }
- admission := ownedBoatstackCommand(repo, command)
- return admission.Allowed && admission.ReadOnly
-}
-
-func controlledWorkspaceSync(repo, command string) bool {
- if strings.ContainsAny(command, "\n`><;&|") || strings.Contains(command, "$(") {
- return false
- }
- fields := strings.Fields(strings.TrimSpace(command))
- if len(fields) < 4 || fields[1] != "workspace-sync" {
- return false
- }
- executable := fields[0]
- if !filepath.IsAbs(executable) {
- executable = filepath.Join(repo, filepath.FromSlash(executable))
- }
- executable, err := filepath.Abs(executable)
- if err != nil {
- return false
- }
- launcherName := "boatstack"
- if strings.EqualFold(filepath.Ext(executable), ".ps1") {
- launcherName = "boatstack.ps1"
- }
- expected := filepath.Join(repo, ".product-loop", launcherName)
- expected, err = filepath.Abs(expected)
- if err != nil || filepath.Clean(executable) != filepath.Clean(expected) {
- return false
- }
- info, err := os.Lstat(executable)
- if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return false
- }
- seenSource := false
- for index := 2; index < len(fields); index += 2 {
- if index+1 >= len(fields) {
- return false
- }
- switch fields[index] {
- case "--source":
- seenSource = strings.TrimSpace(fields[index+1]) != ""
- case "--branch":
- if strings.TrimSpace(fields[index+1]) == "" {
- return false
- }
- case "--repo":
- candidate := fields[index+1]
- if !filepath.IsAbs(candidate) {
- candidate = filepath.Join(repo, filepath.FromSlash(candidate))
- }
- absolute, absErr := filepath.Abs(candidate)
- if absErr != nil || filepath.Clean(absolute) != filepath.Clean(repo) {
- return false
- }
- default:
- return false
- }
- }
- return seenSource
-}
-
-func attemptedRepositoryPath(repo string, input any) string {
- keys := map[string]bool{"path": true, "file_path": true, "filepath": true, "target_path": true, "destination": true}
- var visit func(any) string
- visit = func(value any) string {
- switch typed := value.(type) {
- case map[string]any:
- for key, child := range typed {
- if keys[strings.ToLower(key)] {
- if candidate, ok := child.(string); ok && strings.TrimSpace(candidate) != "" {
- path := candidate
- if !filepath.IsAbs(path) {
- path = filepath.Join(repo, filepath.FromSlash(path))
- }
- absolute, err := filepath.Abs(path)
- if err != nil {
- return ""
- }
- relative, err := filepath.Rel(repo, absolute)
- if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
- return ""
- }
- if err := rejectSymlinkComponents(repo, absolute); err != nil {
- return ""
- }
- return filepath.ToSlash(relative)
- }
- }
- }
- for _, child := range typed {
- if found := visit(child); found != "" {
- return found
- }
- }
- case []any:
- for _, child := range typed {
- if found := visit(child); found != "" {
- return found
- }
- }
- }
- return ""
- }
- return visit(input)
-}
-
-// contentInputKeys are the tool-input fields that carry a document body rather
-// than structure. They are dropped before text classification of a file-writer
-// tool call: the body is data (inert until executed), while structural fields
-// — file_path, destination, url — survive redaction, so a protected path in
-// any of them is still graded.
-var contentInputKeys = map[string]bool{
- "content": true, "contents": true, "new_string": true, "old_string": true,
- "new_str": true, "old_str": true, "patch": true, "diff": true,
- "text": true, "body": true, "data": true,
-}
-
-// redactContentFields deep-copies a decoded tool input with content-bearing
-// fields removed, at every nesting depth. The original input is never mutated.
-func redactContentFields(input any) any {
- switch value := input.(type) {
- case map[string]any:
- out := make(map[string]any, len(value))
- for key, item := range value {
- if contentInputKeys[strings.ToLower(key)] {
- continue
- }
- out[key] = redactContentFields(item)
- }
- return out
- case []any:
- out := make([]any, 0, len(value))
- for _, item := range value {
- out = append(out, redactContentFields(item))
- }
- return out
- default:
- return input
- }
-}
-
-// fileWriterTool reports whether a tool call is a file write: it names a target
-// path and its verb shape is a writer (write/edit/patch/create). Live SQL
-// executors are excluded — their arguments are executed, not stored. Only
-// file-writer calls get content redaction; everything else keeps full-input
-// text grading. control-law: written-content-is-data-not-effect
-func fileWriterTool(nameLower, attemptedPath string) bool {
- return attemptedPath != "" && planningMutationToolPattern.MatchString(nameLower) && !toolExecutesLiveSQL(nameLower)
-}
-
-// featureScopedPath reports whether a repo-relative path lands anywhere under
-// the managed planning tree. It is broader than planningMarkdownPath on
-// purpose: raw host mutation is denied at every depth, while the owned
-// planning-write command validates the exact artifact allowlist itself.
-func featureScopedPath(path string) bool {
- return strings.HasPrefix(filepath.ToSlash(path), ".product-loop/features/")
-}
-
-// featuresPathInCommand extracts the first .product-loop/features/… operand a
-// shell command names, normalized to a slash-form repo-relative path, or ""
-// when none is named. It is the ClassifyCommand analogue of a Write tool's
-// extracted file_path, feeding the same first-write latch.
-func featuresPathInCommand(command string) string {
- match := featuresCommandPathPattern.FindStringSubmatch(command)
- if match == nil {
- return ""
- }
- path := filepath.ToSlash(match[1])
- return strings.TrimPrefix(path, "./")
-}
-
-func planningMarkdownPath(path string) bool {
- if !featureScopedPath(path) {
- return false
- }
- parts := strings.Split(filepath.ToSlash(path), "/")
- return len(parts) == 4 && featureSlugPattern.MatchString(parts[2]) && planningArtifacts[parts[3]]
-}
-
-// preActivationFinding protects only Boatstack-owned planning state before plan
-// activation. Repository attachment, saved plans, approvals, and ambiguous or
-// invalid candidates are observations, not authority over ordinary repository
-// tools. Product mutation is therefore outside this pre-activation boundary;
-// activation rechecks the plan, approval or autonomy receipt, branch, worktree,
-// and product baseline before it creates managed delivery state.
-//
-// The first raw write into the feature tree remains denied. Every planning
-// artifact crosses the owned planning-write boundary, so an engaged hook cannot
-// be bypassed by creating or editing controller state with a host writer.
-// control-law: draft-plans-never-activate-workflow-control
-// control-law: first-planning-write-uses-the-owned-channel
-func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) {
- if !featureScopedPath(attemptedPath) {
- return SafetyFinding{}, false
- }
- finding := SafetyFinding{
- Category: "workflow-phase-bypass", Reason: "Boatstack planning state may be changed only through the owned planning-write channel", Source: "planning-state",
- WorkflowStage: "NOT_STARTED", AttemptedPath: attemptedPath, NextOperation: "planning-write",
- }
- if parts := strings.Split(filepath.ToSlash(attemptedPath), "/"); len(parts) > 2 && featureSlugPattern.MatchString(parts[2]) {
- finding.BlockingFeature = parts[2]
- if fileExists(filepath.Join(WorkspaceFor(repo).FeatureDir(parts[2]), "plan.md")) {
- if status, err := ResolveNext(repo, parts[2]); err == nil {
- finding.WorkflowStage = status.ObservedStage
- }
- }
- }
- return finding, true
-}
-
-func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) {
- active, err := ActiveManagedDeliveries(repo)
- if err != nil {
- return SafetyFinding{Category: "workflow-state-invalid", Reason: "publication is denied because managed delivery state cannot be verified", Source: "delivery-state"}, true
- }
- // Scope publication-authority resolution to un-ignored deliveries so it
- // matches ResolveNext and the run coordinator. Without this, a stale-but-
- // active ignored delivery (e.g. an APPROVED lock whose code shipped out of
- // band) poisons authority for every other delivery with relation=ambiguous.
- // A config that fails to load leaves active unfiltered, preserving the prior
- // behavior.
- if config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()); configErr == nil {
- active = withoutIgnoredDeliveries(active, config.Workflow.IgnoredDeliveries)
- }
- if len(active) == 0 {
- return SafetyFinding{}, false
- }
- branch, _ := gitCommand(repo, "branch", "--show-current")
- matching := []string{}
- for _, feature := range active {
- state, loadErr := LoadDeliveryState(repo, feature)
- if loadErr == nil && stateMatchesBranch(state, strings.TrimSpace(branch)) {
- matching = append(matching, feature)
- }
- }
- // A delivery in another branch or worktree supplies no publication authority
- // here. Direct publication remains guarded when the current branch actually
- // owns one or more managed delivery slices.
- // control-law: managed-publication-scope-follows-current-branch
- if len(matching) == 0 {
- return SafetyFinding{}, false
- }
- selected := matching[0]
- relation := "current_branch"
- if len(matching) > 1 {
- selected = strings.Join(matching, ",")
- relation = "ambiguous"
- }
- finding := SafetyFinding{
- Category: "workflow-publication-bypass", Reason: reason, Source: source,
- BlockingFeature: selected, BranchRelation: relation, NextOperation: "recovery-status",
- }
- if !strings.Contains(selected, ",") {
- if state, loadErr := LoadDeliveryState(repo, selected); loadErr == nil {
- // Report the addressable slice the current branch actually owns — the
- // active slice or a published-but-open earlier slice — rather than always
- // the active slice, which named the wrong slice for a published-slice fix.
- if _, addressable, ok := resolveAddressableSliceByBranch(state, strings.TrimSpace(branch)); ok {
- finding.BlockingSlice = addressable.ID
- } else {
- _, finding.BlockingSlice, _ = deliveryBranchAndSlice(state)
- }
- finding.ParentDelivery = state.ParentDelivery
- }
- }
- return finding, true
-}
-
-// classifySafetyText matches destructive-operation text. scanSQL gates the rules
-// whose grammar is inert as data and destructive only when a live database client
-// runs it — bare DDL (DROP TABLE) and unbounded DML (DELETE FROM … with no WHERE).
-// Callers pass scanSQL=true only in an executor context (a command that invokes a
-// SQL client, a file the command executes, or a live SQL tool); they pass false
-// for a committed artifact, a git operand, or a document edit, so declarative SQL
-// is treated as data. Self-executing rules (rm, git, terraform, supabase db reset)
-// name their own executor and always apply.
-func classifySafetyText(value, source string, scanSQL bool) []SafetyFinding {
- if isPureReadOnlyCommand(value) {
- return nil
- }
- findings := []SafetyFinding{}
- seen := map[string]bool{}
- for _, rule := range irreversiblePatterns {
- if rule.sqlEffect && !scanSQL {
- continue
- }
- if rule.pattern.MatchString(value) && !seen[rule.category] {
- seen[rule.category] = true
- findings = append(findings, SafetyFinding{Category: rule.category, Reason: rule.reason, Source: source})
- }
- }
- if scanSQL {
- for _, statement := range mutationStatementPattern.FindAllString(strings.ToLower(value), -1) {
- normalized := " " + strings.Join(strings.Fields(statement), " ") + " "
- if !strings.Contains(normalized, " where ") {
- findings = append(findings, SafetyFinding{Category: "unbounded-data-mutation", Reason: "unbounded data deletion or update is denied", Source: source})
- break
- }
- }
- }
- return findings
-}
-
-// isPureReadOnlyCommand recognizes a deliberately narrow diagnostic surface.
-// Every pipeline stage must itself be read-only, and compound shell syntax is
-// rejected. Quoted search patterns may name dangerous operations without
-// turning the diagnostic search into an executable capability.
-func isPureReadOnlyCommand(value string) bool {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" || strings.ContainsAny(trimmed, "\n`><") || strings.Contains(trimmed, "$(") ||
- strings.Contains(trimmed, ";") || strings.Contains(trimmed, "&&") || strings.Contains(trimmed, "||") || strings.Contains(trimmed, "<<") {
- return false
- }
- stages, ok := shellPipelineStages(trimmed)
- if !ok {
- return false
- }
- for _, stage := range stages {
- if !readOnlyStage.MatchString(strings.TrimSpace(stage)) {
- return false
- }
- }
- return true
-}
-
-func shellPipelineStages(value string) ([]string, bool) {
- stages := []string{}
- start := 0
- var quote rune
- escaped := false
- for index, char := range value {
- if escaped {
- escaped = false
- continue
- }
- if char == '\\' && quote != '\'' {
- escaped = true
- continue
- }
- if quote != 0 {
- if char == quote {
- quote = 0
- }
- continue
- }
- if char == '\'' || char == '"' {
- quote = char
- continue
- }
- if char == '|' {
- stages = append(stages, value[start:index])
- start = index + 1
- }
- }
- if quote != 0 || escaped {
- return nil, false
- }
- stages = append(stages, value[start:])
- return stages, true
-}
-
-// shellSegments splits a command into simple-command segments on unquoted shell
-// operators (; & | and newline), so each segment's first word is its executor.
-// && and || reduce to their operator characters, which still segments correctly.
-func shellSegments(value string) []string {
- segments := []string{}
- start := 0
- var quote rune
- escaped := false
- for index, char := range value {
- if escaped {
- escaped = false
- continue
- }
- if char == '\\' && quote != '\'' {
- escaped = true
- continue
- }
- if quote != 0 {
- if char == quote {
- quote = 0
- }
- continue
- }
- if char == '\'' || char == '"' {
- quote = char
- continue
- }
- if char == ';' || char == '&' || char == '|' || char == '\n' {
- segments = append(segments, value[start:index])
- start = index + 1
- }
- }
- return append(segments, value[start:])
-}
-
-// segmentExecutor returns the executable basename of a simple command, skipping
-// leading VAR=value assignments and benign wrappers (env, sudo, time, …). It
-// returns "" when the segment has no command word.
-func segmentExecutor(segment string) string {
- fields := strings.Fields(segment)
- for len(fields) > 0 {
- field := fields[0]
- if strings.HasPrefix(field, "-") {
- return ""
- }
- if eq := strings.IndexByte(field, '='); eq > 0 && !strings.ContainsAny(field[:eq], "/\\") {
- fields = fields[1:]
- continue
- }
- base := strings.TrimSuffix(strings.ToLower(filepath.Base(field)), ".exe")
- switch base {
- case "env", "sudo", "time", "nohup", "xargs", "command", "doas", "stdbuf":
- fields = fields[1:]
- continue
- }
- return base
- }
- return ""
-}
-
-// shellDashCScript returns the script passed to a shell's -c flag, so an executor
-// hidden inside `bash -c "…"` is analyzed at the same fidelity as a top-level one.
-func shellDashCScript(executor, segment string) (string, bool) {
- switch executor {
- case "sh", "bash", "zsh", "ksh", "dash":
- default:
- return "", false
- }
- fields := strings.Fields(segment)
- for index, field := range fields {
- if field == "-c" && index+1 < len(fields) {
- return strings.Trim(strings.Join(fields[index+1:], " "), "\"'"), true
- }
- }
- return "", false
-}
-
-// commandExecutesLiveSQL reports whether any segment of the command invokes a live
-// database client. Quoted prose in an unrelated command (git commit -m "DROP
-// TABLE …") is not an executor and returns false; a nested shell -c script is
-// unwrapped so `bash -c "psql … DROP …"` returns true.
-func commandExecutesLiveSQL(command string) bool {
- for _, segment := range shellSegments(command) {
- executor := segmentExecutor(segment)
- if liveSQLClientPattern.MatchString(executor) {
- return true
- }
- if inline, ok := shellDashCScript(executor, segment); ok && commandExecutesLiveSQL(inline) {
- return true
- }
- }
- return false
-}
-
-// executedRepositoryFiles returns repository files the command actually EXECUTES,
-// split into regular files (whose contents are inspected) and symlinked
-// entrypoints (reported, never followed). A file merely named as data — git add
-// x.sql, cp, cat — is returned by neither, because only runner segments (an
-// interpreter or a SQL client that runs a file) are considered.
-func executedRepositoryFiles(repo, command string) (content []string, symlinks []string) {
- seen := map[string]bool{}
- for _, segment := range shellSegments(command) {
- executor := segmentExecutor(segment)
- if !fileRunnerPattern.MatchString(executor) && !liveSQLClientPattern.MatchString(executor) {
- continue
- }
- for _, token := range strings.Fields(segment) {
- candidate := strings.Trim(token, "\"'`;,()[]{}")
- if candidate == "" || strings.HasPrefix(candidate, "-") {
- continue
- }
- ext := strings.ToLower(filepath.Ext(candidate))
- if ext != ".py" && ext != ".sh" && ext != ".bash" && ext != ".ps1" && ext != ".sql" {
- continue
- }
- path := candidate
- if !filepath.IsAbs(path) {
- path = filepath.Join(repo, filepath.FromSlash(candidate))
- }
- abs, err := filepath.Abs(path)
- if err != nil {
- continue
- }
- rel, err := filepath.Rel(repo, abs)
- if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
- continue
- }
- if seen[abs] {
- continue
- }
- info, err := os.Lstat(abs)
- if err != nil {
- continue
- }
- seen[abs] = true
- switch {
- case info.Mode()&os.ModeSymlink != 0:
- symlinks = append(symlinks, abs)
- case info.Mode().IsRegular():
- content = append(content, abs)
- }
- }
- }
- return content, symlinks
-}
-
-// sqlExecutorToolPattern matches a tool name that denotes a database client
-// executing SQL against a live connection (an MCP execute_sql / db query tool),
-// so its arguments are a live capability rather than inert text.
-var sqlExecutorToolPattern = regexp.MustCompile(`(?i)(?:execute|run|exec)[_-]?sql|sql[_-]?(?:exec|execute|query|statement)|db[_-]?(?:execute|exec|query)`)
-
-// toolExecutesLiveSQL reports whether the tool runs SQL against a live database.
-func toolExecutesLiveSQL(name string) bool {
- return sqlExecutorToolPattern.MatchString(strings.ToLower(name))
-}
-
-func ClassifyCommand(repo, command string) []SafetyFinding {
- if strings.TrimSpace(command) == "" {
- return []SafetyFinding{{Category: "malformed-tool-input", Reason: "empty-command", Source: "tool-input"}}
- }
- validatedPlanningTransport := false
- // A complete literal planning envelope carries two different types in one
- // host command: an executable helper header and inert Markdown bytes. Parse
- // that boundary before applying any effect classifier. An incomplete or
- // ambiguous envelope is denied before the shell can interpret its body.
- // control-law: planning-document-body-is-literal-data
- if transport := inspectPlanningWriteTransport(command); transport.Matched {
- if transport.InvalidReason != "" {
- return []SafetyFinding{{
- Category: "planning-transport-invalid", Reason: transport.InvalidReason, Source: "planning-transport",
- BlockingFeature: transport.Feature, NextOperation: "planning-write",
- }}
- }
- if reason := planningTransportBinding(repo, transport); reason != "" {
- return []SafetyFinding{{
- Category: "planning-transport-invalid", Reason: reason, Source: "planning-transport",
- BlockingFeature: transport.Feature, NextOperation: "planning-write",
- }}
- }
- validatedPlanningTransport = true
- command = transport.Header
- }
- owned := ownedCommandAdmission{}
- if deliveryStatePathPattern.MatchString(command) {
- owned = ownedBoatstackCommand(repo, command)
- }
- if owned.Finding != nil {
- return []SafetyFinding{*owned.Finding}
- }
- readOnly := isPureReadOnlyCommand(command) || (owned.Allowed && owned.ReadOnly)
- if deliveryStatePathPattern.MatchString(command) && !validatedPlanningTransport && !owned.Allowed && !readOnly && !approvedUpdatePublisherPattern.MatchString(command) {
- // AttemptedPath carries the matched managed-path fragment (bounded and
- // secret-free, like the phase-bypass finding) so the denial can name the
- // path's declared owner verbs from the state-ownership map.
- return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state", AttemptedPath: deliveryStatePathPattern.FindString(command)}}
- }
- if insightArtifactPathPattern.MatchString(command) && (!readOnly || insightInPlaceMutationPattern.MatchString(command)) && !insightGitStagingPattern.MatchString(command) {
- return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "tracked insight artifacts may be changed only by Boatstack insight transitions", Source: "insight-state", AttemptedPath: insightArtifactPathPattern.FindString(command)}}
- }
- if directPublicationPattern.MatchString(command) && !approvedPublisherPattern.MatchString(command) {
- if finding, blocked := publicationBypassFinding(repo, "direct push or PR mutation is denied while a managed delivery slice is active", "tool-input"); blocked {
- return []SafetyFinding{finding}
- }
- }
- if strings.Contains(command, "workspace-sync") && !readOnly && !owned.Allowed && !controlledWorkspaceSync(repo, command) {
- return []SafetyFinding{{Category: "workspace-sync-bypass", Reason: "recoverable branch alignment must use the exact project-local Boatstack helper", Source: "command"}}
- }
- findings := classifySafetyText(command, "command", commandExecutesLiveSQL(command))
- if len(findings) > 0 {
- return dedupeFindings(findings)
- }
- if !readOnly {
- // Feed any named .product-loop/features/ operand so the first-write latch
- // sees raw shell writes (cp/tee/redirect) the same way it sees a Write tool.
- if finding, blocked := preActivationFinding(repo, featuresPathInCommand(command)); blocked && !owned.Allowed && !approvedPublisherPattern.MatchString(command) && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) {
- return []SafetyFinding{finding}
- }
- }
- if regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\b`).MatchString(command) && strings.Contains(command, repo) {
- findings = append(findings, SafetyFinding{Category: "filesystem-destruction", Reason: "recursive deletion of the repository is denied", Source: "command"})
- }
- if len(findings) > 0 || readOnly {
- return dedupeFindings(findings)
- }
- // Only inspect files the command actually EXECUTES (interpreter / SQL-client
- // segments). A file merely named as data (git add x.sql, cp, cat) is not an
- // executed capability, so its SQL content is never classified. An executed file
- // IS a live capability, so its contents are scanned with scanSQL=true.
- contentFiles, symlinkFiles := executedRepositoryFiles(repo, command)
- if len(symlinkFiles) > 0 {
- return []SafetyFinding{{Category: "symlink-entrypoint", Reason: "an invoked repository entrypoint is a symlink and cannot be inspected safely", Source: filepath.Base(symlinkFiles[0])}}
- }
- for _, path := range contentFiles {
- value, err := os.ReadFile(path)
- if err != nil {
- return []SafetyFinding{{Category: "unreadable-entrypoint", Reason: "an invoked repository entrypoint could not be inspected", Source: filepath.Base(path)}}
- }
- relative, relErr := filepath.Rel(repo, path)
- if relErr != nil {
- relative = filepath.Base(path)
- }
- findings = append(findings, classifySafetyText(string(value), filepath.ToSlash(relative), true)...)
- }
- return dedupeFindings(findings)
-}
-
-func ClassifyTool(repo, name string, input any) []SafetyFinding {
- if strings.EqualFold(name, "Bash") || strings.EqualFold(name, "Shell") || strings.EqualFold(name, "beforeShellExecution") || strings.EqualFold(name, "run_shell_command") {
- if object, ok := input.(map[string]any); ok {
- return ClassifyCommand(repo, stringValue(object["command"]))
- }
- }
- value, err := json.Marshal(input)
- if err != nil {
- return []SafetyFinding{{Category: "malformed-tool-input", Reason: "invalid-tool-input", Source: "tool-input"}}
- }
- nameLower := strings.ToLower(name)
- attemptedPath := attemptedRepositoryPath(repo, input)
- // Written content is DATA, not effect: a file-writer tool's document body is
- // inert until something executes it, so the text classifiers grade only the
- // tool name and its structural fields (paths, destinations) — a runbook that
- // MENTIONS `terraform destroy` or `.git/boatstack/` is not the act of running
- // or tampering with either. Bash command strings stay fully text-scanned (the
- // text IS the command), and live SQL-executor tools keep full-input grading.
- // control-law: written-content-is-data-not-effect
- if fileWriterTool(nameLower, attemptedPath) {
- if redacted, redactErr := json.Marshal(redactContentFields(input)); redactErr == nil {
- value = redacted
- }
- }
- combined := name + " " + string(value)
- // Bare SQL grammar in a tool's arguments is a live capability only when the tool
- // itself executes SQL (an MCP execute_sql / db query tool). A Write/Edit/Read
- // whose content merely contains DDL is a document, not an execution.
- findings := classifySafetyText(combined, "tool-input", toolExecutesLiveSQL(name))
- mutationCapable := mutationToolPattern.MatchString(nameLower) || (strings.HasPrefix(nameLower, "mcp__") && !externalReadOnlyToolPattern.MatchString(nameLower))
- if mutationCapable {
- if finding, blocked := preActivationFinding(repo, attemptedPath); blocked {
- findings = append(findings, finding)
- }
- }
- publicationText := strings.ToLower(combined)
- if deliveryStatePathPattern.MatchString(combined) && regexp.MustCompile(`(?:write|edit|delete|remove|move|rename|create|update)`).MatchString(nameLower) {
- findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state", AttemptedPath: deliveryStatePathPattern.FindString(combined)})
- }
- if insightArtifactPathPattern.MatchString(combined) && regexp.MustCompile(`(?:write|edit|delete|remove|move|rename|create|update)`).MatchString(nameLower) {
- findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "tracked insight artifacts may be changed only by Boatstack insight transitions", Source: "insight-state", AttemptedPath: insightArtifactPathPattern.FindString(combined)})
- }
- if (strings.Contains(publicationText, "pull_request") || strings.Contains(publicationText, "pull request")) &&
- regexp.MustCompile(`(?:create|update|edit|merge|publish)`).MatchString(publicationText) {
- if finding, blocked := publicationBypassFinding(repo, "direct PR mutation is denied while a managed delivery slice is active", "tool-input"); blocked {
- findings = append(findings, finding)
- }
- }
- if regexp.MustCompile(`(?:delete|destroy|reset|drop|truncate|terminate)`).MatchString(nameLower) && regexp.MustCompile(`(?:database|schema|project|cluster|namespace|volume|bucket|backup|snapshot|instance)`).MatchString(strings.ToLower(combined)) {
- findings = append(findings, SafetyFinding{Category: "external-resource-destruction", Reason: "destructive external-resource tools are operator-only", Source: "tool-input"})
- }
- if regexp.MustCompile(`(?:delete|destroy|remove|recreate)`).MatchString(nameLower) && regexp.MustCompile(`(?:branch|service|deployment|application|environment|resource)`).MatchString(strings.ToLower(combined)) {
- findings = append(findings, SafetyFinding{Category: "external-resource-destruction", Reason: "destructive external-resource tools are operator-only", Source: "tool-input"})
- }
- if regexp.MustCompile(`(?:public|unauthenticated|allow[_-]?unauthenticated|iam[_-]?policy|ingress)`).MatchString(strings.ToLower(combined)) && regexp.MustCompile(`(?:create|update|set|add|patch|apply|expose|deploy)`).MatchString(nameLower) {
- findings = append(findings, SafetyFinding{Category: "external-public-exposure", Reason: "public or unauthenticated service exposure is operator-only", Source: "tool-input"})
- }
- return dedupeFindings(findings)
-}
-
-func mutationCapableTool(repo, name string, input any) bool {
- if strings.EqualFold(name, "Bash") || strings.EqualFold(name, "Shell") || strings.EqualFold(name, "beforeShellExecution") || strings.EqualFold(name, "run_shell_command") {
- object, ok := input.(map[string]any)
- if !ok {
- return true
- }
- command := stringValue(object["command"])
- if deliveryStatePathPattern.MatchString(command) && ownedBoatstackCommand(repo, command).Allowed {
- // Boatstack transitions have their own deterministic receipts and
- // reconciliation. Wrapping them in generic host-operation supervision
- // creates a competing controller and can deadlock the owned transition.
- return false
- }
- return !isPureReadOnlyCommandForRepo(repo, command)
- }
- lower := strings.ToLower(name)
- return mutationToolPattern.MatchString(lower) || (strings.HasPrefix(lower, "mcp__") && !externalReadOnlyToolPattern.MatchString(lower))
-}
-
-func supervisedToolIdentity(name string, input any) (string, string) {
- value, _ := json.Marshal(input)
- fingerprint := SHA256Bytes(append([]byte(strings.ToLower(strings.TrimSpace(name))+"\x00"), value...))
- return "tool:" + strings.ToLower(strings.TrimSpace(name)), fingerprint
-}
-
-func activeManagedOperationScope(repo string) (OperationScope, string, bool) {
- engagement := ResolveEngagement(repo, EngagementRequest{})
- if engagement.Mode != EngagementActive {
- return OperationScope{}, "", false
- }
- return OperationScope{Feature: engagement.Feature, Slice: engagement.Slice, Worktree: filepath.Base(repo), HeadBranch: engagement.Branch}, engagement.PlanLockHash, true
-}
-
-func operationRetryClassForTool(name string) string {
- lower := strings.ToLower(name)
- if strings.HasPrefix(lower, "mcp__") || strings.Contains(lower, "upload") || strings.Contains(lower, "browser") {
- return "RECONCILE_FIRST"
- }
- if strings.Contains(lower, "write") || strings.Contains(lower, "edit") || strings.Contains(lower, "patch") || strings.Contains(lower, "create") {
- return "ATOMIC_LOCAL"
- }
- return "IDEMPOTENT_EXTERNAL"
-}
-
-func hookAttemptKey(host, fingerprint string, eventValue []byte) string {
- var event map[string]any
- if json.Unmarshal(eventValue, &event) == nil {
- for _, key := range []string{"tool_call_id", "tool_use_id", "call_id"} {
- if identity := strings.TrimSpace(stringValue(event[key])); identity != "" {
- return SHA256Bytes([]byte(strings.ToLower(strings.TrimSpace(host)) + "\x00" + identity + "\x00" + fingerprint))
- }
- }
- }
- return SHA256Bytes([]byte(strings.ToLower(strings.TrimSpace(host)) + "\x00" + fingerprint))
-}
-
-func superviseToolAttempt(repo, host, name string, input any, eventValue []byte) *SafetyFinding {
- if !mutationCapableTool(repo, name, input) {
- return nil
- }
- scope, authority, managed := activeManagedOperationScope(repo)
- if !managed {
- return nil
- }
- kind, fingerprint := supervisedToolIdentity(name, input)
- target := attemptedRepositoryPath(repo, input)
- if target == "" {
- target = kind
- }
- receipt, err := PrepareOperation(OperationPrepareOptions{
- Repo: repo, Kind: kind, Scope: scope, Target: target, PackageFingerprint: fingerprint,
- AuthorizationFingerprint: authority, RetryClass: operationRetryClassForTool(name), MaxAttempts: 3,
- ExpectedPostcondition: "the supervised tool reports completion and its target can be reconciled",
- })
- if err != nil {
- return &SafetyFinding{Category: "operation-state-invalid", Reason: "the managed operation receipt could not be prepared", Source: "operation-controller", NextOperation: "operation-status"}
- }
- if receipt.State == OperationSucceeded {
- return &SafetyFinding{Category: "operation-already-succeeded", Reason: "the identical fingerprinted operation already succeeded", Source: "operation-controller", OperationID: receipt.OperationID, OperationState: string(receipt.State), AttemptNumber: receipt.Attempt, NextOperation: "none"}
- }
- attemptKey := hookAttemptKey(host, fingerprint, eventValue)
- begin, beginErr := BeginOperation(repo, receipt.OperationID, attemptKey, name)
- if beginErr == nil {
- return nil
- }
- finding := &SafetyFinding{
- Category: "operation-state-invalid", Reason: beginErr.Error(), Source: "operation-controller",
- OperationID: receipt.OperationID, OperationState: string(begin.Receipt.State), AttemptNumber: begin.Receipt.Attempt, NextOperation: "operation-status",
- }
- switch {
- case errors.Is(beginErr, ErrOperationInFlight):
- finding.Category = "operation-in-flight"
- finding.Reason = "the identical authorized operation is already executing"
- finding.NextOperation = "wait"
- case begin.Receipt.State == OperationReconcileRequired:
- finding.Category = "operation-reconciliation-required"
- finding.Reason = "the previous attempt ended without an observable completion"
- finding.ReconciliationRequired = true
- finding.NextOperation = "reconcile"
- case begin.Receipt.State == OperationFailedFinal:
- finding.Category = "operation-retry-exhausted"
- finding.Reason = "the persistent operation retry budget is exhausted"
- finding.NextOperation = "manual_recovery"
- }
- return finding
-}
-
-func postToolEvent(host string, value []byte) (string, any, string, bool, bool) {
- var event map[string]any
- if json.Unmarshal(value, &event) != nil {
- return "", nil, "", false, false
- }
- eventName := stringValue(event["hook_event_name"])
- postNames := map[string]bool{"postToolUse": true, "postToolUseFailure": true, "afterShellExecution": true, "afterMCPExecution": true, "PostToolUse": true, "PostToolUseFailure": true, "AfterTool": true}
- if !postNames[eventName] {
- return "", nil, "", false, false
- }
- name := stringValue(event["tool_name"])
- input := event["tool_input"]
- if eventName == "afterShellExecution" {
- name = "Bash"
- input = map[string]any{"command": stringValue(event["command"])}
- }
- if eventName == "afterMCPExecution" {
- var err error
- input, err = cursorMCPInput(input)
- if err != nil {
- return "", nil, "UNKNOWN", true, true
- }
- }
- hasResult := event["tool_response"] != nil || event["tool_result"] != nil || event["tool_output"] != nil || event["result"] != nil || event["output"] != nil || event["error"] != nil || event["tool_error"] != nil || event["exit_code"] != nil || event["exitCode"] != nil
- if strings.Contains(strings.ToLower(eventName), "failure") {
- hasResult = event["error"] != nil || event["tool_error"] != nil
- }
- if !hasResult {
- return "", nil, "UNKNOWN", true, true
- }
- outcome := "SUCCEEDED"
- failed := event["error"] != nil || event["tool_error"] != nil || event["is_error"] == true
- for _, key := range []string{"exit_code", "exitCode"} {
- if code, ok := event[key].(float64); ok && code != 0 {
- failed = true
- }
- }
- if response, ok := event["tool_response"].(map[string]any); ok {
- if response["error"] != nil || response["is_error"] == true || response["success"] == false {
- failed = true
- }
- for _, key := range []string{"exit_code", "exitCode"} {
- if code, ok := response[key].(float64); ok && code != 0 {
- failed = true
- }
- }
- }
- if failed {
- outcome = "UNKNOWN"
- }
- if strings.TrimSpace(name) == "" || input == nil {
- return "", nil, "UNKNOWN", true, true
- }
- return name, input, outcome, true, false
-}
-
-func completeSupervisedToolEvent(repo, host string, value []byte) (bool, bool) {
- name, input, outcome, handled, malformed := postToolEvent(host, value)
- if !handled {
- return false, false
- }
- if malformed {
- return true, true
- }
- if name == "" || input == nil || !mutationCapableTool(repo, name, input) {
- return true, false
- }
- kind, fingerprint := supervisedToolIdentity(name, input)
- target := attemptedRepositoryPath(repo, input)
- if target == "" {
- target = kind
- }
- id := operationID(kind, target, fingerprint)
- receipt, err := loadOperation(repo, id)
- if err != nil || receipt.State != OperationExecuting || receipt.Lease == nil {
- return true, false
- }
- attemptKey := hookAttemptKey(host, fingerprint, value)
- if _, err := CompleteOperationAttempt(repo, id, attemptKey, outcome, "host completion event observed", ""); err != nil && outcome != "UNKNOWN" {
- _, _ = CompleteOperationAttempt(repo, id, attemptKey, "UNKNOWN", "completion event could not be correlated", "")
- }
- return true, false
-}
-
-func dedupeFindings(values []SafetyFinding) []SafetyFinding {
- seen := map[string]bool{}
- result := []SafetyFinding{}
- for _, value := range values {
- key := value.Category + "\x00" + value.Source
- if !seen[key] {
- seen[key] = true
- result = append(result, value)
- }
- }
- sort.Slice(result, func(i, j int) bool {
- if result[i].Category == result[j].Category {
- return result[i].Source < result[j].Source
- }
- return result[i].Category < result[j].Category
- })
- return result
-}
-
-type hookHostContract struct {
- decode func([]byte) (string, any, error)
- allow func() ([]byte, error)
- // deny takes the resolved repository so the denial can carry its computed
- // solution set (empty when the repository could not be resolved).
- // control-law: solution-set-derives-from-guard-declarations
- deny func(repo string, finding SafetyFinding) ([]byte, error)
-}
-
-func decodeJSONObject(host string, value []byte) (map[string]any, error) {
- if len(strings.TrimSpace(string(value))) == 0 {
- return nil, malformedHookInput("empty-input")
- }
- var event map[string]any
- if err := DecodeJSON("parse "+host+" hook event", "stdin", value, &event); err != nil {
- return nil, malformedHookInput("invalid-json")
- }
- return event, nil
-}
-
-func cursorMCPInput(value any) (any, error) {
- if text, ok := value.(string); ok {
- if strings.TrimSpace(text) == "" {
- return nil, malformedHookInput("empty-tool-input")
- }
- var decoded any
- if err := json.Unmarshal([]byte(text), &decoded); err != nil {
- return nil, malformedHookInput("invalid-tool-input-json")
- }
- return decoded, nil
- }
- if value == nil {
- return nil, malformedHookInput("missing-tool-input")
- }
- return value, nil
-}
-
-func decodeCursorHook(value []byte) (string, any, error) {
- event, err := decodeJSONObject("cursor", value)
- if err != nil {
- return "", nil, err
- }
- eventName := stringValue(event["hook_event_name"])
- command := stringValue(event["command"])
- toolName := stringValue(event["tool_name"])
- toolInput := event["tool_input"]
-
- switch eventName {
- case "preToolUse":
- if _, present := event["tool_name"]; !present {
- return "", nil, malformedHookInput("missing-tool-name")
- }
- if strings.TrimSpace(toolName) == "" {
- return "", nil, malformedHookInput("empty-tool-name")
- }
- if toolInput == nil {
- return "", nil, malformedHookInput("missing-tool-input")
- }
- return toolName, toolInput, nil
- case "beforeShellExecution":
- if _, present := event["command"]; !present {
- return "", nil, malformedHookInput("missing-command")
- }
- if strings.TrimSpace(command) == "" {
- return "", nil, malformedHookInput("empty-command")
- }
- return "Bash", map[string]any{"command": command}, nil
- case "beforeMCPExecution":
- if _, present := event["tool_name"]; !present {
- return "", nil, malformedHookInput("missing-tool-name")
- }
- if strings.TrimSpace(toolName) == "" {
- return "", nil, malformedHookInput("empty-tool-name")
- }
- input, inputErr := cursorMCPInput(toolInput)
- if inputErr != nil {
- return "", nil, inputErr
- }
- return toolName, input, nil
- case "":
- // Older Cursor builds omitted hook_event_name. Preserve only the two
- // unambiguous shapes; an MCP transport command must never be classified
- // as the requested tool operation.
- if toolName != "" && toolInput != nil {
- if command != "" {
- return "", nil, malformedHookInput("ambiguous-event")
- }
- input, inputErr := cursorMCPInput(toolInput)
- if inputErr != nil {
- return "", nil, inputErr
- }
- return toolName, input, nil
- }
- if command != "" && toolName == "" {
- return "Bash", map[string]any{"command": command}, nil
- }
- return "", nil, malformedHookInput("missing-command-or-tool")
- default:
- return "", nil, malformedHookInput("unsupported-event")
- }
-}
-
-func decodePreToolUseHook(host string, value []byte) (string, any, error) {
- event, err := decodeJSONObject(host, value)
- if err != nil {
- return "", nil, err
- }
- eventName := stringValue(event["hook_event_name"])
- if eventName != "" && eventName != "PreToolUse" && !(host == "claude" && eventName == "preToolUse") {
- return "", nil, malformedHookInput("unsupported-event")
- }
- name := stringValue(event["tool_name"])
- input := event["tool_input"]
- if _, present := event["tool_name"]; !present {
- return "", nil, malformedHookInput("missing-tool-name")
- }
- if strings.TrimSpace(name) == "" {
- return "", nil, malformedHookInput("empty-tool-name")
- }
- if input == nil {
- return "", nil, malformedHookInput("missing-tool-input")
- }
- return name, input, nil
-}
-
-func decodeGeminiHook(value []byte) (string, any, error) {
- event, err := decodeJSONObject("gemini", value)
- if err != nil {
- return "", nil, err
- }
- if eventName := stringValue(event["hook_event_name"]); eventName != "" && eventName != "BeforeTool" {
- return "", nil, malformedHookInput("unsupported-event")
- }
- name := stringValue(event["tool_name"])
- if strings.TrimSpace(name) == "" {
- return "", nil, malformedHookInput("missing-tool-name")
- }
- input, present := event["tool_input"]
- if !present || input == nil {
- return "", nil, malformedHookInput("missing-tool-input")
- }
- return name, input, nil
-}
-
-func structuredHookDeny(repo, host string, finding SafetyFinding) ([]byte, error) {
- denial := denialWithOptions(repo, host, finding)
- hookOutput := map[string]any{
- "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": denial.Render(RenderPlain),
- }
- // Opt-in structured object for hosts that adopt rich denial rendering. Nested
- // inside the host's existing container; the flat reason above is always the
- // complete fallback for any host that ignores it. Off by default (no host
- // documents tolerating unknown keys — see references/host-hook-contracts.md).
- if denialRichEnabled() {
- hookOutput["boatstackDenial"] = denial.Structured()
- }
- value, err := json.Marshal(map[string]any{"hookSpecificOutput": hookOutput})
- return append(value, '\n'), err
-}
-
-var hookHostContracts = map[string]hookHostContract{
- "cursor": {
- decode: decodeCursorHook,
- allow: func() ([]byte, error) {
- value, err := json.Marshal(map[string]any{"continue": true, "permission": "allow"})
- return append(value, '\n'), err
- },
- deny: func(repo string, finding SafetyFinding) ([]byte, error) {
- denial := denialWithOptions(repo, "cursor", finding)
- message := denial.Render(RenderPlain)
- payload := map[string]any{
- "continue": true, "permission": "deny", "user_message": message, "agent_message": message,
- }
- if denialRichEnabled() {
- payload["boatstackDenial"] = denial.Structured()
- }
- value, err := json.Marshal(payload)
- return append(value, '\n'), err
- },
- },
- "claude": {
- decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("claude", value) },
- allow: func() ([]byte, error) { return nil, nil },
- deny: func(repo string, finding SafetyFinding) ([]byte, error) {
- return structuredHookDeny(repo, "claude", finding)
- },
- },
- "codex": {
- decode: func(value []byte) (string, any, error) { return decodePreToolUseHook("codex", value) },
- allow: func() ([]byte, error) { return nil, nil },
- deny: func(repo string, finding SafetyFinding) ([]byte, error) {
- return structuredHookDeny(repo, "codex", finding)
- },
- },
- "gemini": {
- decode: decodeGeminiHook,
- allow: func() ([]byte, error) {
- value, err := json.Marshal(map[string]any{"decision": "allow"})
- return append(value, '\n'), err
- },
- deny: func(repo string, finding SafetyFinding) ([]byte, error) {
- denial := denialWithOptions(repo, "gemini", finding)
- payload := map[string]any{"decision": "deny", "reason": denial.Render(RenderPlain)}
- if denialRichEnabled() {
- payload["boatstackDenial"] = denial.Structured()
- }
- value, err := json.Marshal(payload)
- return append(value, '\n'), err
- },
- },
-}
-
-// denialMessage renders the human-facing reason string embedded in a host's hook
-// decision. It delegates to the structured Denial model (denial.go) — including
-// the finding's computed solution set — and renders the plain, multi-line form,
-// the safe default that every host displays. Richer treatments (markdown, ANSI,
-// the structured object) are produced from the same Denial by the CLI/guard
-// surfaces and the opt-in rich path.
-func denialMessage(repo, host string, finding SafetyFinding) string {
- return denialWithOptions(repo, host, finding).Render(RenderPlain)
-}
-
-// EngagementProbeDecision is the developer-level entry point. Repository
-// presence carries no authority; HookDecision applies policy only when the
-// canonical resolver proves a current active delivery.
-func EngagementProbeDecision(options SafetyHookOptions) ([]byte, bool) {
- return HookDecision(options)
-}
-
-func HookDecision(options SafetyHookOptions) ([]byte, bool) {
- host := strings.ToLower(strings.TrimSpace(options.Host))
- contract, supported := hookHostContracts[host]
- engagement := ResolveEngagement(options.Repo, EngagementRequest{})
- if engagement.Mode != EngagementActive {
- return nil, false
- }
- if !supported {
- finding := SafetyFinding{Category: "unsupported-host", Reason: "unknown host is denied by the fail-closed guard", Source: "hook"}
- value, _ := structuredHookDeny("", "codex", finding)
- return value, true
- }
- repo, err := ResolveRepository(engagement.RepoRoot)
- if err != nil {
- finding := SafetyFinding{Category: "unresolved-repository", Reason: "repository identity could not be established", Source: "hook"}
- value, _ := contract.deny("", finding)
- return value, true
- }
- if handled, malformed := completeSupervisedToolEvent(repo, host, options.Input); handled {
- if malformed {
- finding := SafetyFinding{Category: "malformed-tool-input", Reason: "invalid-post-event", Source: "hook"}
- value, _ := contract.deny(repo, finding)
- return value, true
- }
- value, _ := contract.allow()
- return value, false
- }
- name, input, err := contract.decode(options.Input)
- if err != nil {
- reason := "invalid-event"
- var decodeErr hookDecodeError
- if errors.As(err, &decodeErr) {
- reason = decodeErr.code
- }
- finding := SafetyFinding{Category: "malformed-tool-input", Reason: reason, Source: "hook"}
- value, _ := contract.deny(repo, finding)
- return value, true
- }
- findings := ClassifyTool(repo, name, input)
- if len(findings) == 0 {
- if finding := superviseToolAttempt(repo, host, name, input, options.Input); finding != nil {
- finding.RepeatCount = recordDenial(repo, *finding)
- value, _ := contract.deny(repo, *finding)
- return value, true
- }
- // An allowed mutation-capable call is forward progress: stale denial
- // history must not escalate the next unrelated denial.
- // control-law: repeated-denials-escalate-to-solutions
- if mutationCapableTool(repo, name, input) {
- resetDenialLedger(repo)
- }
- value, _ := contract.allow()
- return value, false
- }
- finding := findings[0]
- finding.RepeatCount = recordDenial(repo, finding)
- value, _ := contract.deny(repo, finding)
- return value, true
-}
-
-func operationalChangedFiles(repo string, highRisk []string, defaultBranch string) ([]string, error) {
- diffStart := "HEAD"
- if strings.TrimSpace(defaultBranch) != "" {
- if head, headErr := gitCommand(repo, "branch", "--show-current"); headErr == nil && head != defaultBranch {
- if baseCommit, baseErr := resolveBaseCommit(repo, defaultBranch); baseErr == nil {
- if mergeBase, mergeErr := gitCommand(repo, "merge-base", baseCommit, "HEAD"); mergeErr == nil && mergeBase != "" {
- diffStart = mergeBase
- }
- }
- }
- }
- command := exec.Command("git", "-C", repo, "diff", "--name-only", "--diff-filter=ACMR", diffStart)
- value, err := command.Output()
- if err != nil {
- return nil, err
- }
- untrackedCommand := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard")
- untracked, err := untrackedCommand.Output()
- if err != nil {
- return nil, err
- }
- paths := []string{}
- seen := map[string]bool{}
- all := strings.TrimSpace(string(value)) + "\n" + strings.TrimSpace(string(untracked))
- for _, path := range strings.Split(all, "\n") {
- path = filepath.ToSlash(strings.TrimSpace(path))
- if path == "" || seen[path] {
- continue
- }
- seen[path] = true
- matched := operationalPathPattern.MatchString(path)
- for _, pattern := range highRisk {
- if ok, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(path)); ok {
- matched = true
- }
- prefix := strings.TrimSuffix(filepath.ToSlash(pattern), "/**")
- if prefix != pattern && strings.HasPrefix(path, prefix+"/") {
- matched = true
- }
- }
- if matched {
- paths = append(paths, path)
- }
- }
- return paths, nil
-}
-
-func CheckRepositorySafety(repoPath string) (SafetyReport, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return SafetyReport{}, err
- }
- highRisk := []string{}
- defaultBranch := ""
- configPath := WorkspaceFor(repo).ProjectConfigPath()
- if value, readErr := os.ReadFile(configPath); readErr == nil {
- var config ProjectConfig
- if json.Unmarshal(value, &config) == nil {
- highRisk = config.Project.HighRiskPaths
- defaultBranch = config.Project.DefaultBranch
- }
- }
- paths, err := operationalChangedFiles(repo, highRisk, defaultBranch)
- if err != nil {
- return SafetyReport{}, err
- }
- findings := []SafetyFinding{}
- for _, relative := range paths {
- value, readErr := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative)))
- if readErr != nil {
- return SafetyReport{}, readErr
- }
- // A committed file in the delivery diff is a DATA ARTIFACT, not an execution:
- // a declarative migration .sql or a schema dump is applied later by the
- // controlled deploy pipeline (the operator boundary), so its bare SQL is not a
- // capability the agent is exercising now (scanSQL=false). Self-executing
- // destruction committed into a script (supabase db reset, terraform destroy)
- // still blocks, because those rules name their own executor.
- findings = append(findings, classifySafetyText(string(value), relative, false)...)
- }
- findings = dedupeFindings(findings)
- status := "PASS"
- if len(findings) > 0 {
- status = "BLOCKED"
- }
- return SafetyReport{Status: status, Findings: findings}, nil
-}
diff --git a/boatstack/safety_corpus_test.go b/boatstack/safety_corpus_test.go
deleted file mode 100644
index 7944573..0000000
--- a/boatstack/safety_corpus_test.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-// The guard is a classifier, so it is graded like one — against an external corpus
-// on TWO axes (Guard-as-Benchmarked-Artifact):
-//
-// - constitutional: real destruction that MUST block. This is the monotone floor —
-// 100% required, and a change may never regress it (a live destructive effect is
-// never allowed for convenience).
-// - routine: ordinary product work that MUST pass. This is the false-positive axis —
-// the guard earns its keep by NOT blocking these. Tracked as a rate so the
-// boat-terminal harness can grow the corpus and watch for drift.
-//
-// This is the corpus itself — a test, not a note. Extend it (from the boat-terminal
-// or by hand) as new destructive shapes and new routine idioms appear.
-type guardCase struct {
- name string
- tool string // "" => Bash command in `input`; otherwise a tool name
- input any // command string, or a tool-input map
- axis string // "constitutional" | "routine"
- wantBlock bool
-}
-
-func classifyCase(repo string, c guardCase) []SafetyFinding {
- if c.tool == "" {
- return ClassifyCommand(repo, c.input.(string))
- }
- return ClassifyTool(repo, c.tool, c.input)
-}
-
-func TestGuardCorpusDualReward(t *testing.T) {
- repo := safetyTestRepo(t)
- // A DDL file that a live client would execute (for the psql -f constitutional case).
- if err := os.WriteFile(filepath.Join(repo, "migrate.sql"), []byte("DROP TABLE accounts;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- cases := []guardCase{
- // --- constitutional: live destruction, MUST block (monotone floor) ---
- {"psql-drop-inline", "", `psql -c "DROP SCHEMA public CASCADE"`, "constitutional", true},
- {"psql-truncate", "", `psql -c "TRUNCATE TABLE accounts"`, "constitutional", true},
- {"psql-unbounded-delete", "", `psql -c "DELETE FROM accounts"`, "constitutional", true},
- {"psql-run-ddl-file", "", `psql -f migrate.sql`, "constitutional", true},
- {"supabase-reset", "", `supabase db reset`, "constitutional", true},
- {"prisma-reset", "", `prisma migrate reset --force`, "constitutional", true},
- {"terraform-destroy", "", `terraform destroy -auto-approve`, "constitutional", true},
- {"rm-rf-root", "", `rm -rf /`, "constitutional", true},
- {"rm-rf-home", "", `rm -rf ~`, "constitutional", true},
- {"git-reset-hard", "", `git reset --hard HEAD~1`, "constitutional", true},
- {"git-force-push", "", `git push --force origin main`, "constitutional", true},
- {"kubectl-delete-ns", "", `kubectl delete namespace production`, "constitutional", true},
- {"docker-volume-rm", "", `docker volume rm data-volume`, "constitutional", true},
- {"gcloud-sql-delete", "", `gcloud sql instances delete primary`, "constitutional", true},
- {"aws-delete-snapshot", "", `aws rds delete-db-snapshot --db-snapshot-identifier b1`, "constitutional", true},
- {"mcp-execute-sql-drop", "mcp__db__execute_sql", map[string]any{"query": "DROP TABLE users"}, "constitutional", true},
- // First-write latch: the managed planning tree is authored only through
- // planning-write — a raw first write is denied even before any candidate
- // exists. control-law: first-planning-write-uses-the-owned-channel
- {"raw-write-first-plan", "Write", map[string]any{
- "file_path": filepath.Join(repo, ".product-loop", "features", "checkout", "plan.md"),
- "content": "# Plan\n",
- }, "constitutional", true},
- {"cp-first-plan", "", `cp draft.md .product-loop/features/checkout/plan.md`, "constitutional", true},
- {"redirect-first-source-plan", "", `printf x > .product-loop/features/checkout/source-plan.md`, "constitutional", true},
- // Content redaction must never launder a protected TARGET: writing INTO
- // managed runtime state is tampering regardless of what the body says.
- // control-law: written-content-is-data-not-effect
- {"write-into-delivery-state", "Write", map[string]any{
- "file_path": ".git/boatstack/deliveries/checkout/state.json",
- "content": "{}",
- }, "constitutional", true},
-
- // --- routine: ordinary product work, MUST pass (false-positive axis) ---
- {"git-add-migration", "", `git add migrate.sql`, "routine", false},
- {"git-commit-mentions-drop", "", `git commit -m "add migration that will DROP TABLE stale"`, "routine", false},
- {"git-diff-migration", "", `git diff --stat migrate.sql`, "routine", false},
- {"git-restore-staged", "", `git restore --staged migrate.sql`, "routine", false},
- {"git-status", "", `git status`, "routine", false},
- {"cat-sql", "", `cat migrate.sql`, "routine", false},
- {"recovery-status-jq", "", `boatstack-helper recovery-status --repo . | jq .next_operation`, "routine", false},
- {"git-diff-wc", "", `git diff | wc -l`, "routine", false},
- {"echo-hello", "", `echo hello`, "routine", false},
- {"edit-note-mentions-ddl", "Edit", map[string]any{
- "file_path": filepath.Join(repo, "notes.md"),
- "content": "Runbook: operator runs `psql -c \"DROP SCHEMA public CASCADE\"` off-hours.",
- }, "routine", false},
- // The owned planning channel and ordinary product writes stay open at zero
- // candidates — the first-write latch is path-scoped, never a blanket deny.
- {"planning-write-first-artifact", "", ".product-loop/boatstack planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", "routine", false},
- {"check-source-plan", "", `boatstack-helper check-source-plan --repo . --plan docs/plan.md`, "routine", false},
- {"write-product-source", "Write", map[string]any{
- "file_path": filepath.Join(repo, "src", "app.ts"),
- "content": "export const x = 1\n",
- }, "routine", false},
- // Written content is data: documenting destructive commands or managed
- // paths in a file body is not the act of running or tampering with them.
- // control-law: written-content-is-data-not-effect
- {"write-runbook-mentions-destroy", "Write", map[string]any{
- "file_path": filepath.Join(repo, "runbook.md"),
- "content": "Recovery drill: run `terraform destroy -target=module.stale` then restore from the snapshot.",
- }, "routine", false},
- {"write-doc-mentions-guard-state", "Write", map[string]any{
- "file_path": filepath.Join(repo, "docs", "ops.md"),
- "content": "Delivery state lives under `.git/boatstack/deliveries/` and is owned by the helper.",
- }, "routine", false},
- {"edit-note-mentions-git-reset", "Edit", map[string]any{
- "file_path": filepath.Join(repo, "notes.md"),
- "old_string": "TODO",
- "new_string": "Never run `git reset --hard` on shared branches; use revert PRs.",
- }, "routine", false},
- // Solution-set picks: commands the guard itself enumerates as legal moves
- // at THIS fixture's stage must pass the guard
- // (guard-never-prescribes-what-it-would-deny). Stage-scoped picks like
- // record-approval are held in their own fixture by the closure sweep.
- {"pick-undo", "", `boatstack-helper undo --mutation abc123`, "routine", false},
- {"pick-discard-delivery", "", `boatstack-helper discard-delivery --feature stale`, "routine", false},
- {"pick-operation-status", "", `boatstack-helper operation-status`, "routine", false},
- {"pick-record-change", "", `boatstack-helper record-change --feature demo --slice s1 --message fix --source-stage ci --classification implementation_repair --mechanism retry-with-corrected-state`, "routine", false},
- {"pick-next-status", "", `boatstack-helper next-status`, "routine", false},
- }
-
- var constTotal, constBlocked, routineTotal, routinePassed int
- for _, c := range cases {
- c := c
- t.Run(c.axis+"/"+c.name, func(t *testing.T) {
- blocked := len(classifyCase(repo, c)) > 0
- switch c.axis {
- case "constitutional":
- constTotal++
- if blocked {
- constBlocked++
- } else {
- // A missed real destruction is a hard failure: the floor broke.
- t.Errorf("CONSTITUTIONAL MISS — real destruction allowed: %s", c.name)
- }
- case "routine":
- routineTotal++
- if !blocked {
- routinePassed++
- } else {
- t.Errorf("ROUTINE FALSE-BLOCK — ordinary work denied: %s -> %#v", c.name, classifyCase(repo, c))
- }
- default:
- t.Fatalf("unknown axis %q", c.axis)
- }
- })
- }
-
- // Monotone floor: the constitutional axis must be 100%. The routine axis is
- // reported so drift is visible; today it must also be 100% (all are fixed cases).
- if constTotal == 0 || constBlocked != constTotal {
- t.Fatalf("constitutional block rate %d/%d — the destruction floor regressed", constBlocked, constTotal)
- }
- if routinePassed != routineTotal {
- t.Fatalf("routine pass rate %d/%d — a false-positive regressed", routinePassed, routineTotal)
- }
- t.Logf("guard corpus: constitutional %d/%d blocked (floor), routine %d/%d passed",
- constBlocked, constTotal, routinePassed, routineTotal)
-}
diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go
deleted file mode 100644
index 8a64e25..0000000
--- a/boatstack/safety_test.go
+++ /dev/null
@@ -1,947 +0,0 @@
-package boatstack
-
-import (
- "encoding/json"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func safetyTestRepo(t *testing.T) string {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Safety fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- return repo
-}
-
-func engageHookFixture(t *testing.T, repo string) {
- t.Helper()
- branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current"))
- if branch == "" {
- t.Fatal("hook fixture repository has no current branch")
- }
- lockPath := filepath.Join(WorkspaceFor(repo).FeatureDir("hook-fixture"), "plan.lock.json")
- if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(lockPath, []byte("hook fixture lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- state := DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "hook-fixture", PlanLockHash: lockHash,
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Status: StatusBuild, BaseBranch: branch, HeadBranch: branch}},
- Mode: "NORMAL", RepairCounters: map[string]int{},
- }
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- if err := syncEngagementLease(repo, state); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestEngageHookFixtureBindsCurrentNonMainBranch(t *testing.T) {
- repo := safetyTestRepo(t)
- runGit(t, repo, "switch", "-c", "fixture-topic")
- engageHookFixture(t, repo)
-
- status := ResolveEngagement(repo, EngagementRequest{})
- if status.Mode != EngagementActive || status.Branch != "fixture-topic" {
- t.Fatalf("non-main hook fixture engagement = %+v, want ACTIVE on fixture-topic", status)
- }
-}
-
-func writeValidSavedFeaturePlan(t *testing.T, repo, feature string) string {
- t.Helper()
- directory := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Source plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Feature spec\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = feature
- planPath := filepath.Join(directory, "plan.md")
- writeMarkdownPlan(t, planPath, plan, true)
- return planPath
-}
-
-func TestIrreversibleCommandCorpusIsDenied(t *testing.T) {
- repo := safetyTestRepo(t)
- cases := map[string]string{
- "schema drop": `psql -c "DROP SCHEMA public CASCADE"`,
- "truncate": `psql -c "TRUNCATE TABLE accounts"`,
- "unbounded delete": `psql -c "DELETE FROM accounts"`,
- "multiline update": "psql <<'SQL'\nUPDATE accounts\nSET active = false;\nSQL",
- "database reset": `supabase db reset`,
- "Supabase branch delete": `supabase branches delete dev_engineer --project-ref example --yes`,
- "Supabase lifecycle downgrade": `supabase branches update dev_engineer --project-ref example --persistent=false --yes`,
- "Cloud Run public exposure": `gcloud run deploy api --allow-unauthenticated`,
- "Cloud Run public IAM": `gcloud run services add-iam-policy-binding api --member=allUsers --role=roles/run.invoker`,
- "Kubernetes public service": `kubectl expose deployment api --type=LoadBalancer`,
- "recursive root": `rm -rf /`,
- "wildcard deletion": `rm -rf build/*`,
- "compound pipeline": `rg reset scripts | rm -rf .`,
- "subshell": `echo $(git reset --hard HEAD~1)`,
- "environment prefix": `TARGET=dev sh -c 'psql -c "DROP SCHEMA public CASCADE"'`,
- "hard reset": `git reset --hard HEAD~1`,
- "force push": `git push --force origin main`,
- "cloud deletion": `gcloud sql instances delete primary`,
- "namespace deletion": `kubectl delete namespace production`,
- "volume deletion": `docker volume rm data-volume`,
- "backup deletion": `aws rds delete-db-snapshot --db-snapshot-identifier backup-1`,
- "powershell": `Remove-Item -Recurse -Force $HOME`,
- }
- for name, command := range cases {
- t.Run(name, func(t *testing.T) {
- if findings := ClassifyCommand(repo, command); len(findings) == 0 {
- t.Fatalf("dangerous command was allowed: %s", command)
- }
- })
- }
-}
-
-// control-law: external-control-plane-effects-stay-operator-only
-func TestExternalControlPlaneReadOnlyCommandsRemainAllowed(t *testing.T) {
- repo := safetyTestRepo(t)
- for _, command := range []string{
- "supabase branches list --project-ref example -o json",
- "supabase branches get dev_engineer --project-ref example",
- "gcloud run services describe api",
- } {
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Fatalf("read-only control-plane command was denied: %s: %#v", command, findings)
- }
- }
-}
-
-// control-law: external-control-plane-effects-stay-operator-only
-func TestExternalControlPlaneInvocationVariantsAreDenied(t *testing.T) {
- repo := safetyTestRepo(t)
- for name, command := range map[string]string{
- "whitespace": "supabase\tbranches\tdelete dev_engineer",
- "environment prefix": "env SUPABASE_ACCESS_TOKEN=example supabase branches delete dev_engineer",
- "shell": "sh -c 'supabase branches update dev_engineer --persistent=false'",
- "tokenized argv": `python -c "import subprocess; subprocess.run(['supabase','branches','delete','dev_engineer'])"`,
- } {
- t.Run(name, func(t *testing.T) {
- if findings := ClassifyCommand(repo, command); len(findings) == 0 {
- t.Fatalf("external mutation variant was allowed: %s", command)
- }
- })
- }
-}
-
-// control-law: external-control-plane-effects-stay-operator-only
-func TestSupabaseRecreateWrapperIsDeniedThroughExecutedFileInspection(t *testing.T) {
- repo := safetyTestRepo(t)
- script := filepath.Join(repo, "scripts", "dev_environment.py")
- if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
- t.Fatal(err)
- }
- value := []byte("import subprocess\nsubprocess.run(['supabase', 'branches', 'delete', 'dev_engineer'])\n")
- if err := os.WriteFile(script, value, 0o644); err != nil {
- t.Fatal(err)
- }
- findings := ClassifyCommand(repo, "python scripts/dev_environment.py recreate --confirm dev_engineer")
- if len(findings) == 0 || findings[0].Category != "external-resource-destruction" || findings[0].Source != "scripts/dev_environment.py" {
- t.Fatalf("destructive wrapper was not denied at the shared guard: %#v", findings)
- }
-}
-
-// control-law: external-control-plane-effects-stay-operator-only
-func TestIndirectShellWrapperIsDeniedThroughExecutedFileInspection(t *testing.T) {
- repo := safetyTestRepo(t)
- script := filepath.Join(repo, "scripts", "delete-branch.sh")
- if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(script, []byte("#!/bin/sh\nsupabase branches delete dev_engineer\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- if findings := ClassifyCommand(repo, "bash scripts/delete-branch.sh"); len(findings) == 0 {
- t.Fatal("indirect destructive shell wrapper was allowed")
- }
-}
-
-// control-law: external-control-plane-effects-stay-operator-only
-func TestExternalControlPlaneMCPMutationsAreDenied(t *testing.T) {
- repo := safetyTestRepo(t)
- cases := []struct {
- name string
- input map[string]any
- category string
- }{
- {"mcp__supabase__delete_branch", map[string]any{"branch": "dev_engineer"}, "external-resource-destruction"},
- {"mcp__cloud__update_service", map[string]any{"service": "api", "allow_unauthenticated": true}, "external-public-exposure"},
- }
- for _, test := range cases {
- findings := ClassifyTool(repo, test.name, test.input)
- if len(findings) == 0 || findings[0].Category != test.category {
- t.Fatalf("%s was not denied as %s: %#v", test.name, test.category, findings)
- }
- }
-}
-
-// control-law: external-control-plane-effects-stay-operator-only
-func TestIncidentCommandsAreDeniedThroughCodexHook(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- script := filepath.Join(repo, "scripts", "dev_environment.py")
- if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(script, []byte("import subprocess\nsubprocess.run(['supabase', 'branches', 'delete', 'dev_engineer'])\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- commands := []string{
- "supabase branches delete dev_engineer --project-ref example --yes",
- "supabase branches update dev_engineer --project-ref example --persistent=false --yes",
- "python scripts/dev_environment.py recreate --confirm dev_engineer",
- }
- for _, command := range commands {
- input, err := json.Marshal(map[string]any{
- "hook_event_name": "PreToolUse", "tool_name": "Bash",
- "tool_input": map[string]any{"command": command},
- })
- if err != nil {
- t.Fatal(err)
- }
- output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: input})
- if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) {
- t.Fatalf("Codex hook allowed incident command %q: %s", command, output)
- }
- }
-}
-
-// control-law: reap-mutates-only-through-the-sanctioned-actuator
-// The sanctioned workspace-reap helper is allowed through the pre-activation
-// interlock, while the raw destructive equivalents an operator would otherwise
-// run by hand stay denied and are redirected to workspace-reap.
-func TestReapHelperIsExemptWhileRawWorktreeRemovalStaysDenied(t *testing.T) {
- repo := safetyTestRepo(t)
- // A saved-but-unactivated plan latches the pre-activation interlock, so this
- // proves the allowlist entry rather than merely the absence of a deny pattern.
- writeValidSavedFeaturePlan(t, repo, "pending-feature")
- helper := filepath.Join(repo, ".product-loop", "bin", helperName())
- if err := os.MkdirAll(filepath.Dir(helper), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
- t.Fatal(err)
- }
-
- allowed := ".product-loop/bin/" + helperName() + " workspace-reap --repo . --confirm"
- if findings := ClassifyCommand(repo, allowed); len(findings) != 0 {
- t.Fatalf("sanctioned reap helper was denied: %#v", findings)
- }
-
- // Recursive deletion of the Boatstack worktree directory stays denied.
- rawFilesystem := ClassifyCommand(repo, "rm -rf .product-loop/worktrees/*")
- if len(rawFilesystem) == 0 || rawFilesystem[0].Category != "filesystem-destruction" {
- t.Fatalf("raw worktree deletion was not denied: %#v", rawFilesystem)
- }
- if message := denialMessage(".", "cursor", rawFilesystem[0]); !strings.Contains(message, "workspace-reap") {
- t.Fatalf("filesystem-destruction denial should redirect to workspace-reap: %s", message)
- }
-
- // Touching a worktree's runtime ledger is workflow-state-tamper.
- rawState := ClassifyCommand(repo, "rm -rf .git/worktrees/old-feature/boatstack")
- if len(rawState) == 0 || rawState[0].Category != "workflow-state-tamper" {
- t.Fatalf("raw runtime-state deletion was not denied: %#v", rawState)
- }
- if message := denialMessage(".", "cursor", rawState[0]); !strings.Contains(message, "workspace-reap") {
- t.Fatalf("workflow-state-tamper denial should mention workspace-reap: %s", message)
- }
-}
-
-func TestWorkspaceSyncIsTheOnlyAllowedRepositoryAlignmentCommand(t *testing.T) {
- repo := safetyTestRepo(t)
- writeValidSavedFeaturePlan(t, repo, "pending-feature")
- launcher := filepath.Join(repo, ".product-loop", "boatstack")
- if err := os.MkdirAll(filepath.Dir(launcher), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(launcher, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- command := ".product-loop/boatstack workspace-sync --repo . --branch main --source origin/main"
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Fatalf("exact project-local workspace sync was denied: %#v", findings)
- }
- for _, command := range []string{
- "boatstack-helper workspace-sync --repo . --branch main --source origin/main",
- "/tmp/boatstack-helper workspace-sync --repo . --branch main --source origin/main",
- ".product-loop/boatstack workspace-sync --repo /tmp --branch main --source origin/main",
- } {
- findings := ClassifyCommand(repo, command)
- if len(findings) == 0 || findings[0].Category != "workspace-sync-bypass" {
- t.Fatalf("unverified workspace sync was allowed: %s %#v", command, findings)
- }
- }
- raw := ClassifyCommand(repo, "git reset --hard origin/main")
- if len(raw) == 0 || raw[0].Category != "git-history-destruction" {
- t.Fatalf("raw hard reset was not denied: %#v", raw)
- }
- message := denialMessage(".", "cursor", raw[0])
- for _, expected := range []string{"project-local workspace-sync", "do not scan delivery artifacts", "do not", "retry"} {
- if !strings.Contains(message, expected) {
- t.Fatalf("hard-reset denial omitted %q: %s", expected, message)
- }
- }
-}
-
-func TestInvokedSymlinkFailsClosed(t *testing.T) {
- repo := safetyTestRepo(t)
- target := filepath.Join(repo, "target.py")
- link := filepath.Join(repo, "run.py")
- if err := os.WriteFile(target, []byte("print('safe')\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(target, link); err != nil {
- t.Skipf("symlinks unavailable: %v", err)
- }
- findings := ClassifyCommand(repo, "python run.py")
- if len(findings) == 0 || findings[0].Category != "symlink-entrypoint" {
- t.Fatalf("invoked symlink did not fail closed: %#v", findings)
- }
-}
-
-// Effect-Typed Allowlist: an inspection pipeline whose every stage is read-only by
-// effect is allowed (compose a reader/status helper with pure filters), while any
-// effect-changing syntax — redirection, command substitution, an in-place writer —
-// is still denied. Classify by effect type, not by metacharacter presence.
-func TestReadOnlyInspectionPipelinesAllowed(t *testing.T) {
- repo := safetyTestRepo(t)
- allowed := []string{
- "boatstack-helper recovery-status --repo . | jq .next_operation",
- "boatstack-helper mutation-status --repo . | grep active",
- "git diff | wc -l",
- "git log --oneline | head -20 | awk '{print $1}'",
- "git status --short | sort | uniq -c",
- "cat plan.md | cut -c1-80",
- }
- for _, command := range allowed {
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Errorf("read-only inspection pipeline wrongly blocked: %q -> %#v", command, findings)
- }
- }
- denied := []string{
- "git log > out.txt", // redirection changes effect
- "git diff | tee snapshot.txt", // tee writes
- "echo $(git rev-parse HEAD)", // command substitution
- "git status && rm -rf build", // command separator hides a mutator
- }
- for _, command := range denied {
- if isPureReadOnlyCommand(command) {
- t.Errorf("effect-changing command wrongly treated as read-only: %q", command)
- }
- }
-}
-
-// Constitutional/Optimization split: the executor-gating optimization narrows WHEN
-// a destruction rule is observed (to cut false positives) but must NEVER disable the
-// boundary. When the executor is live the constitutional rule still fires; the
-// optimization's benefit (data operations pass) does not become a leak; and no
-// project config can trade the boundary away.
-func TestExecutorGatingNeverDisablesTheBoundary(t *testing.T) {
- repo := safetyTestRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "migrate.sql"), []byte("DROP TABLE accounts;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- // Optimization FLOOR: a live executor still blocks (the gating did not disable it).
- mustBlock := []string{
- `psql -c "DROP TABLE accounts"`, // executor-gated SQL, live client
- `psql -f migrate.sql`, // live client runs a DDL file it executes
- `rm -rf /`, // self-executing constitutional
- `terraform destroy -auto-approve`, // self-executing constitutional
- `supabase db reset`, // self-executing constitutional
- `git push --force origin main`, // self-executing constitutional
- }
- for _, command := range mustBlock {
- if findings := ClassifyCommand(repo, command); len(findings) == 0 {
- t.Errorf("optimization disabled the boundary — live destruction allowed: %q", command)
- }
- }
- // Optimization BENEFIT (not a leak): the same DDL as inert data passes, because
- // no live executor observes it.
- mustPass := []string{
- "git add migrate.sql",
- `git commit -m "add migration with DROP TABLE accounts"`,
- }
- for _, command := range mustPass {
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Errorf("data operation wrongly blocked: %q -> %#v", command, findings)
- }
- }
-}
-
-func TestSafeDiagnosticsAndFixForwardCommandsRemainAllowed(t *testing.T) {
- repo := safetyTestRepo(t)
- safeScript := filepath.Join(repo, "scripts", "apply_schema.py")
- if err := os.MkdirAll(filepath.Dir(safeScript), 0o755); err != nil {
- t.Fatal(err)
- }
- fixture, err := os.ReadFile("testdata/safety/safe_apply.py.txt")
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(safeScript, fixture, 0o644); err != nil {
- t.Fatal(err)
- }
- commands := []string{
- `rg -n "reset-public|DROP SCHEMA public CASCADE" scripts/apply_schema.py`,
- `git diff -- scripts/apply_schema.py | head -20`,
- `python scripts/apply_schema.py --dry-run`,
- `psql -c "SELECT current_database()"`,
- `.product-loop/boatstack check-update --repo . --force`,
- `psql -c "UPDATE accounts SET active = false WHERE id = 7"`,
- }
- for _, command := range commands {
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Fatalf("safe command %q was denied: %#v", command, findings)
- }
- }
-}
-
-func containsCategory(findings []SafetyFinding, category string) bool {
- for _, finding := range findings {
- if finding.Category == category {
- return true
- }
- }
- return false
-}
-
-// Git plumbing and file I/O never execute SQL, so naming a DDL-laden file (or
-// spelling a keyword in a message or a note) must not be classified as database
-// destruction. This is the recurring migration-commit false positive.
-func TestDataOperationsOnSQLAreNotDestruction(t *testing.T) {
- repo := safetyTestRepo(t)
- dump := filepath.Join(repo, "schema", "generated", "staging.sql")
- if err := os.MkdirAll(filepath.Dir(dump), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(dump, []byte("DROP SCHEMA public CASCADE;\nTRUNCATE TABLE accounts;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- allowed := []string{
- "git add schema/generated/staging.sql",
- `git commit -m "regenerate staging schema (adds DROP TABLE stale)"`,
- "git diff --stat schema/generated/staging.sql",
- "git restore --staged schema/generated/staging.sql",
- "git status",
- "cat schema/generated/staging.sql",
- }
- for _, command := range allowed {
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Errorf("data operation wrongly blocked: %q -> %#v", command, findings)
- }
- }
- // A note that merely mentions the keywords is a document, not an execution.
- edit := ClassifyTool(repo, "Edit", map[string]any{
- "file_path": filepath.Join(repo, "notes.md"),
- "content": "Recovery runbook: operator runs `psql -c \"DROP SCHEMA public CASCADE\"` off-hours.",
- })
- if containsCategory(edit, "database-destruction") {
- t.Errorf("document edit wrongly flagged as database-destruction: %#v", edit)
- }
-}
-
-// The real boundary must stay intact: a command whose EXECUTOR runs SQL against a
-// live database is still denied, whether the DDL is inline, in a file it executes,
-// or in a live SQL tool.
-func TestLiveSQLExecutionStillBlocked(t *testing.T) {
- repo := safetyTestRepo(t)
- migration := filepath.Join(repo, "scripts", "drop.sql")
- if err := os.MkdirAll(filepath.Dir(migration), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(migration, []byte("DROP SCHEMA public CASCADE;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- pyRunner := filepath.Join(repo, "scripts", "run_ddl.py")
- if err := os.WriteFile(pyRunner, []byte("cur.execute('DROP TABLE accounts')\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- blocked := []string{
- `psql -c "DROP SCHEMA public CASCADE"`, // inline DDL via a live client
- "psql -f scripts/drop.sql", // client executes the file
- "python scripts/run_ddl.py", // interpreter executes DDL-running code
- }
- for _, command := range blocked {
- if findings := ClassifyCommand(repo, command); !containsCategory(findings, "database-destruction") {
- t.Errorf("live SQL execution not blocked: %q -> %#v", command, findings)
- }
- }
- // A live SQL tool (MCP execute_sql) running DDL is still denied.
- tool := ClassifyTool(repo, "mcp__db__execute_sql", map[string]any{"query": "DROP TABLE users"})
- if !containsCategory(tool, "database-destruction") {
- t.Errorf("live SQL tool not blocked: %#v", tool)
- }
-}
-
-// Read-only helper status commands may be piped for inspection during recovery
-// without dropping into the full classifier.
-func TestReadOnlyHelperStatusIsPipeable(t *testing.T) {
- repo := safetyTestRepo(t)
- for _, command := range []string{
- "boatstack-helper mutation-status | grep active",
- "boatstack-helper recovery-status | head -20",
- } {
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Errorf("read-only helper pipe wrongly blocked: %q -> %#v", command, findings)
- }
- }
-}
-
-func TestAPIMethodNamesDoNotMasqueradeAsSQLMutations(t *testing.T) {
- repo := safetyTestRepo(t)
- path := filepath.Join(repo, "api", "main.py")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- value := []byte("" +
- "from fastapi import FastAPI\n" +
- "from fastapi.middleware.cors import CORSMiddleware\n\n" +
- "app = FastAPI()\n" +
- "app.add_middleware(\n" +
- " CORSMiddleware,\n" +
- " allow_methods=[\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n" +
- ")\n" +
- "metadata.update({\"supported_method\": \"DELETE\"})\n")
- if err := os.WriteFile(path, value, 0o644); err != nil {
- t.Fatal(err)
- }
- if findings := ClassifyCommand(repo, "python api/main.py"); len(findings) != 0 {
- t.Fatalf("ordinary API method configuration was denied: %#v", findings)
- }
-
- runGit(t, repo, "add", "api/main.py")
- runGit(t, repo, "commit", "-m", "add API")
- runGit(t, repo, "switch", "-c", "feat/cors")
- if err := os.WriteFile(path, append(value, []byte("# localhost ports 3000-3010\n")...), 0o644); err != nil {
- t.Fatal(err)
- }
- report, err := CheckRepositorySafety(repo)
- if err != nil || report.Status != "PASS" {
- t.Fatalf("CORS operational diff was denied: %#v %v", report, err)
- }
-}
-
-func TestInvokedRepositoryScriptIsInspected(t *testing.T) {
- repo := safetyTestRepo(t)
- path := filepath.Join(repo, "scripts", "apply_schema.py")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- unsafe, err := os.ReadFile("testdata/safety/unsafe_apply.py.txt")
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, unsafe, 0o644); err != nil {
- t.Fatal(err)
- }
- findings := ClassifyCommand(repo, "python scripts/apply_schema.py")
- if len(findings) == 0 || findings[0].Source != "scripts/apply_schema.py" {
- t.Fatalf("indirect destructive script was not denied with a repository-relative source: %#v", findings)
- }
- if strings.Contains(findings[0].Source, repo) {
- t.Fatal("guard leaked an absolute repository path")
- }
-}
-
-func TestMCPAndMalformedEventsFailClosedWithoutEchoingSecrets(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- event := []byte(`{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary","token":"secret-value"}}`)
- cursorOutput, cursorDenied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: event})
- if !cursorDenied || !strings.Contains(string(cursorOutput), `"permission":"deny"`) {
- t.Fatalf("Cursor MCP deletion was not denied: %s", cursorOutput)
- }
- for _, host := range []string{"claude", "codex"} {
- preToolEvent := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary","token":"secret-value"}}`)
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: preToolEvent})
- if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) {
- t.Fatalf("%s MCP deletion was not denied: %s", host, output)
- }
- if strings.Contains(string(output), "secret-value") || strings.Contains(string(output), "primary") {
- t.Fatalf("%s denial leaked tool arguments: %s", host, output)
- }
- }
- output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: []byte(`{"bad":true}`)})
- if !denied || !strings.Contains(string(output), `"permission":"deny"`) {
- t.Fatalf("malformed Cursor event did not fail closed: %s", output)
- }
-}
-
-func TestHostContractsNormalizeCanonicalInputs(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- cases := []struct {
- name, host string
- input string
- denied bool
- output string
- }{
- {"cursor shell allow", "cursor", `{"hook_event_name":"beforeShellExecution","command":"git status --short"}`, false, `"permission":"allow"`},
- {"cursor shell deny", "cursor", `{"hook_event_name":"beforeShellExecution","command":"git reset --hard HEAD~1"}`, true, `"permission":"deny"`},
- {"cursor MCP object deny ignores transport command", "cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary"},"command":"docker"}`, true, `"permission":"deny"`},
- {"cursor MCP string deny ignores transport URL", "cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":"{\"database\":\"primary\"}","url":"https://example.invalid/mcp"}`, true, `"permission":"deny"`},
- {"cursor legacy shell allow", "cursor", `{"command":"git status --short"}`, false, `"permission":"allow"`},
- {"cursor legacy ambiguous deny", "cursor", `{"command":"docker","tool_name":"mcp__status__read","tool_input":{}}`, true, `"permission":"deny"`},
- {"claude allow", "claude", `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, false, ""},
- {"claude deny", "claude", `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"}}`, true, `"permissionDecision":"deny"`},
- {"cursor Claude compatibility allow", "claude", `{"hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"git status --short"}}`, false, ""},
- {"cursor Claude compatibility deny", "claude", `{"hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"git reset --hard HEAD~1"}}`, true, `"permissionDecision":"deny"`},
- {"codex allow", "codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, false, ""},
- {"codex deny", "codex", `{"hook_event_name":"PreToolUse","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary"}}`, true, `"permissionDecision":"deny"`},
- {"codex lowercase PreToolUse deny", "codex", `{"hook_event_name":"preToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, true, `"permissionDecision":"deny"`},
- {"malformed post deny", "codex", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, true, `"permissionDecision":"deny"`},
- }
- for _, test := range cases {
- t.Run(test.name, func(t *testing.T) {
- output, denied := HookDecision(SafetyHookOptions{Host: test.host, Repo: repo, Input: []byte(test.input)})
- if denied != test.denied {
- t.Fatalf("denied = %t, want %t; output=%s", denied, test.denied, output)
- }
- if test.output != "" && !strings.Contains(string(output), test.output) {
- t.Fatalf("output %s does not contain %s", output, test.output)
- }
- if test.output == "" && len(output) != 0 {
- t.Fatalf("expected empty allow output, got %s", output)
- }
- })
- }
-}
-
-func TestMalformedHostPayloadsDenyWithoutLeakingInput(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- for _, test := range []struct{ host, input, reason string }{
- {"cursor", ``, "empty-input"},
- {"cursor", `{`, "invalid-json"},
- {"cursor", `{"hook_event_name":"beforeShellExecution"}`, "missing-command"},
- {"cursor", `{"hook_event_name":"beforeShellExecution","command":""}`, "empty-command"},
- {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_input":{}}`, "missing-tool-name"},
- {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":"secret-not-json"}`, "invalid-tool-input-json"},
- {"cursor", `{"hook_event_name":"unknown","command":"secret-command"}`, "unsupported-event"},
- {"claude", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"secret-command"}}`, "invalid-post-event"},
- {"codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash"}`, "missing-tool-input"},
- } {
- output, denied := HookDecision(SafetyHookOptions{Host: test.host, Repo: repo, Input: []byte(test.input)})
- if !denied {
- t.Fatalf("%s malformed payload was allowed", test.host)
- }
- body := string(output)
- if !strings.Contains(body, "HOST_PAYLOAD_MALFORMED:"+test.reason) {
- t.Fatalf("%s malformed payload did not expose safe reason %s: %s", test.host, test.reason, body)
- }
- if !strings.Contains(body, "No unsafe operation was detected") || strings.Contains(body, "denied an irreversible operation") {
- t.Fatalf("%s malformed payload was misattributed: %s", test.host, body)
- }
- if strings.Contains(body, "run the verified installer") || strings.Contains(body, "hydrate") {
- t.Fatalf("%s malformed payload recommended runtime repair: %s", test.host, body)
- }
- if strings.Contains(string(output), "secret") {
- t.Fatalf("%s denial leaked input: %s", test.host, output)
- }
- }
-}
-
-func TestCursorMalformedPayloadGuidesOneRetryThenExternalDiagnosis(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: []byte(`{"hook_event_name":"beforeShellExecution"}`)})
- if !denied {
- t.Fatal("missing Cursor command was allowed")
- }
- body := string(output)
- for _, expected := range []string{"Retry once", "stop shell and tool retries", "preserve current edits", "Start a new Cursor task", "diagnose-hook --host cursor", "Do not reinstall Boatstack"} {
- if !strings.Contains(body, expected) {
- t.Fatalf("Cursor recovery omitted %q: %s", expected, body)
- }
- }
-}
-
-func TestEveryHostUsesStableEmptyCommandReason(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- inputs := map[string]string{
- "cursor": `{"hook_event_name":"beforeShellExecution","command":""}`,
- "claude": `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":""}}`,
- "codex": `{"hook_event_name":"PreToolUse","tool_name":"Shell","tool_input":{"command":""}}`,
- }
- for _, host := range []string{"cursor", "claude", "codex"} {
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(inputs[host])})
- if !denied || !strings.Contains(string(output), "HOST_PAYLOAD_MALFORMED:empty-command") {
- t.Fatalf("%s did not return stable empty-command reason: %s", host, output)
- }
- }
-}
-
-func TestBlockedHookNeverCreatesSentinelSideEffect(t *testing.T) {
- repo := safetyTestRepo(t)
- engageHookFixture(t, repo)
- sentinel := filepath.Join(repo, "sentinel")
- command := "rm -rf . && touch " + sentinel
- event, _ := json.Marshal(map[string]any{"command": command})
- _, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: event})
- if !denied {
- if output, err := exec.Command("sh", "-c", command).CombinedOutput(); err != nil {
- t.Fatalf("sentinel simulation: %v: %s", err, output)
- }
- }
- if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
- t.Fatal("blocked command created its sentinel side effect")
- }
-}
-
-// A committed declarative migration is a DATA ARTIFACT applied later by the
-// controlled deploy pipeline, not a capability the agent is exercising now, so it
-// must not block gate progression — regenerating and committing schema SQL is the
-// normal migration step. A self-executing destructive capability committed into a
-// SCRIPT still blocks, because those rules name their own executor.
-func TestOperationalDiffTreatsDeclarativeSQLAsData(t *testing.T) {
- repo := safetyTestRepo(t)
- sqlPath := filepath.Join(repo, "schema", "generated", "staging.sql")
- if err := os.MkdirAll(filepath.Dir(sqlPath), 0o755); err != nil {
- t.Fatal(err)
- }
- // A pg_dump-style artifact, legitimately full of DROP/DDL.
- if err := os.WriteFile(sqlPath, []byte("DROP SCHEMA public CASCADE;\nCREATE SCHEMA public;\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- report, err := CheckRepositorySafety(repo)
- if err != nil {
- t.Fatal(err)
- }
- if report.Status != "PASS" {
- t.Fatalf("declarative migration .sql was wrongly blocked: %#v", report)
- }
-
- // A committed script that RUNS a destructive reset is a live capability the
- // deploy pipeline would execute — it must still block.
- scriptPath := filepath.Join(repo, "scripts", "reset.sh")
- if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(scriptPath, []byte("#!/usr/bin/env bash\nsupabase db reset --linked\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- report, err = CheckRepositorySafety(repo)
- if err != nil {
- t.Fatal(err)
- }
- if report.Status != "BLOCKED" || len(report.Findings) == 0 {
- t.Fatalf("committed self-executing destructive script did not block gates: %#v", report)
- }
-}
-
-func TestConfiguredHighRiskPathsParticipateInSafetyScan(t *testing.T) {
- repo := safetyTestRepo(t)
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Project.HighRiskPaths = []string{"config/operations.txt"}
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".product-loop/project.json")
- runGit(t, repo, "commit", "-m", "configure high-risk path")
- runGit(t, repo, "switch", "-c", "feat/operations")
- path := filepath.Join(repo, "config", "operations.txt")
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("terraform destroy -auto-approve\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "config/operations.txt")
- runGit(t, repo, "commit", "-m", "add operational instruction")
- report, err := CheckRepositorySafety(repo)
- if err != nil || report.Status != "BLOCKED" {
- t.Fatalf("configured high-risk path was not scanned: %#v %v", report, err)
- }
-}
-
-func TestSafetyGuardLatencyIsBounded(t *testing.T) {
- repo := safetyTestRepo(t)
- started := time.Now()
- for index := 0; index < 1000; index++ {
- ClassifyCommand(repo, `git status --short`)
- ClassifyCommand(repo, `psql -c "DROP SCHEMA public CASCADE"`)
- }
- if elapsed := time.Since(started); elapsed > 2*time.Second {
- t.Fatalf("2,000 safety classifications exceeded the 2s fixture bound: %s", elapsed)
- }
-}
-
-// TestPlanningMarkdownPathRejectsIntakeStaging is the conformance guard that the
-// removed intake staging directory is no longer a permitted planning-write path;
-// only feature-scoped planning artifacts remain writable.
-func TestPlanningMarkdownPathRejectsIntakeStaging(t *testing.T) {
- rejected := []string{
- ".product-loop/intake/source-plan.md",
- ".product-loop/intake/anything.md",
- }
- for _, path := range rejected {
- if planningMarkdownPath(path) {
- t.Errorf("intake staging path must no longer be a permitted planning-write path: %s", path)
- }
- }
- if !planningMarkdownPath(".product-loop/features/account-recovery/plan.md") {
- t.Fatal("feature-scoped planning artifacts must remain writable")
- }
-}
-
-// Positive and relation conformance for
-// control-law: ambient-plans-never-activate-workflow-control.
-// A saved draft is visible to Boatstack, but it supplies no authority over
-// ordinary repository tools. The hook retains its always-on destructive floor
-// and the separate owned planning-state boundary.
-func TestSavedPlanDoesNotActivateWorkflowControl(t *testing.T) {
- repo := nextTestRepo(t)
- writeValidSavedFeaturePlan(t, repo, "guarded-feature")
- statusBefore, err := gitCommand(repo, "status", "--short")
- if err != nil {
- t.Fatal(err)
- }
-
- assertAllowed := func(label string, findings []SafetyFinding) {
- t.Helper()
- if len(findings) != 0 {
- t.Fatalf("%s was controlled by an ambient draft: %#v", label, findings)
- }
- }
- assertAllowed("native edit", ClassifyTool(repo, "Write", map[string]any{"file_path": "src/app.ts", "content": "changed"}))
- assertAllowed("patch", ClassifyTool(repo, "ApplyPatch", map[string]any{"path": "src/app.ts", "patch": "diff"}))
- assertAllowed("shell redirection", ClassifyCommand(repo, "printf changed > src/app.ts"))
- assertAllowed("package installation", ClassifyCommand(repo, "npm install example"))
- assertAllowed("analysis CLI", ClassifyCommand(repo, "analysis-tool inspect --model system.json"))
- assertAllowed("test runner", ClassifyCommand(repo, "go test ./..."))
- assertAllowed("MCP mutation", ClassifyTool(repo, "mcp__files__update", map[string]any{"path": "src/app.ts"}))
- assertAllowed("unknown MCP capability", ClassifyTool(repo, "mcp__files__act", map[string]any{"path": "src/app.ts"}))
-
- if findings := ClassifyCommand(repo, "git status --short"); len(findings) != 0 {
- t.Fatalf("read-only inspection was denied: %#v", findings)
- }
- if findings := ClassifyTool(repo, "mcp__files__read", map[string]any{"path": "src/app.ts"}); len(findings) != 0 {
- t.Fatalf("explicitly read-only MCP inspection was denied: %#v", findings)
- }
- if findings := ClassifyCommand(repo, ".product-loop/boatstack check-plan --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 {
- t.Fatalf("bounded plan inspection was denied: %#v", findings)
- }
- if findings := ClassifyTool(repo, "Write", map[string]any{"file_path": ".product-loop/features/guarded-feature/plan.md", "content": "# revised plan"}); len(findings) == 0 || findings[0].NextOperation != "planning-write" {
- t.Fatalf("raw managed planning write escaped the owned channel: %#v", findings)
- }
- if findings := ClassifyCommand(repo, ".product-loop/boatstack record-approval --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 {
- t.Fatalf("exact approval transition was denied: %#v", findings)
- }
- statusAfter, err := gitCommand(repo, "status", "--short")
- if err != nil {
- t.Fatal(err)
- }
- if statusAfter != statusBefore {
- t.Fatalf("denied operations changed the worktree: before=%q after=%q", statusBefore, statusAfter)
- }
-}
-
-func TestUnactivatedApprovalAndPolicyStateRemainObservations(t *testing.T) {
- unmanaged := nextTestRepo(t)
- if findings := ClassifyTool(unmanaged, "Write", map[string]any{"file_path": "src/app.ts"}); len(findings) != 0 {
- t.Fatalf("unmanaged product editing changed: %#v", findings)
- }
-
- approved := nextTestRepo(t)
- planPath := writeValidSavedFeaturePlan(t, approved, "approved-feature")
- runGit(t, approved, "config", "user.name", "Boatstack Test")
- runGit(t, approved, "config", "user.email", "boatstack@example.invalid")
- runGit(t, approved, "add", ".")
- runGit(t, approved, "commit", "-m", "record approved plan")
- approvalPath := filepath.Join(approved, ".product-loop", "features", "approved-feature", "approval.md")
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- writeApprovalReceipt(t, approvalPath, check.Fingerprint)
- if _, err := CheckApprovalReceipt(approvalPath, check); err != nil {
- t.Fatal(err)
- }
- findings := ClassifyTool(approved, "Edit", map[string]any{"path": "src/app.ts"})
- if len(findings) != 0 {
- t.Fatalf("approved-but-not-activated plan controlled product tools: %#v", findings)
- }
-
- policy := nextTestRepo(t)
- config := testConfig()
- config.Workflow.HumanPlanApproval = false
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(policy, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- writeValidSavedFeaturePlan(t, policy, "policy-feature")
- findings = ClassifyTool(policy, "Write", map[string]any{"path": "src/app.ts"})
- if len(findings) != 0 {
- t.Fatalf("policy-ready but unactivated plan controlled product tools: %#v", findings)
- }
-}
-
-func TestCursorPreToolUseIsSilentWithDormantDraft(t *testing.T) {
- repo := nextTestRepo(t)
- writeValidSavedFeaturePlan(t, repo, "cursor-feature")
- input := []byte(`{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`)
- for attempt := 0; attempt < 2; attempt++ {
- output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: input})
- if denied || len(output) != 0 {
- t.Fatalf("Cursor dormant probe produced a policy effect: %s", output)
- }
- }
-}
-
-// Relation conformance: every supported host reaches the same dormant boundary.
-func TestDormantDraftDoesNotControlNativeEditAcrossHostContracts(t *testing.T) {
- repo := nextTestRepo(t)
- writeValidSavedFeaturePlan(t, repo, "host-conformance")
- tests := map[string][]byte{
- "cursor": []byte(`{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`),
- "claude": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`),
- "codex": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`),
- "gemini": []byte(`{"hook_event_name":"BeforeTool","tool_name":"write_file","tool_input":{"file_path":"src/app.ts","content":"changed"}}`),
- }
- for host, input := range tests {
- t.Run(host, func(t *testing.T) {
- output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: input})
- if denied || len(output) != 0 {
- t.Fatalf("%s dormant probe produced a policy effect: %s", host, output)
- }
- })
- }
-}
diff --git a/boatstack/safety_update_publisher_test.go b/boatstack/safety_update_publisher_test.go
deleted file mode 100644
index d050eb2..0000000
--- a/boatstack/safety_update_publisher_test.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package boatstack
-
-import (
- "path/filepath"
- "strings"
- "testing"
-)
-
-// updatePreviewArg is the update preview path the version-update publisher must be
-// given. It lives under /boatstack/updates/, so every publish-update-pr
-// command names a path inside the .git/boatstack/ subtree that the tamper guard
-// watches. The exemption exists precisely so that path, passed as a read argument to
-// the trusted helper, is not mistaken for a direct edit of runtime authority.
-func updatePreviewArg(repo string) string {
- return filepath.Join(repo, ".git", "boatstack", "updates", "v0.7.68", "pr-preview.json")
-}
-
-// The sanctioned version-update publisher must run in-session. Before the exemption,
-// deliveryStatePathPattern matched the .git/boatstack/ preview argument and the command
-// was denied as workflow-state-tamper — the exact reason self-updates could only be
-// published from outside the guarded session.
-func TestUpdatePublisherIsExemptFromStateTamper(t *testing.T) {
- repo := safetyTestRepo(t)
- preview := updatePreviewArg(repo)
- fingerprint := strings.Repeat("a", 64)
- commands := map[string]string{
- "POSIX launcher": ".product-loop/boatstack publish-update-pr --repo . --preview " + preview + " --preview-fingerprint " + fingerprint,
- "PowerShell launcher": "& '.product-loop\\boatstack.ps1' publish-update-pr --repo . --preview " + preview + " --preview-fingerprint " + fingerprint,
- }
- for name, command := range commands {
- t.Run(name, func(t *testing.T) {
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Fatalf("the update publisher was denied: %#v", findings)
- }
- })
- }
-}
-
-func TestUpdatePublisherRejectsForeignRuntimeEntrypoints(t *testing.T) {
- repo := safetyTestRepo(t)
- preview := updatePreviewArg(repo)
- fingerprint := strings.Repeat("a", 64)
- for name, command := range map[string]string{
- "internal helper": ".product-loop/bin/boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint,
- "foreign helper": "/tmp/boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint,
- "launcher alias": "/tmp/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint,
- } {
- t.Run(name, func(t *testing.T) {
- findings := ClassifyCommand(repo, command)
- if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" {
- t.Fatalf("foreign runtime entrypoint was admitted: %#v", findings)
- }
- })
- }
-}
-
-// The exemption must hold in the state that actually blocked real updates: a repo with
-// an active managed delivery. The tamper branch runs before the delivery-aware checks,
-// so the publish must pass through regardless of delivery state.
-func TestUpdatePublisherIsExemptWithActiveDelivery(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64),
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD"}},
- }); err != nil {
- t.Fatal(err)
- }
- command := ".product-loop/boatstack publish-update-pr --repo . --preview " + updatePreviewArg(repo) + " --preview-fingerprint " + strings.Repeat("a", 64)
- if findings := ClassifyCommand(repo, command); len(findings) != 0 {
- t.Fatalf("the update publisher was denied while a delivery was active: %#v", findings)
- }
-}
-
-// The exemption is narrow. A direct write to any .git/boatstack/ path is still denied.
-// This proves the exemption did not weaken tamper protection.
-func TestDirectWritesUnderBoatstackStillDenied(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64),
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD"}},
- }); err != nil {
- t.Fatal(err)
- }
- statePath, err := deliveryStatePath(repo, "phased-feature")
- if err != nil {
- t.Fatal(err)
- }
- denied := map[string]string{
- "remove update preview": "rm " + updatePreviewArg(repo),
- "remove delivery state": "rm " + statePath,
- "overwrite state": "printf broken > " + statePath,
- }
- for name, command := range denied {
- t.Run(name, func(t *testing.T) {
- findings := ClassifyCommand(repo, command)
- if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" {
- t.Fatalf("a direct write under .git/boatstack/ was not denied: %#v", findings)
- }
- })
- }
-}
-
-// The exemption is anchored end to end, so no second command can be smuggled after the
-// publisher. A chained command still names the preview path and is still denied.
-func TestUpdatePublisherExemptionRejectsChaining(t *testing.T) {
- repo := safetyTestRepo(t)
- preview := updatePreviewArg(repo)
- fingerprint := strings.Repeat("a", 64)
- for name, command := range map[string]string{
- "semicolon": ".product-loop/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + "; rm -rf important",
- "and": ".product-loop/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + " && rm -rf important",
- "pipe": ".product-loop/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + " | tee steal",
- } {
- t.Run(name, func(t *testing.T) {
- findings := ClassifyCommand(repo, command)
- if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" {
- t.Fatalf("a chained command escaped the tamper guard: %#v", findings)
- }
- })
- }
-}
-
-// The pre-existing approved-publisher exemption for publish-pr had no regression test.
-// Close that gap: while a delivery is active, the sanctioned publish-pr helper stays
-// allowed even though direct pushes and PR creation are denied.
-func TestApprovedPublishPRStaysAllowedDuringActiveDelivery(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64),
- ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "REVIEW", HeadBranch: "main"}},
- }); err != nil {
- t.Fatal(err)
- }
- if findings := ClassifyCommand(repo, "gh pr create --title phase-one"); len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" {
- t.Fatalf("sanity: a direct PR creation should be denied while a delivery is active: %#v", findings)
- }
- allowed := ".product-loop/boatstack publish-pr --preview .product-loop/features/phased-feature/pr.md --preview-fingerprint " + strings.Repeat("a", 64) + " --action create"
- if findings := ClassifyCommand(repo, allowed); len(findings) != 0 {
- t.Fatalf("the sanctioned publish-pr helper was denied: %#v", findings)
- }
-}
diff --git a/boatstack/sdk/sdk.go b/boatstack/sdk/sdk.go
new file mode 100644
index 0000000..f4c8ad3
--- /dev/null
+++ b/boatstack/sdk/sdk.go
@@ -0,0 +1,88 @@
+// Package sdk exposes the versioned Boatstack V2 surface protocol without
+// exposing or duplicating the internal controller implementation.
+package sdk
+
+import (
+ "context"
+
+ boatstack "github.com/operatorstack/boatstack/boatstack"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/model"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/protocol"
+ "github.com/operatorstack/boatstack/boatstack/internal/kernel/supervisor"
+ "github.com/operatorstack/boatstack/boatstack/internal/surfaces"
+)
+
+const SchemaVersion = surfaces.SchemaVersion
+
+type Operation = surfaces.Operation
+
+const (
+ OperationResolve = surfaces.OperationResolve
+ OperationApply = surfaces.OperationApply
+ OperationRecover = surfaces.OperationRecover
+ OperationDoctor = surfaces.OperationDoctor
+ OperationCatalog = surfaces.OperationCatalog
+ OperationEvents = surfaces.OperationEvents
+ OperationGuard = surfaces.OperationGuard
+)
+
+type Request = surfaces.Request
+type Response = surfaces.Response
+type DoctorReport = surfaces.DoctorReport
+type Goal = model.Goal
+type GoalKind = model.GoalKind
+
+const (
+ GoalApprovedPlan = model.GoalApprovedPlan
+ GoalVerified = model.GoalVerified
+ GoalOpenPR = model.GoalOpenPR
+ GoalMerged = model.GoalMerged
+ GoalAbandoned = model.GoalAbandoned
+)
+
+type TransitionID = catalog.TransitionID
+type Transition = catalog.Transition
+type AuthorityClass = catalog.AuthorityClass
+
+const (
+ AuthorityRepository = catalog.AuthorityRepository
+ AuthorityHuman = catalog.AuthorityHuman
+ AuthorityAutonomy = catalog.AuthorityAutonomy
+ AuthorityProvider = catalog.AuthorityProvider
+)
+
+type AuthorityReceipt = protocol.AuthorityReceipt
+type AuthorityBundle = protocol.AuthorityBundle
+type Parameter = protocol.Parameter
+type Parameters = protocol.Parameters
+type Admission = protocol.Admission
+type TransitionReceipt = protocol.TransitionReceipt
+type Decision = supervisor.Decision
+type DecisionKind = supervisor.DecisionKind
+type GuardDecision = supervisor.GuardDecision
+
+const (
+ DecisionPrescribed = supervisor.DecisionPrescribed
+ DecisionTerminal = supervisor.DecisionTerminal
+ DecisionFrontier = supervisor.DecisionFrontier
+ DecisionBlocked = supervisor.DecisionBlocked
+ DecisionRefused = supervisor.DecisionRefused
+ DecisionUnresolved = supervisor.DecisionUnresolved
+)
+
+// Client is the only supported in-process V2 entry point. It delegates every
+// decision and effect to the same kernel used by the CLI.
+type Client struct{ kernel boatstack.V2Kernel }
+
+func New(externalStateRoot string) (Client, error) {
+ kernel, err := boatstack.NewV2Kernel(externalStateRoot)
+ if err != nil {
+ return Client{}, err
+ }
+ return Client{kernel: kernel}, nil
+}
+
+func (c Client) Do(ctx context.Context, request Request) (Response, error) {
+ return c.kernel.Handle(ctx, request)
+}
diff --git a/boatstack/sdk/sdk_test.go b/boatstack/sdk/sdk_test.go
new file mode 100644
index 0000000..610acbf
--- /dev/null
+++ b/boatstack/sdk/sdk_test.go
@@ -0,0 +1,21 @@
+package sdk_test
+
+import (
+ "testing"
+
+ "github.com/operatorstack/boatstack/boatstack/sdk"
+)
+
+func TestPublicProtocolCanBeConstructedWithoutInternalPackages(t *testing.T) {
+ request := sdk.Request{
+ SchemaVersion: sdk.SchemaVersion,
+ Operation: sdk.OperationResolve,
+ Repository: t.TempDir(),
+ Host: "mcp",
+ CorrelationID: "correlation",
+ Goal: sdk.Goal{ID: "goal", Kind: sdk.GoalVerified, DeliveryID: "delivery"},
+ }
+ if request.Goal.Kind != sdk.GoalVerified || request.Operation != sdk.OperationResolve {
+ t.Fatalf("public V2 aliases lost protocol identity: %#v", request)
+ }
+}
diff --git a/boatstack/skill_frontmatter.go b/boatstack/skill_frontmatter.go
deleted file mode 100644
index afbc819..0000000
--- a/boatstack/skill_frontmatter.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "io"
- "path/filepath"
- "strings"
-
- "go.yaml.in/yaml/v3"
-)
-
-var codexSkillFields = map[string]string{
- "name": "!!str",
- "description": "!!str",
-}
-
-var claudeSkillFields = map[string]string{
- "name": "!!str",
- "description": "!!str",
- "argument-hint": "!!str",
- "user-invocable": "!!bool",
- "disable-model-invocation": "!!bool",
-}
-
-func validateGeneratedSkills(files map[string][]byte) error {
- for _, path := range sortedKeys(files) {
- if !strings.HasSuffix(path, "/SKILL.md") {
- continue
- }
- if err := validateSkillFrontmatter(path, files[path]); err != nil {
- return fmt.Errorf("validate generated skill %s: %w", path, err)
- }
- }
- return nil
-}
-
-func validateSkillFrontmatter(path string, raw []byte) error {
- const opening = "---\n"
- if !strings.HasPrefix(string(raw), opening) {
- return fmt.Errorf("must start with YAML frontmatter delimiter ---")
- }
- closing := strings.Index(string(raw[len(opening):]), "\n---\n")
- if closing < 0 {
- return fmt.Errorf("frontmatter is missing its closing ---")
- }
- frontmatter := raw[len(opening) : len(opening)+closing]
- if strings.ContainsRune(string(frontmatter), '\t') {
- return fmt.Errorf("frontmatter must not contain tabs")
- }
-
- var document yaml.Node
- decoder := yaml.NewDecoder(strings.NewReader(string(frontmatter)))
- if err := decoder.Decode(&document); err != nil {
- return fmt.Errorf("parse YAML frontmatter: %w", err)
- }
- var extra yaml.Node
- if err := decoder.Decode(&extra); err != io.EOF {
- if err == nil {
- return fmt.Errorf("frontmatter must contain exactly one YAML document")
- }
- return fmt.Errorf("parse YAML frontmatter: %w", err)
- }
- if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
- return fmt.Errorf("frontmatter must be one YAML mapping")
- }
-
- allowed, err := skillFieldsForPath(path)
- if err != nil {
- return err
- }
- root := document.Content[0]
- fields := map[string]*yaml.Node{}
- for index := 0; index < len(root.Content); index += 2 {
- key := root.Content[index]
- value := root.Content[index+1]
- if key.Kind != yaml.ScalarNode || key.Tag != "!!str" {
- return fmt.Errorf("frontmatter keys must be strings")
- }
- if key.Column != 1 {
- return fmt.Errorf("top-level key %q must start at column 1", key.Value)
- }
- expectedTag, ok := allowed[key.Value]
- if !ok {
- return fmt.Errorf("unsupported frontmatter field %q", key.Value)
- }
- if _, duplicate := fields[key.Value]; duplicate {
- return fmt.Errorf("duplicate frontmatter field %q", key.Value)
- }
- if value.Kind != yaml.ScalarNode || value.Tag != expectedTag {
- return fmt.Errorf("frontmatter field %q must have type %s", key.Value, yamlTypeName(expectedTag))
- }
- fields[key.Value] = value
- }
-
- for _, required := range []string{"name", "description"} {
- value, ok := fields[required]
- if !ok || strings.TrimSpace(value.Value) == "" {
- return fmt.Errorf("frontmatter field %q is required and must not be empty", required)
- }
- }
- expectedName := filepath.Base(filepath.Dir(filepath.FromSlash(path)))
- if fields["name"].Value != expectedName {
- return fmt.Errorf("frontmatter name %q must match skill directory %q", fields["name"].Value, expectedName)
- }
- return nil
-}
-
-func skillFieldsForPath(path string) (map[string]string, error) {
- switch {
- case strings.HasPrefix(path, ".agents/skills/"):
- return codexSkillFields, nil
- case strings.HasPrefix(path, ".claude/skills/"):
- return claudeSkillFields, nil
- case strings.HasPrefix(path, ".gemini/skills/"):
- return claudeSkillFields, nil
- default:
- return nil, fmt.Errorf("unsupported generated skill path")
- }
-}
-
-func yamlTypeName(tag string) string {
- if tag == "!!bool" {
- return "boolean"
- }
- return "string"
-}
diff --git a/boatstack/skill_frontmatter_test.go b/boatstack/skill_frontmatter_test.go
deleted file mode 100644
index a80cb8c..0000000
--- a/boatstack/skill_frontmatter_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package boatstack
-
-import (
- "strings"
- "testing"
-)
-
-func TestGeneratedSkillFrontmatterIsValidYAML(t *testing.T) {
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
-
- skillCount := 0
- for path, value := range bundle.Files {
- if !strings.HasSuffix(path, "/SKILL.md") {
- continue
- }
- skillCount++
- if err := validateSkillFrontmatter(path, value); err != nil {
- t.Errorf("%s has invalid frontmatter: %v", path, err)
- }
- }
- expected := 0
- if contains(config.Adapters, "claude") {
- expected += len(claudeVisibleSkills) + 1
- }
- if contains(config.Adapters, "gemini") {
- expected += len(claudeVisibleSkills) + 1
- }
- if contains(config.Adapters, "codex") {
- expected += len(claudeVisibleSkills) + 1
- }
- if skillCount != expected {
- t.Fatalf("validated %d generated skills, want %d", skillCount, expected)
- }
-}
-
-func TestBoatstackRoutersHaveUnindentedTopLevelKeys(t *testing.T) {
- config := testConfig()
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
-
- for _, path := range []string{
- ".agents/skills/boatstack/SKILL.md",
- ".claude/skills/boatstack/SKILL.md",
- ".gemini/skills/boatstack/SKILL.md",
- } {
- frontmatter := skillFrontmatterForTest(t, bundle.Files[path])
- if !strings.Contains(frontmatter, "\ndescription:") {
- t.Errorf("%s description is not at column 1:\n%s", path, frontmatter)
- }
- for _, line := range strings.Split(frontmatter, "\n") {
- if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
- t.Errorf("%s has an indented top-level key %q", path, line)
- }
- }
- }
-}
-
-func TestValidateSkillFrontmatterRejectsMalformedMetadata(t *testing.T) {
- validCodex := "---\nname: boatstack\ndescription: Use when testing.\n---\n\n# Boatstack\n"
- validClaude := "---\nname: boatstack\ndescription: Use when testing.\nuser-invocable: false\n---\n\n# Boatstack\n"
- tests := []struct {
- name string
- path string
- raw string
- want string
- }{
- {"tab indentation", ".agents/skills/boatstack/SKILL.md", "---\nname: boatstack\n\tdescription: broken\n---\n", "must not contain tabs"},
- {"space indentation", ".agents/skills/boatstack/SKILL.md", "---\n name: boatstack\n description: broken\n---\n", "column 1"},
- {"invalid YAML", ".agents/skills/boatstack/SKILL.md", "---\nname: [\ndescription: broken\n---\n", "parse YAML"},
- {"missing opening delimiter", ".agents/skills/boatstack/SKILL.md", "name: boatstack\n", "must start"},
- {"missing closing delimiter", ".agents/skills/boatstack/SKILL.md", "---\nname: boatstack\n", "missing its closing"},
- {"duplicate field", ".agents/skills/boatstack/SKILL.md", "---\nname: boatstack\nname: boatstack\ndescription: duplicate\n---\n", "duplicate"},
- {"missing description", ".agents/skills/boatstack/SKILL.md", "---\nname: boatstack\n---\n", "description"},
- {"empty description", ".agents/skills/boatstack/SKILL.md", "---\nname: boatstack\ndescription: ' '\n---\n", "must not be empty"},
- {"unsupported Codex field", ".agents/skills/boatstack/SKILL.md", strings.Replace(validCodex, "description:", "user-invocable: false\ndescription:", 1), "unsupported"},
- {"wrong Claude type", ".claude/skills/boatstack/SKILL.md", strings.Replace(validClaude, "user-invocable: false", "user-invocable: no", 1), "boolean"},
- {"directory mismatch", ".agents/skills/other/SKILL.md", validCodex, "must match skill directory"},
- {"unsupported host", ".other/skills/boatstack/SKILL.md", validCodex, "unsupported generated skill path"},
- {"non-mapping", ".agents/skills/boatstack/SKILL.md", "---\n- name\n- description\n---\n", "one YAML mapping"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- err := validateSkillFrontmatter(test.path, []byte(test.raw))
- if err == nil || !strings.Contains(err.Error(), test.want) {
- t.Fatalf("got %v, want error containing %q", err, test.want)
- }
- })
- }
-}
-
-func TestGeneratedSkillHarnessRejectsInvalidBundle(t *testing.T) {
- files := map[string][]byte{
- ".agents/skills/boatstack/SKILL.md": []byte("---\nname: boatstack\n\tdescription: broken\n---\n"),
- }
- err := validateGeneratedSkills(files)
- if err == nil || !strings.Contains(err.Error(), ".agents/skills/boatstack/SKILL.md") {
- t.Fatalf("generated-skill boundary did not name and reject invalid output: %v", err)
- }
-}
-
-func skillFrontmatterForTest(t *testing.T, raw []byte) string {
- t.Helper()
- value := string(raw)
- closing := strings.Index(value[4:], "\n---\n")
- if !strings.HasPrefix(value, "---\n") || closing < 0 {
- t.Fatalf("test fixture lacks frontmatter delimiters: %q", value)
- }
- return value[:4+closing]
-}
diff --git a/boatstack/solution_closure_conformance_test.go b/boatstack/solution_closure_conformance_test.go
deleted file mode 100644
index ea37b14..0000000
--- a/boatstack/solution_closure_conformance_test.go
+++ /dev/null
@@ -1,274 +0,0 @@
-package boatstack
-
-import (
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
-)
-
-// control-law: guard-never-prescribes-what-it-would-deny
-// control-law: solution-set-derives-from-guard-declarations
-//
-// The sibling harness's v2.9 campaign lost trials to exactly this defect
-// class: the system compiled an artifact its own laws then rejected. The
-// solution set makes that class structurally testable here — every command the
-// enumerator presents as a legal pick must be admitted by the guard at the
-// exact position that emitted it, and must be a legal or observation move on
-// the declared delivery model. These sweeps hold both consumers of the guard
-// tables to the same rows.
-
-// substituteOwedFlags replaces placeholders with a dummy value so a
-// rendered command can be fed back to the guard (which rejects angle brackets
-// as shell metacharacters). The closure property is defined over substituted
-// lines: what the user runs after filling the owed input.
-func substituteOwedFlags(line string) string {
- line = strings.ReplaceAll(line, "'--feature' ''", "'--feature' 'demo'")
- line = strings.ReplaceAll(line, "'--artifact' ''", "'--artifact' 'plan.md'")
- line = strings.ReplaceAll(line, "'--source-plan' ''", "'--source-plan' 'README.md'")
- line = strings.ReplaceAll(line, "'--shell' ''", "'--shell' 'posix'")
- line = strings.ReplaceAll(line, "--feature ''", "--feature demo")
- line = strings.ReplaceAll(line, "--artifact ''", "--artifact plan.md")
- line = strings.ReplaceAll(line, "--source-plan ''", "--source-plan README.md")
- line = strings.ReplaceAll(line, "--shell ''", "--shell posix")
- line = strings.ReplaceAll(line, "--feature ", "--feature demo")
- line = strings.ReplaceAll(line, "--artifact ", "--artifact plan.md")
- line = strings.ReplaceAll(line, "--source-plan ", "--source-plan README.md")
- line = strings.ReplaceAll(line, "--shell ", "--shell posix")
- return strings.ReplaceAll(line, "", "test-value")
-}
-
-// solutionOptions is the full pick list of a position: the primary plus the
-// alternatives.
-func solutionOptions(next FlowNext) []PrescribedCommand {
- var options []PrescribedCommand
- if next.Prescribed != nil {
- options = append(options, *next.Prescribed)
- }
- return append(options, next.Alternatives...)
-}
-
-// deliveryStages maps each oracle-resolved stage to a synthetic VERIFIED
-// status, mirroring planningStages for the delivery side.
-var deliveryStages = []NextStatus{
- {VerificationStatus: "VERIFIED", ObservedStage: "BUILD", NextOperation: "build", Feature: "demo", ActiveSlice: "s1"},
- {VerificationStatus: "VERIFIED", ObservedStage: "TEST_PASSED", NextOperation: "review-gate", Feature: "demo", ActiveSlice: "s1"},
- {VerificationStatus: "VERIFIED", ObservedStage: "REVIEW_PASSED", NextOperation: "ship-gate", Feature: "demo", ActiveSlice: "s1"},
- {VerificationStatus: "VERIFIED", ObservedStage: "PUBLISHED", NextOperation: "none", Feature: "demo", ActiveSlice: "s1"},
- {VerificationStatus: "VERIFIED", ObservedStage: "AMENDMENT_REQUIRED", NextOperation: "amend-plan", Feature: "demo", ActiveSlice: "s1"},
- {VerificationStatus: "VERIFIED", ObservedStage: "AMENDMENT_DRAFTED", NextOperation: "plan-gate", Feature: "demo", ActiveSlice: "s1"},
- {VerificationStatus: "VERIFIED", ObservedStage: "AMENDMENT_APPROVED", NextOperation: "build", Feature: "demo", ActiveSlice: "s1"},
- {VerificationStatus: "VERIFIED", ObservedStage: "PLAN_INVALID", NextOperation: "amend-plan", Feature: "demo", ActiveSlice: "s1"},
-}
-
-// Positive/Relation: every pre-activation option is admitted by
-// controlledPhaseTransition at the emitting stage — the guard admits its own
-// solution set, verb for verb, from the same tables.
-func TestPlanningSolutionSetIsClosedUnderGuardAdmission(t *testing.T) {
- for _, status := range planningStages {
- next, err := nextControlFromStatus(".", status)
- if err != nil {
- t.Fatal(err)
- }
- options := solutionOptions(next)
- if len(options) == 0 {
- t.Errorf("%s/%s: the solution set must never be empty at a pre-activation stage", status.ObservedStage, status.NextOperation)
- }
- for _, option := range options {
- line := substituteOwedFlags(option.CommandLine())
- if !controlledPhaseTransition(line, status.ObservedStage) {
- t.Errorf("%s/%s: enumerated %q but the guard denies it at that stage", status.ObservedStage, status.NextOperation, line)
- }
- }
- }
- // AMBIGUOUS keeps its documented no-primary exception, but the solution set
- // still names the legal observations — a weak model always has a pick.
- next, err := nextControlFromStatus(".", NextStatus{ObservedStage: "AMBIGUOUS"})
- if err != nil {
- t.Fatal(err)
- }
- if next.Prescribed != nil {
- t.Fatalf("AMBIGUOUS must not gain a fabricated primary: %+v", next.Prescribed)
- }
- if len(next.Alternatives) == 0 {
- t.Fatal("AMBIGUOUS must still enumerate read-only picks (next-status, doctor)")
- }
- for _, option := range next.Alternatives {
- if !controlledPhaseTransition(substituteOwedFlags(option.CommandLine()), "AMBIGUOUS") {
- t.Errorf("AMBIGUOUS pick %q must be guard-admitted", option.CommandLine())
- }
- }
-}
-
-// Relation/effect: the prescribed workspace command must not merely pass the
-// text guard. Its verified post-state must expose the identical plan on the
-// feature branch and make activation the next concrete command after approval.
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-// control-law: workspace-transition-preserves-plan-authority
-func TestPrescribedWorkspaceTransitionReachesActivatableState(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one")
- next, err := NextControl(repo, "")
- if err != nil || next.Prescribed == nil || next.Prescribed.Verb != "workspace-cut" {
- t.Fatalf("valid draft did not prescribe workspace-cut: %+v (%v)", next, err)
- }
- result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if err != nil || result.VerificationStatus != "VERIFIED" || result.PlanFingerprint != fingerprint {
- t.Fatalf("prescribed transition did not establish its post-state: %+v (%v)", result, err)
- }
- planPath := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md")
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- writeApprovalReceipt(t, filepath.Join(filepath.Dir(planPath), "approval.md"), check.Fingerprint)
- after, err := NextControl(result.DestinationRepo, "")
- if err != nil || after.Prescribed == nil || after.Prescribed.Verb != "activate-plan" {
- t.Fatalf("workspace post-state is not activatable: %+v (%v)", after, err)
- }
-}
-
-// Positive/Relation: every delivery-state option is either a legal move on the
-// registry graph from that exact state, or an observation row that accepts the
-// state. Nothing outside the declared model is ever offered.
-func TestDeliverySolutionSetIsLegalOnTheDeclaredModel(t *testing.T) {
- graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights())
- for _, status := range deliveryStages {
- next, err := nextControlFromStatus(".", status)
- if err != nil {
- t.Fatal(err)
- }
- if !next.Resolved {
- t.Fatalf("%s must resolve a flow state", status.ObservedStage)
- }
- options := solutionOptions(next)
- if len(options) == 0 {
- t.Errorf("%s: the solution set must never be empty at a resolved state", status.ObservedStage)
- }
- for _, option := range options {
- descriptor, isRegistry := deliverycontrol.Transition(option.Transition)
- if !isRegistry {
- t.Errorf("%s: delivery option %s carries a non-registry transition %s", status.ObservedStage, option.Verb, option.Transition)
- continue
- }
- if descriptor.To != "" {
- if !graph.IsLegalMove(next.State, option.Transition) {
- t.Errorf("%s: %s is not a legal move from %s", status.ObservedStage, option.Transition, next.State)
- }
- continue
- }
- if descriptor.From != nil && !transitionAccepts(descriptor, next.State) {
- t.Errorf("%s: observation %s does not accept state %s", status.ObservedStage, option.Transition, next.State)
- }
- }
- }
-}
-
-// Bypass: no rendered option, after owed-input substitution, may trip the
-// text-level guard laws — the managed-state path law and the destruction
-// classifier. This closes the publish-update-pr regression class end to end:
-// the guard denying its own prescribed command line.
-func TestSolutionSetCommandsPassTheTextGuards(t *testing.T) {
- statuses := append(append([]NextStatus{}, planningStages...), deliveryStages...)
- for _, status := range statuses {
- next, err := nextControlFromStatus(".", status)
- if err != nil {
- t.Fatal(err)
- }
- for _, option := range solutionOptions(next) {
- line := substituteOwedFlags(option.CommandLine())
- if deliveryStatePathPattern.MatchString(line) && !isPureReadOnlyCommand(line) && !approvedUpdatePublisherPattern.MatchString(line) {
- t.Errorf("%s: prescribed %q names managed state the guard would deny", status.ObservedStage, line)
- }
- if findings := classifySafetyText(line, "command", commandExecutesLiveSQL(line)); len(findings) > 0 {
- t.Errorf("%s: prescribed %q trips the text guard: %+v", status.ObservedStage, line, findings)
- }
- }
- }
-}
-
-// End to end: in a real DRAFT_PLAN repository, every enumerated option passes
-// the full command classifier — zero findings, not just the phase interlock.
-func TestDraftPlanSolutionSetPassesFullClassifier(t *testing.T) {
- repo := nextTestRepo(t)
- writeSavedFeaturePlan(t, repo, "demo")
- next, err := NextControl(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- options := solutionOptions(next)
- if len(options) < 3 {
- t.Fatalf("DRAFT_PLAN should enumerate the primary plus alternatives, got %d: %+v", len(options), options)
- }
- for _, option := range options {
- line := substituteOwedFlags(option.CommandLine())
- if findings := ClassifyCommand(repo, line); len(findings) > 0 {
- t.Errorf("guard denies its own prescription %q: %+v", line, findings)
- }
- }
-}
-
-// Failure-state/Invariants: dedup identity holds, the cap holds, the primary
-// never reappears in Alternatives, and owed flags never leak into Args.
-func TestSolutionSetInvariants(t *testing.T) {
- statuses := append(append([]NextStatus{}, planningStages...), deliveryStages...)
- for _, status := range statuses {
- next, err := nextControlFromStatus(".", status)
- if err != nil {
- t.Fatal(err)
- }
- if len(next.Alternatives) > solutionSetCap {
- t.Errorf("%s: alternatives exceed the cap: %d", status.ObservedStage, len(next.Alternatives))
- }
- seen := map[string]bool{}
- if next.Prescribed != nil {
- seen[prescriptionKey(*next.Prescribed)] = true
- }
- for _, option := range next.Alternatives {
- key := prescriptionKey(option)
- if seen[key] {
- t.Errorf("%s: duplicate or primary-shadowing option %q", status.ObservedStage, option.CommandLine())
- }
- seen[key] = true
- if option.AutoDerivable != (len(option.RequiresHumanInput) == 0) {
- t.Errorf("%s: AutoDerivable must equal owed-input emptiness: %+v", status.ObservedStage, option)
- }
- for _, owed := range option.RequiresHumanInput {
- for _, arg := range option.Args {
- if arg == owed {
- t.Errorf("%s: owed flag %s fabricated into Args: %+v", status.ObservedStage, owed, option)
- }
- }
- }
- }
- }
-}
-
-// Rendering: the text carriers keep exactly one primary Run line; alternatives
-// are one line (flow) or one sentence (response), capped.
-func TestSolutionSetRenderingKeepsOnePrimary(t *testing.T) {
- status := deliveryStages[1] // TEST_PASSED: gate, rework, abandon all legal
- next, err := nextControlFromStatus(".", status)
- if err != nil {
- t.Fatal(err)
- }
- out := FormatFlowNext(next)
- if got := strings.Count(out, "Run: "); got != 1 {
- t.Fatalf("flow rendering must keep exactly one Run line, got %d:\n%s", got, out)
- }
- if !strings.Contains(out, "Also legal from here: ") {
- t.Fatalf("flow rendering must name the other legal moves:\n%s", out)
- }
- line := ""
- for _, candidate := range strings.Split(out, "\n") {
- if strings.HasPrefix(candidate, "Also legal from here: ") {
- line = candidate
- }
- }
- if got := strings.Count(line, ","); got > solutionSetTextCap-1 {
- t.Fatalf("text rendering must cap at %d entries: %q", solutionSetTextCap, line)
- }
-}
diff --git a/boatstack/statemap.go b/boatstack/statemap.go
deleted file mode 100644
index 9c30785..0000000
--- a/boatstack/statemap.go
+++ /dev/null
@@ -1,293 +0,0 @@
-package boatstack
-
-import "path/filepath"
-
-// The state-ownership map: one declared registry of every filesystem tree
-// Boatstack manages — its class, partition, owning verbs, and whether the
-// guard protects it from raw mutation. Before this map the same knowledge was
-// scattered across path resolvers, guard regexes, and prose, and every
-// state-partitioning defect (a clone-shared ledger, a worktree-local delivery
-// state blocking a sibling, binary-vs-pin drift) was discovered by failing.
-// The conformance suite in statemap_conformance_test.go holds the map, the
-// WorkspaceContext resolvers, and the guard's path classifiers to each other,
-// so the next divergence fails a test instead of a user.
-// control-law: every-managed-path-has-a-declared-owner
-
-// PathClass names what kind of state a managed tree holds.
-type PathClass string
-
-const (
- // ClassCommittedGenerated is the exported, hash-manifested bundle Boatstack
- // owns wholesale and regenerates (project.json, hooks, references).
- ClassCommittedGenerated PathClass = "committed-generated"
- // ClassCommittedPlanning is the durable feature evidence authored through
- // owned verbs and committed with the product (plans, approvals, locks, PRs).
- ClassCommittedPlanning PathClass = "committed-planning"
- // ClassCommittedInsight is the repository inbox for business and product
- // insights. Captures and append-only events are reviewable Git artifacts;
- // they are never runtime or detached controller state.
- ClassCommittedInsight PathClass = "committed-insight"
- // ClassCheckoutRuntime is reinstallable machine state living inside the
- // checkout but gitignored (the pinned helper binary, managed worktrees).
- ClassCheckoutRuntime PathClass = "checkout-runtime"
- // ClassRuntimeWorktree is mutable control state partitioned per worktree
- // under the worktree's own Git directory.
- ClassRuntimeWorktree PathClass = "runtime-worktree"
- // ClassRuntimeShared is control state shared by every worktree of a clone,
- // under the Git common directory.
- ClassRuntimeShared PathClass = "runtime-shared"
- // ClassHostActivation is host-owned configuration Boatstack merges into
- // (never owns wholesale): hook fragments in .claude/.cursor/.codex/.gemini.
- ClassHostActivation PathClass = "host-activation"
- // ClassDetached is the external per-user control root used by Detached
- // Supervision (repositories/, registry.json, shared runtimes).
- ClassDetached PathClass = "detached"
-)
-
-// StateEntry declares one managed tree.
-type StateEntry struct {
- Name string
- Class PathClass
- // Partition names the isolation domain: "checkout" (inside the worktree's
- // working tree), "per-worktree" (the worktree's Git dir), "git-common"
- // (shared by all worktrees), or "external" (outside the repository).
- Partition string
- // OwnerVerbs are the helper verbs/transitions that may create or mutate the
- // tree. Empty means host-owned (Boatstack only merges fragments in).
- OwnerVerbs []string
- Gitignored bool
- // GuardProtected marks trees only Boatstack transitions may name in a raw
- // mutation — exactly the set deliveryStatePathPattern denies.
- GuardProtected bool
- // Sample resolves one concrete representative path for conformance checks.
- Sample func(w WorkspaceContext) (string, error)
-}
-
-func staticSample(path string) func(WorkspaceContext) (string, error) {
- return func(WorkspaceContext) (string, error) { return path, nil }
-}
-
-func generatedSample(parts ...string) func(WorkspaceContext) (string, error) {
- return func(w WorkspaceContext) (string, error) {
- return filepath.Join(append([]string{w.GeneratedRoot()}, parts...)...), nil
- }
-}
-
-// StateRegistry returns the declared ownership map. It is a function (not a
-// package variable) so entries can never be mutated by a caller.
-func StateRegistry() []StateEntry {
- return []StateEntry{
- {
- Name: "project-config", Class: ClassCommittedGenerated, Partition: "checkout",
- OwnerVerbs: []string{"init", "update", "export"},
- Sample: func(w WorkspaceContext) (string, error) { return w.ProjectConfigPath(), nil },
- },
- {
- Name: "source-config", Class: ClassCommittedGenerated, Partition: "checkout",
- OwnerVerbs: []string{"init", "migrate-config", "update"},
- Sample: func(w WorkspaceContext) (string, error) { return w.SourceConfigPath(), nil },
- },
- {
- Name: "generated-references", Class: ClassCommittedGenerated, Partition: "checkout",
- OwnerVerbs: []string{"init", "update", "export"},
- Sample: generatedSample("workflow.md"),
- },
- {
- Name: "guard-hooks", Class: ClassCommittedGenerated, Partition: "checkout",
- OwnerVerbs: []string{"init", "update", "export"},
- Sample: generatedSample("hooks", "guard.sh"),
- },
- {
- Name: "runtime-launchers", Class: ClassCommittedGenerated, Partition: "checkout",
- OwnerVerbs: []string{"init", "update", "export"},
- Sample: generatedSample("boatstack"),
- },
- {
- Name: "generated-lock", Class: ClassCommittedGenerated, Partition: "checkout",
- OwnerVerbs: []string{"init", "update", "export"},
- Sample: generatedSample("generated.lock.json"),
- },
- {
- Name: "planning-artifacts", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"planning-write"},
- Sample: generatedSample("features", "sample-feature", "plan.md"),
- },
- {
- Name: "approval-receipt", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"record-approval"},
- Sample: generatedSample("features", "sample-feature", "approval.md"),
- },
- {
- Name: "autonomy-receipt", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"record-autonomy"},
- Sample: generatedSample("features", "sample-feature", "autonomy.md"),
- },
- {
- Name: "plan-lock", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"activate-plan"},
- Sample: generatedSample("features", "sample-feature", "plan.lock.json"),
- },
- {
- Name: "compiled-artifacts", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"activate-plan"},
- Sample: generatedSample("features", "sample-feature", "compiled", "tasks.json"),
- },
- {
- Name: "pr-preview", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"ship-gate", "publish-pr"},
- Sample: generatedSample("features", "sample-feature", "pr.md"),
- },
- {
- Name: "change-ledger", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"record-change"},
- Sample: generatedSample("features", "sample-feature", "changes.md"),
- },
- {
- Name: "discard-archive", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"discard-delivery"},
- Sample: generatedSample("features", ".discarded", "sample-feature", "plan.md"),
- },
- {
- Name: "pr-briefs", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"pr-context"},
- Sample: generatedSample("pr-briefs", "sample-branch", "pr.md"),
- },
- {
- Name: "verified-boundaries", Class: ClassCommittedPlanning, Partition: "checkout",
- OwnerVerbs: []string{"record-delivery-gate"},
- Sample: generatedSample("verified-boundaries.md"),
- },
- {
- Name: "insight-artifacts", Class: ClassCommittedInsight, Partition: "checkout", GuardProtected: true,
- OwnerVerbs: []string{"insight"},
- Sample: func(w WorkspaceContext) (string, error) {
- base, err := w.InsightDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "ins-sample", "capture.json"), nil
- },
- },
- {
- Name: "worktree-helper", Class: ClassCheckoutRuntime, Partition: "checkout", Gitignored: true,
- OwnerVerbs: []string{"init", "update", "hydrate-runtime", "activate-worktree-runtime"},
- Sample: generatedSample("bin", "install.lock.json"),
- },
- {
- Name: "managed-worktrees", Class: ClassCheckoutRuntime, Partition: "checkout", Gitignored: true,
- OwnerVerbs: []string{"workspace-cut", "workspace-cleanup", "workspace-reap"},
- Sample: generatedSample("worktrees", "sample-branch"),
- },
- {
- Name: "delivery-state", Class: ClassRuntimeWorktree, Partition: "per-worktree", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"activate-plan", "record-delivery-gate", "record-change", "publish-pr", "repair-state", "discard-delivery"},
- Sample: func(w WorkspaceContext) (string, error) {
- base, err := w.DeliveryDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "sample-feature", "state.json"), nil
- },
- },
- {
- Name: "operation-ledger", Class: ClassRuntimeWorktree, Partition: "per-worktree", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"run-preflight", "publish-pr", "publish-update-pr"},
- Sample: func(w WorkspaceContext) (string, error) {
- base, err := w.OperationDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "sample-operation.json"), nil
- },
- },
- {
- Name: "flow-logs", Class: ClassRuntimeWorktree, Partition: "per-worktree", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"flow"},
- Sample: func(w WorkspaceContext) (string, error) {
- base, err := w.FlowDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "trajectory.jsonl"), nil
- },
- },
- {
- // The denial ledger backing repeated-denials-escalate-to-solutions.
- // Written only by the hook's own deny path; per-worktree so one
- // worktree's denial history never escalates a sibling's denials.
- Name: "guard-denial-ledger", Class: ClassRuntimeWorktree, Partition: "per-worktree", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"safety-hook", "engagement-probe"},
- Sample: func(w WorkspaceContext) (string, error) {
- base, err := w.GuardDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "denials.json"), nil
- },
- },
- {
- Name: "runtime-slots", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"init", "update", "hydrate-runtime"},
- Sample: func(w WorkspaceContext) (string, error) {
- base, err := w.RuntimeDir("v0.0.0", "0000000")
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "runtime.lock.json"), nil
- },
- },
- {
- Name: "runtime-bootstrap-slots", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"init", "update", "hydrate-runtime"},
- Sample: func(w WorkspaceContext) (string, error) {
- base, err := w.BootstrapRuntimeDir("v0.0.0", "0000000")
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "runtime.lock.json"), nil
- },
- },
- {
- Name: "mutation-receipts", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"activate-plan", "undo"},
- Sample: staticSample(filepath.FromSlash(".git/boatstack/mutations/v1/sample.json")),
- },
- {
- Name: "update-previews", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"prepare-update-pr", "publish-update-pr"},
- Sample: staticSample(filepath.FromSlash(".git/boatstack/updates/v0.0.0/pr-preview.json")),
- },
- {
- Name: "repair-receipts", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"update"},
- Sample: staticSample(filepath.FromSlash(".git/boatstack/updates/v0.0.0/repair.json")),
- },
- {
- Name: "visual-evidence", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"record-pr-visual-evidence", "review-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"},
- Sample: staticSample(filepath.FromSlash(".git/boatstack/visual-evidence/sample/manifest.json")),
- },
- {
- Name: "quarantine", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true,
- OwnerVerbs: []string{"repair-state"},
- Sample: staticSample(filepath.FromSlash(".git/boatstack/quarantine/sample-feature/receipt.json")),
- },
- {
- Name: "host-hook-config", Class: ClassHostActivation, Partition: "checkout",
- // Host-owned files Boatstack merges hook fragments into; never owned
- // wholesale, so no owner verbs beyond the activation merge.
- OwnerVerbs: []string{"init", "update", "activate"},
- Sample: staticSample(filepath.FromSlash(".claude/settings.json")),
- },
- {
- Name: "detached-registry", Class: ClassDetached, Partition: "external", GuardProtected: true,
- OwnerVerbs: []string{"attach", "detach"},
- Sample: staticSample(filepath.FromSlash("state-root/boatstack/registry.json")),
- },
- {
- Name: "detached-repositories", Class: ClassDetached, Partition: "external", GuardProtected: true,
- OwnerVerbs: []string{"attach", "detach", "activate"},
- Sample: staticSample(filepath.FromSlash("state-root/boatstack/repositories/sample/binding.json")),
- },
- }
-}
diff --git a/boatstack/statemap_conformance_test.go b/boatstack/statemap_conformance_test.go
deleted file mode 100644
index b2f7d33..0000000
--- a/boatstack/statemap_conformance_test.go
+++ /dev/null
@@ -1,275 +0,0 @@
-package boatstack
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "os"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "testing"
-)
-
-// control-law: every-managed-path-has-a-declared-owner
-//
-// The state-ownership map (statemap.go) is the single declaration of every
-// tree Boatstack manages: class, partition, owning verbs, guard protection.
-// These tests hold the declaration, the WorkspaceContext resolvers, the
-// guard's path classifiers, the exported ownership doc, and the package's
-// hand-joined path literals to each other — so the next state-partitioning
-// divergence fails here instead of surfacing as a live defect.
-
-func statemapContext(t *testing.T) WorkspaceContext {
- t.Helper()
- repo := safetyTestRepo(t)
- w, err := ResolveWorkspaceContext(repo)
- if err != nil {
- t.Fatalf("resolve workspace context: %v", err)
- }
- return w
-}
-
-func entrySample(t *testing.T, w WorkspaceContext, entry StateEntry) string {
- t.Helper()
- sample, err := entry.Sample(w)
- if err != nil {
- t.Fatalf("entry %s sample: %v", entry.Name, err)
- }
- if sample == "" {
- t.Fatalf("entry %s resolves an empty sample", entry.Name)
- }
- return filepath.ToSlash(sample)
-}
-
-// Positive: every WorkspaceContext resolver's output is owned by exactly one
-// registry entry of the expected class — the resolvers and the declaration
-// cannot drift apart.
-func TestEveryWorkspaceResolverIsDeclared(t *testing.T) {
- w := statemapContext(t)
- registry := StateRegistry()
-
- resolve := func(f func() (string, error)) string {
- path, err := f()
- if err != nil {
- t.Fatal(err)
- }
- return filepath.ToSlash(path)
- }
- resolverOutputs := map[string]struct {
- path string
- wantClass PathClass
- }{
- "GeneratedRoot": {filepath.ToSlash(w.GeneratedRoot()), ClassCommittedGenerated},
- "ProjectConfigPath": {filepath.ToSlash(w.ProjectConfigPath()), ClassCommittedGenerated},
- "SourceConfigPath": {filepath.ToSlash(w.SourceConfigPath()), ClassCommittedGenerated},
- "DeliveryDir": {resolve(w.DeliveryDir), ClassRuntimeWorktree},
- "OperationDir": {resolve(w.OperationDir), ClassRuntimeWorktree},
- "FlowDir": {resolve(w.FlowDir), ClassRuntimeWorktree},
- "GuardDir": {resolve(w.GuardDir), ClassRuntimeWorktree},
- "InsightDir": {resolve(w.InsightDir), ClassCommittedInsight},
- "RuntimeDir": {resolve(func() (string, error) {
- return w.RuntimeDir("v0.0.0", "0000000")
- }), ClassRuntimeShared},
- "BootstrapRuntimeDir": {resolve(func() (string, error) {
- return w.BootstrapRuntimeDir("v0.0.0", "0000000")
- }), ClassRuntimeShared},
- }
-
- for name, want := range resolverOutputs {
- owners := 0
- var ownerClass PathClass
- for _, entry := range registry {
- sample := entrySample(t, w, entry)
- // A resolver is "declared" when some entry's sample lives at or under
- // its output (the entry is the concrete artifact inside the tree).
- if sample == want.path || strings.HasPrefix(sample, want.path+"/") {
- if entry.Class != ownerClass {
- owners++
- ownerClass = entry.Class
- }
- }
- }
- if owners == 0 {
- t.Errorf("resolver %s (%s) has no declared owner in the state registry", name, want.path)
- continue
- }
- if ownerClass != want.wantClass && name != "GeneratedRoot" {
- t.Errorf("resolver %s owned by class %s, want %s", name, ownerClass, want.wantClass)
- }
- }
- // GeneratedRoot hosts multiple classes by design (generated bundle, planning,
- // checkout runtime) — assert it is covered, which the loop above did.
-}
-
-// Negative: the declaration is well-formed — unique names, non-empty owners,
-// and no two entries of different classes resolve the same sample path.
-func TestStateRegistryIsWellFormed(t *testing.T) {
- w := statemapContext(t)
- seenNames := map[string]bool{}
- seenSamples := map[string]PathClass{}
- for _, entry := range StateRegistry() {
- if seenNames[entry.Name] {
- t.Errorf("duplicate entry name %s", entry.Name)
- }
- seenNames[entry.Name] = true
- if len(entry.OwnerVerbs) == 0 {
- t.Errorf("entry %s declares no owner", entry.Name)
- }
- if entry.Partition == "" || entry.Class == "" {
- t.Errorf("entry %s missing partition or class: %+v", entry.Name, entry)
- }
- sample := entrySample(t, w, entry)
- if class, dup := seenSamples[sample]; dup && class != entry.Class {
- t.Errorf("sample %s claimed by two classes: %s and %s", sample, class, entry.Class)
- }
- seenSamples[sample] = entry.Class
- }
-}
-
-// Relation: the guard's path classifiers agree with the declaration exactly.
-// deliveryStatePathPattern denies precisely the GuardProtected trees, and the
-// planning first-write latch covers precisely the features/-resident
-// committed-planning trees.
-func TestGuardClassifiersMatchDeclaredOwnership(t *testing.T) {
- w := statemapContext(t)
- repoRoot := filepath.ToSlash(w.RepoRoot)
- for _, entry := range StateRegistry() {
- sample := entrySample(t, w, entry)
- gotProtected := deliveryStatePathPattern.MatchString(sample) || insightArtifactPathPattern.MatchString(sample)
- if gotProtected != entry.GuardProtected {
- t.Errorf("guard pattern(%s)=%t but declaration says GuardProtected=%t (sample %s)", entry.Name, gotProtected, entry.GuardProtected, sample)
- }
- if entry.Class == ClassCommittedPlanning {
- relative := strings.TrimPrefix(strings.TrimPrefix(sample, repoRoot), "/")
- underFeatures := strings.HasPrefix(relative, ".product-loop/features/")
- if featureScopedPath(relative) != underFeatures {
- t.Errorf("first-write latch scope disagrees for %s (%s)", entry.Name, relative)
- }
- }
- }
-}
-
-// Bypass: no NEW file may hand-join ".product-loop" paths without consciously
-// extending this allowlist — the frozen inventory of declaring files. Growth
-// pressure should flow toward WorkspaceContext/statemap, not new literals.
-func TestProductLoopLiteralsStayInDeclaredFiles(t *testing.T) {
- allowed := map[string]string{
- "delivery.go": "controller-syntax", "export.go": "controller-bundle",
- "hooks.go": "embedded-installation",
- "launcher.go": "embedded-installation",
- "init.go": "embedded-installation", "installation_repair.go": "embedded-installation", "mutation_undo.go": "controller-syntax",
- "paths.go": "canonical-owner", "planning.go": "product-diff-syntax", "pr.go": "product-diff-syntax",
- "recovery.go": "product-diff-syntax", "runtime_cache.go": "embedded-installation", "safety.go": "policy-syntax",
- "update.go": "embedded-installation", "update_publication.go": "embedded-installation",
- "workspace.go": "repository-workspace",
- // denial.go names .product-loop/features/ only in user-facing denial
- // copy (the owned-channel guidance), never as a joined path.
- "denial.go": "user-guidance",
- }
- validClass := map[string]bool{"canonical-owner": true, "controller-bundle": true, "controller-syntax": true, "embedded-installation": true, "policy-syntax": true, "product-diff-syntax": true, "repository-workspace": true, "user-guidance": true}
-
- fset := token.NewFileSet()
- entries, err := os.ReadDir(".")
- if err != nil {
- t.Fatal(err)
- }
- offenders := map[string]bool{}
- scanned := 0
- for _, item := range entries {
- name := item.Name()
- if item.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
- continue
- }
- file, err := parser.ParseFile(fset, name, nil, 0)
- if err != nil {
- t.Fatalf("parse %s: %v", name, err)
- }
- scanned++
- ast.Inspect(file, func(n ast.Node) bool {
- lit, ok := n.(*ast.BasicLit)
- if !ok || lit.Kind != token.STRING {
- return true
- }
- value, err := strconv.Unquote(lit.Value)
- if err != nil {
- return true
- }
- if strings.Contains(value, ".product-loop") && allowed[name] == "" {
- offenders[name] = true
- }
- return true
- })
- }
- if scanned < 20 {
- t.Fatalf("scanned only %d files; the literal guarantee would be vacuous", scanned)
- }
- if len(offenders) > 0 {
- names := make([]string, 0, len(offenders))
- for name := range offenders {
- names = append(names, name)
- }
- sort.Strings(names)
- t.Fatalf("new hand-joined .product-loop literals outside the declared files: %v — route the path through WorkspaceContext/statemap or consciously extend the allowlist", names)
- }
- // Reverse check: a file on the allowlist that no longer carries a literal is
- // stale — shrink the list so the freeze stays honest.
- for name, class := range allowed {
- if !validClass[class] {
- t.Errorf("allowlist entry %s has unknown ownership class %q", name, class)
- }
- content, err := os.ReadFile(name)
- if err != nil {
- t.Fatalf("allowlisted file %s unreadable: %v", name, err)
- }
- if !strings.Contains(string(content), ".product-loop") {
- t.Errorf("allowlist entry %s is stale — it no longer names .product-loop", name)
- }
- }
-}
-
-// Failure-state / doc drift: the exported ownership table mirrors the registry
-// exactly, and the stale pre-isolation ledger path is gone from the doc.
-func TestOwnershipDocMirrorsRegistry(t *testing.T) {
- content, err := os.ReadFile(filepath.Join("references", "artifacts.md"))
- if err != nil {
- t.Fatal(err)
- }
- doc := string(content)
- if strings.Contains(doc, "Git-common `boatstack/operations/v1`") {
- t.Fatal("artifacts.md still documents the orphaned Git-common operations/v1 ledger as current")
- }
-
- documented := map[string]bool{}
- inTable := false
- for _, line := range strings.Split(doc, "\n") {
- if strings.HasPrefix(line, "## State ownership") {
- inTable = true
- continue
- }
- if inTable && strings.HasPrefix(line, "## ") {
- break
- }
- if !inTable || !strings.HasPrefix(line, "| ") || strings.HasPrefix(line, "| ---") || strings.HasPrefix(line, "| Name") {
- continue
- }
- cells := strings.Split(line, "|")
- if len(cells) > 1 {
- documented[strings.TrimSpace(cells[1])] = true
- }
- }
-
- registry := map[string]bool{}
- for _, entry := range StateRegistry() {
- registry[entry.Name] = true
- if !documented[entry.Name] {
- t.Errorf("registry entry %s missing from the artifacts.md ownership table", entry.Name)
- }
- }
- for name := range documented {
- if !registry[name] {
- t.Errorf("artifacts.md documents %s which is not in the registry", name)
- }
- }
-}
diff --git a/boatstack/supervisory_control_test.go b/boatstack/supervisory_control_test.go
deleted file mode 100644
index 00fa7d1..0000000
--- a/boatstack/supervisory_control_test.go
+++ /dev/null
@@ -1,321 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// TestSupervisoryControlNeverDeadlocks is the conformance model for the
-// nonblocking-supervisory-control invariant:
-//
-// Every durable supervisory transition that removes an actuator must leave a
-// bounded actuator reachable in the resulting state that reaches the next valid
-// state (or reverses the transition).
-//
-// The canonical violation it guards against is a durable pointer/state that
-// advances on request-success and thereby revokes the correction actuator for a
-// target whose postcondition has not yet been observed — exactly the delivery
-// publication bug that stranded a just-published slice. Each subtest names the
-// property it asserts and drives the exported delivery/recovery functions
-// directly, in the black-box style of TestMutationBoundaryPreservesALegalTrajectory.
-func TestSupervisoryControlNeverDeadlocks(t *testing.T) {
- // Property: publication advances the BUILD pointer to the next slice, but the
- // published slice remains re-gateable in place because its PR postcondition is
- // not yet terminal. Advancing "which slice builds next" must not revoke "which
- // slices may still be corrected".
- t.Run("published-open slice stays correctable after the pointer advances", func(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- gateSlice(t, repo, feature, "phase-one")
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil {
- t.Fatalf("publish phase-one: %v", err)
- }
-
- state := loadDelivery(t, repo, feature)
- if state.ActiveIndex != 1 || state.Slices[1].Status != "BUILD" {
- t.Fatalf("publication did not advance the BUILD pointer: %#v", state)
- }
- if state.Slices[0].Status != "PUBLISHED" || state.Slices[0].PRState != "OPEN" {
- t.Fatalf("published slice did not record an open PR: %#v", state.Slices[0])
- }
-
- // The bounded correction actuator is still reachable: re-gate phase-one in
- // place through BOTH gates (its changelog baseline anchors to phase-one, not
- // the active slice), and the BUILD pointer must not move.
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil {
- t.Fatalf("published-open slice was not re-gateable (test) in place: %v", err)
- }
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"}); err != nil {
- t.Fatalf("published-open slice was not re-gateable (review) in place: %v", err)
- }
- got := loadDelivery(t, repo, feature)
- if got.ActiveIndex != 1 {
- t.Fatalf("re-gating a published-open slice advanced the pointer: %#v", got)
- }
- if got.Slices[0].Status != "REVIEW_PASSED" || got.Slices[0].PRState != "OPEN" {
- t.Fatalf("in-place re-gate did not restore the corrected slice: %#v", got.Slices[0])
- }
- })
-
- // Property: re-publishing a published-open slice (an --action update of its PR)
- // is idempotent on the BUILD pointer — the advance happens once, on the first
- // REVIEW_PASSED -> PUBLISHED transition, never again.
- t.Run("re-publication does not double-advance the pointer", func(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- gateSlice(t, repo, feature, "phase-one")
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil {
- t.Fatalf("publish phase-one: %v", err)
- }
- // Re-publishing the same already-PUBLISHED, open slice is the --action update
- // path (e.g. after a corrective in-place change): it refreshes the recorded
- // PR URL but must not advance the BUILD pointer a second time.
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1?rev=2"); err != nil {
- t.Fatalf("re-publish phase-one: %v", err)
- }
- state := loadDelivery(t, repo, feature)
- if state.ActiveIndex != 1 {
- t.Fatalf("re-publication double-advanced the pointer: ActiveIndex=%d", state.ActiveIndex)
- }
- if state.Slices[0].PRURL != "https://example.invalid/pr/1?rev=2" {
- t.Fatalf("re-publication did not refresh the PR URL: %#v", state.Slices[0])
- }
- if state.Slices[1].Status != "BUILD" {
- t.Fatalf("re-publication disturbed the active slice: %#v", state.Slices[1])
- }
- })
-
- // Property: once a published slice's PR reaches a terminal state (merged/closed)
- // the in-place actuator is correctly removed, but a bounded FORWARD actuator —
- // the corrective child delivery — remains reachable. Removal of one actuator is
- // only legal because it exposes another; a terminal PR is never a deadlock.
- t.Run("terminal PR refuses in-place correction but offers a corrective child", func(t *testing.T) {
- repo, feature := activateTwoSliceDelivery(t)
- gateSlice(t, repo, feature, "phase-one")
- if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil {
- t.Fatalf("publish phase-one: %v", err)
- }
- // Drive the delivery to fully published directly: phase-two's real gate flow
- // needs per-slice committed diffs this conformance property does not exercise.
- state := loadDelivery(t, repo, feature)
- state.ActiveIndex = 2
- state.Slices[1].Status = "PUBLISHED"
- state.Slices[1].PRState = "OPEN"
- state.Slices[1].PRURL = "https://example.invalid/pr/2"
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatalf("persist fully-published state: %v", err)
- }
-
- // The whole delivery is published; simulate the merge observation the
- // recovery/next resolver would make against GitHub.
- restore := stubRecoveryGh(t, "MERGED", "https://example.invalid/pr/2", branchForFeature(feature))
- defer restore()
-
- status, err := ResolveRecovery(RecoveryStatusOptions{
- Repo: repo, Feature: feature,
- Message: "required checks failed after merge", SourceStage: "ci",
- })
- if err != nil {
- t.Fatalf("resolve recovery: %v", err)
- }
- if status.NextOperation != "draft_corrective_child" {
- t.Fatalf("terminal PR did not route to the bounded forward actuator: %#v", status)
- }
-
- // The terminal observation is now cached on the slice, so the network-free
- // gate resolver refuses an in-place re-gate and points at the corrective child.
- reloaded := loadDelivery(t, repo, feature)
- if _, _, err := resolveAddressableSlice(reloaded, "phase-two"); err == nil || !strings.Contains(err.Error(), "corrective child") {
- t.Fatalf("terminal slice was still addressable in place: %v", err)
- }
- })
-
- // Property: the addressable-slice resolver both redirects (pr-context/gate to a
- // named published-open slice) and guards (refuses future, terminal, or unknown
- // slices with a message that names the correct actuator). This is the shared
- // lookup behind record-delivery-gate --slice and pr-context --slice.
- t.Run("addressable resolver redirects to correctable slices and guards the rest", func(t *testing.T) {
- state := DeliveryState{
- ActiveIndex: 1,
- Slices: []DeliverySlice{
- {ID: "phase-one", Status: "PUBLISHED", PRState: "OPEN"},
- {ID: "phase-two", Status: "BUILD"},
- {ID: "phase-three", Status: "PENDING"},
- },
- }
- cases := []struct {
- name string
- sliceID string
- wantIndex int
- wantErr string
- }{
- {"empty resolves to the active slice", "", 1, ""},
- {"active slice by name", "phase-two", 1, ""},
- {"published-open slice redirects in place", "phase-one", 0, ""},
- {"future slice is refused", "phase-three", -1, "not active"},
- {"unknown slice is refused", "phase-nine", -1, "does not exist"},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- index, slice, err := resolveAddressableSlice(state, tc.sliceID)
- if tc.wantErr != "" {
- if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
- t.Fatalf("want error containing %q, got %v", tc.wantErr, err)
- }
- return
- }
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if index != tc.wantIndex || slice.ID != state.Slices[tc.wantIndex].ID {
- t.Fatalf("resolved index=%d slice=%s, want index=%d", index, slice.ID, tc.wantIndex)
- }
- })
- }
-
- // A terminal published slice is refused in place and routed to a corrective child.
- terminal := DeliveryState{
- ActiveIndex: 1,
- Slices: []DeliverySlice{
- {ID: "phase-one", Status: "PUBLISHED", PRState: "MERGED"},
- {ID: "phase-two", Status: "BUILD"},
- },
- }
- if _, _, err := resolveAddressableSlice(terminal, "phase-one"); err == nil || !strings.Contains(err.Error(), "corrective child") {
- t.Fatalf("terminal published slice was addressable in place: %v", err)
- }
- })
-
- // Property: the safety guard that removes ordinary command authority at
- // exceptional stages still admits a bounded recovery verb at EVERY stage, so no
- // stage transition is a deadlock. (Reinforces
- // TestControlledPhaseTransitionAllowsBoundedRecoveryVerbs as an invariant, not
- // an enumerated fixture.)
- t.Run("a bounded recovery verb is admitted at every stage", func(t *testing.T) {
- stages := []string{"BUILD", "DELIVERY", "SHIP_GATE", "PR_OPEN", "INVALID_STATE", "PUBLISHED", ""}
- for _, stage := range stages {
- admitted := controlledPhaseTransition("boatstack-helper repair-state --repo .", stage) ||
- controlledPhaseTransition("boatstack-helper undo --repo . --mutation abc", stage)
- if !admitted {
- t.Fatalf("stage %q admitted no bounded recovery verb — a deadlock", stage)
- }
- }
- })
-
- // Coreachability invariant (stronger than "some verb is admitted"): the verb a
- // blocked state PRESCRIBES must be both admitted in-tool AND accept/clear that
- // exact state — diagnosis-predicate ⊆ recovery-predicate. Otherwise the guard
- // sends the operator to a verb that refuses, a fail-closed state with no exit
- // (the repair-state-refuses-a-valid/orphan-dir wedge). All prescribed recovery
- // verbs must be reachable through controlledPhaseTransition at INVALID_STATE.
- t.Run("every prescribed recovery verb is admitted at INVALID_STATE", func(t *testing.T) {
- for _, verb := range []string{"repair-state", "discard-delivery", "doctor", "undo"} {
- if !controlledPhaseTransition("boatstack-helper "+verb+" --repo .", "INVALID_STATE") {
- t.Fatalf("INVALID_STATE prescribes %q but the guard does not admit it — a dead-end", verb)
- }
- }
- })
-
- // An orphan (pr.md, no plan.lock) is prescribed discard-delivery; that verb must
- // accept and clear it, and the state must resolve afterward.
- t.Run("orphan is prescribed discard-delivery which clears it", func(t *testing.T) {
- repo := nextTestRepo(t)
- dir := filepath.Join(repo, ".product-loop", "features", "orphan")
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "pr.md"), []byte("# Preview\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.NextOperation != "discard-delivery" {
- t.Fatalf("orphan prescribed %q, want discard-delivery", status.NextOperation)
- }
- if !controlledPhaseTransition("boatstack-helper discard-delivery --repo . --feature orphan", "INVALID_STATE") {
- t.Fatal("prescribed discard-delivery is not admitted at INVALID_STATE")
- }
- result, err := DiscardDelivery(repo, "orphan", false)
- if err != nil {
- t.Fatalf("discard-delivery refused the orphan it was prescribed for: %v", err)
- }
- if result.Action != "discarded" {
- t.Fatalf("discard-delivery did not clear the orphan: %#v", result)
- }
- after, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if after.ObservedStage == "INVALID_STATE" && after.NextOperation == "discard-delivery" {
- t.Fatalf("orphan still blocks after discard-delivery: %#v", after)
- }
- })
-
- // A malformed draft remains visible to explicit Boatstack resolution without
- // controlling unrelated repository work.
- t.Run("malformed draft is explicitly repairable without ambient control", func(t *testing.T) {
- repo := nextTestRepo(t)
- dir := filepath.Join(repo, ".product-loop", "features", "broken")
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "plan.md"), []byte("not a valid plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if finding, blocked := preActivationFinding(repo, "src/x.go"); blocked {
- t.Fatalf("malformed ambient draft controlled product work: %+v", finding)
- }
- status, err := ResolveNext(repo, "broken")
- if err != nil {
- t.Fatal(err)
- }
- if status.NextOperation != "plan-gate" {
- t.Fatalf("selected draft prescribed %q, want plan-gate validation", status.NextOperation)
- }
- result, err := RepairState(repo, "broken")
- if err != nil {
- t.Fatalf("repair-state refused the malformed draft it was prescribed for: %v", err)
- }
- if result.Action != "quarantined" {
- t.Fatalf("repair-state did not clear the malformed draft: %#v", result)
- }
- })
-}
-
-func gateSlice(t *testing.T, repo, feature, sliceID string) {
- t.Helper()
- for _, gate := range []string{"test", "review"} {
- if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: sliceID, Gate: gate, Status: "PASS"}); err != nil {
- t.Fatalf("record %s gate for %s: %v", gate, sliceID, err)
- }
- }
-}
-
-func loadDelivery(t *testing.T, repo, feature string) DeliveryState {
- t.Helper()
- state, err := LoadDeliveryState(repo, feature)
- if err != nil {
- t.Fatalf("load delivery state: %v", err)
- }
- return state
-}
-
-// stubRecoveryGh replaces the package gh shim with a fixed `gh pr view`
-// observation so recovery/next resolution stays network-free and deterministic
-// in tests. It returns a restore function.
-func stubRecoveryGh(t *testing.T, state, url, branch string) func() {
- t.Helper()
- previous := recoveryGh
- recoveryGh = func(repo string, arguments ...string) (string, error) {
- payload, err := MarshalJSON(map[string]any{
- "state": state, "headRefName": branch, "headRefOid": "deadbeef", "url": url,
- })
- if err != nil {
- return "", err
- }
- return string(payload), nil
- }
- return func() { recoveryGh = previous }
-}
diff --git a/boatstack/testdata/reviewer-pr-body.md b/boatstack/testdata/reviewer-pr-body.md
deleted file mode 100644
index 941b62d..0000000
--- a/boatstack/testdata/reviewer-pr-body.md
+++ /dev/null
@@ -1,50 +0,0 @@
-## Why this change
-
-Repository hooks could not reliably find the project toolchain when an IDE pushed without an activated environment, while the privacy detector could attempt an unexpected large model download during tests.
-
-## What changed
-
-| Area | Before | After | Reviewer focus |
-|---|---|---|---|
-| Hook tooling | Depended on the caller's active shell | Resolves the repository's configured environment first | Resolution order and cross-platform fallback |
-| Privacy detection | Missing optional models could trigger a runtime download | Fails fast, warns, and uses the installed compact model | Detection remains active without hidden network work |
-| Typing | Third-party registry shape was implicit | Registry access has an explicit checked type | No runtime behavior change |
-
-## Review order
-
-1. Review the hook's environment resolution and failure behavior.
-2. Review the privacy detector's missing-model boundary and warning.
-3. Confirm the typing-only change does not alter runtime registration.
-
-## Evidence
-
-| Claim | Evidence | Result | Source |
-|---|---|---|---|
-| Hook runs without an activated environment | Repository hook smoke procedure | `NOT_VERIFIED` | Current branch test notes |
-| Privacy detection remains active with the compact model | Targeted detector scenario | `NOT_VERIFIED` | Current branch test notes |
-| Static typing remains clean | Project type-check command | `NOT_VERIFIED` | Current branch test notes |
-
-## Operational safety
-
-Repository safety scan passed. Destructive recovery remains operator-only.
-
-## Security and privacy
-
-The fallback keeps privacy detection enabled and removes an unexpected network/download side effect from the test path.
-
-## Known gaps and risks
-
-Native Windows environment layout remains unverified. This brief does not claim approval or completed Boatstack gates.
-
-## Rollout and rollback
-
-No data migration is required. Roll back the hook resolution and detector fallback commits independently if either environment path regresses.
-
-
-Boatstack provenance
-
-- Mode: evidence-limited ad-hoc
-- Approval and gate evidence: unavailable
-- Coding-host attribution: record here when known
-
-
diff --git a/boatstack/testdata/safety/safe_apply.py.txt b/boatstack/testdata/safety/safe_apply.py.txt
deleted file mode 100644
index 0574f9d..0000000
--- a/boatstack/testdata/safety/safe_apply.py.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-"""Sanitized fix-forward migrator used by the irreversible-operation tests."""
-
-import argparse
-
-parser = argparse.ArgumentParser()
-parser.add_argument("--dry-run", action="store_true")
-args = parser.parse_args()
-
-expected_target = "project-ref-7f31"
-assert expected_target == configured_target
-
-if not args.dry_run:
- with connection.transaction():
- apply_forward_only_changes()
diff --git a/boatstack/testdata/safety/unsafe_apply.py.txt b/boatstack/testdata/safety/unsafe_apply.py.txt
deleted file mode 100644
index 38c81df..0000000
--- a/boatstack/testdata/safety/unsafe_apply.py.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Sanitized partial-schema migrator that must never be executed by an agent."""
-
-import argparse
-
-parser = argparse.ArgumentParser()
-parser.add_argument("--reset-public", action="store_true")
-
-if parser.parse_args().reset_public:
- cursor.execute("DROP SCHEMA public CASCADE")
diff --git a/boatstack/testdata/v2-scenarios/historical.json b/boatstack/testdata/v2-scenarios/historical.json
new file mode 100644
index 0000000..ce5db64
--- /dev/null
+++ b/boatstack/testdata/v2-scenarios/historical.json
@@ -0,0 +1,1266 @@
+{
+ "schema_version": 1,
+ "fixture_count": 22,
+ "fixtures": [
+ {
+ "name": "stale-worktree-runtime-selection",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "wrong-source",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:stale-worktree-runtime-selection"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "wrong-source",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-stale-worktree-runtime-selection",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-stale-worktree-runtime-selection"
+ },
+ "event": "runtime.hydrate",
+ "authority": [
+ "repository-policy"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "runtime.hydrate",
+ "expected_postcondition": {
+ "runtime": "verified"
+ },
+ "forbidden_transition": "publication.execute",
+ "source_provenance": [
+ "PR #172"
+ ],
+ "failure_class": "stale runtime selection"
+ },
+ {
+ "name": "detached-bootstrap-hydration",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "detached",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "uninitialized",
+ "workspace": "absent",
+ "plan": "absent",
+ "configuration": "verified",
+ "runtime": "absent",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:detached-bootstrap-hydration"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "detached",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "uninitialized",
+ "workspace": "absent",
+ "plan": "absent",
+ "configuration": "verified",
+ "runtime": "absent",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-detached-bootstrap-hydration",
+ "kind": "approved-plan",
+ "delivery_id": "delivery-detached-bootstrap-hydration"
+ },
+ "event": "runtime.hydrate",
+ "authority": [
+ "repository-policy"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "runtime.hydrate",
+ "expected_postcondition": {
+ "runtime": "verified"
+ },
+ "forbidden_transition": "plan.activate",
+ "source_provenance": [
+ "PR #173"
+ ],
+ "failure_class": "missing recovery coreachability"
+ },
+ {
+ "name": "workspace-transition-deadlock",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "attention-required",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:workspace-transition-deadlock"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "attention-required",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-workspace-transition-deadlock",
+ "kind": "safely-abandoned",
+ "delivery_id": "delivery-workspace-transition-deadlock"
+ },
+ "event": "workspace.abandon",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "workspace.abandon",
+ "expected_postcondition": {
+ "workspace": "abandoned"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PR #174"
+ ],
+ "failure_class": "workspace lifecycle deadlock"
+ },
+ {
+ "name": "cleanup-before-publication",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "gates-passed",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:cleanup-before-publication"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "gates-passed",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-cleanup-before-publication",
+ "kind": "open-or-updated-pr",
+ "delivery_id": "delivery-cleanup-before-publication"
+ },
+ "event": "workspace.cleanup",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "preserves": "workspace"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PR #175",
+ "PR #176"
+ ],
+ "failure_class": "cleanup treated as completion"
+ },
+ {
+ "name": "saved-plan-ambient-restriction",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "dormant",
+ "delivery": "approved",
+ "workspace": "absent",
+ "plan": "approved",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:saved-plan-ambient-restriction"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "dormant",
+ "delivery": "approved",
+ "workspace": "absent",
+ "plan": "approved",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-saved-plan-ambient-restriction",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-saved-plan-ambient-restriction"
+ },
+ "event": "plan.activate",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "engagement": "dormant"
+ },
+ "forbidden_transition": "plan.activate",
+ "source_provenance": [
+ "PR #177"
+ ],
+ "failure_class": "ambient engagement leakage"
+ },
+ {
+ "name": "detached-command-admission-mismatch",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "detached",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "planning",
+ "workspace": "absent",
+ "plan": "draft",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:detached-command-admission-mismatch"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "detached",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "planning",
+ "workspace": "absent",
+ "plan": "draft",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-detached-command-admission-mismatch",
+ "kind": "approved-plan",
+ "delivery_id": "delivery-detached-command-admission-mismatch"
+ },
+ "event": "plan.validate",
+ "authority": [
+ "repository-policy"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "plan.validate",
+ "expected_postcondition": {
+ "plan": "valid"
+ },
+ "forbidden_transition": "plan.activate",
+ "source_provenance": [
+ "PR #178"
+ ],
+ "failure_class": "host and native controller disagreement"
+ },
+ {
+ "name": "split-bootstrap-command-authority",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "uninitialized",
+ "workspace": "absent",
+ "plan": "absent",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:split-bootstrap-command-authority"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "uninitialized",
+ "workspace": "absent",
+ "plan": "absent",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-split-bootstrap-command-authority",
+ "kind": "approved-plan",
+ "delivery_id": "delivery-split-bootstrap-command-authority"
+ },
+ "event": "plan.create",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "plan.create",
+ "expected_postcondition": {
+ "plan": "draft"
+ },
+ "forbidden_transition": "plan.activate",
+ "source_provenance": [
+ "PR #179"
+ ],
+ "failure_class": "split transition authority"
+ },
+ {
+ "name": "configuration-projection-drift",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "divergent",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:configuration-projection-drift"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "divergent",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-configuration-projection-drift",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-configuration-projection-drift"
+ },
+ "event": "configuration.mutate",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "configuration.mutate",
+ "expected_postcondition": {
+ "configuration": "verified"
+ },
+ "forbidden_transition": "publication.execute",
+ "source_provenance": [
+ "PR #180"
+ ],
+ "failure_class": "configuration projection ownership"
+ },
+ {
+ "name": "partial-delivery-vs-merged-projection",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "merged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:partial-delivery-vs-merged-projection"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "merged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-partial-delivery-vs-merged-projection",
+ "kind": "merged-delivery",
+ "delivery_id": "delivery-partial-delivery-vs-merged-projection"
+ },
+ "event": "workspace.cleanup",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "workspace": "active"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PR #181",
+ "PR #185"
+ ],
+ "failure_class": "control-insufficient state projection"
+ },
+ {
+ "name": "dormant-repository-interference",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "dormant",
+ "delivery": "uninitialized",
+ "workspace": "absent",
+ "plan": "absent",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:dormant-repository-interference"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "dormant",
+ "delivery": "uninitialized",
+ "workspace": "absent",
+ "plan": "absent",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-dormant-repository-interference",
+ "kind": "approved-plan",
+ "delivery_id": "delivery-dormant-repository-interference"
+ },
+ "event": "plan.create",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "engagement": "dormant"
+ },
+ "forbidden_transition": "plan.create",
+ "source_provenance": [
+ "PR #182"
+ ],
+ "failure_class": "ambient engagement leakage"
+ },
+ {
+ "name": "configuration-mutation-self-invalidation",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "stale",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:configuration-mutation-self-invalidation"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "stale",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-configuration-mutation-self-invalidation",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-configuration-mutation-self-invalidation"
+ },
+ "event": "configuration.mutate",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "configuration.mutate",
+ "expected_postcondition": {
+ "configuration": "verified"
+ },
+ "forbidden_transition": "publication.execute",
+ "source_provenance": [
+ "PR #183"
+ ],
+ "failure_class": "successful command invalidated its own state"
+ },
+ {
+ "name": "cross-shard-deterministic-resolution",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "planning",
+ "workspace": "absent",
+ "plan": "draft",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:cross-shard-deterministic-resolution"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "planning",
+ "workspace": "absent",
+ "plan": "draft",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "unverified",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-cross-shard-deterministic-resolution",
+ "kind": "approved-plan",
+ "delivery_id": "delivery-cross-shard-deterministic-resolution"
+ },
+ "event": "plan.validate",
+ "authority": [
+ "repository-policy"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "plan.validate",
+ "expected_postcondition": {
+ "plan": "valid"
+ },
+ "forbidden_transition": "",
+ "source_provenance": [
+ "PR #184"
+ ],
+ "failure_class": "test order as hidden authority"
+ },
+ {
+ "name": "unpublished-equal-main-not-landed",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/main",
+ "evidence_source": "historical:unpublished-equal-main-not-landed"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/main"
+ },
+ "requested_goal": {
+ "id": "goal-unpublished-equal-main-not-landed",
+ "kind": "merged-delivery",
+ "delivery_id": "delivery-unpublished-equal-main-not-landed"
+ },
+ "event": "workspace.cleanup",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "workspace": "active"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PR #185"
+ ],
+ "failure_class": "Git ancestry confused with publication"
+ },
+ {
+ "name": "closed-unmerged-not-cleanup-eligible",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "published",
+ "workspace": "published",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "closed-unmerged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:closed-unmerged-not-cleanup-eligible"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "published",
+ "workspace": "published",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "closed-unmerged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-closed-unmerged-not-cleanup-eligible",
+ "kind": "merged-delivery",
+ "delivery_id": "delivery-closed-unmerged-not-cleanup-eligible"
+ },
+ "event": "workspace.cleanup",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "workspace": "published"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PR #185"
+ ],
+ "failure_class": "closed PR collapsed into merged"
+ },
+ {
+ "name": "configured-merged-terminal",
+ "initial_plant_facts": {
+ "phase": "TERMINAL",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "terminal",
+ "workspace": "landed",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "merged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "established",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:configured-merged-terminal"
+ },
+ "canonical_observation": {
+ "phase": "TERMINAL",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "terminal",
+ "workspace": "landed",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "merged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "established",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-configured-merged-terminal",
+ "kind": "merged-delivery",
+ "delivery_id": "delivery-configured-merged-terminal"
+ },
+ "event": "",
+ "authority": [
+ "repository-policy"
+ ],
+ "expected_decision": "TERMINAL",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "terminal": "established"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PR #185"
+ ],
+ "failure_class": "configured terminal ignored"
+ },
+ {
+ "name": "active-workspace-preserved",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "merged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:active-workspace-preserved"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "merged",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-active-workspace-preserved",
+ "kind": "merged-delivery",
+ "delivery_id": "delivery-active-workspace-preserved"
+ },
+ "event": "workspace.cleanup",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "workspace": "active"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PR #185"
+ ],
+ "failure_class": "workspace completion outside kernel"
+ },
+ {
+ "name": "amendment-deadlock",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "amendment",
+ "workspace": "active",
+ "plan": "amendment-required",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:amendment-deadlock"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "amendment",
+ "workspace": "active",
+ "plan": "amendment-required",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-amendment-deadlock",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-amendment-deadlock"
+ },
+ "event": "plan.approve-amendment",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "plan.approve-amendment",
+ "expected_postcondition": {
+ "plan": "approved"
+ },
+ "forbidden_transition": "plan.activate",
+ "source_provenance": [
+ "PR #181",
+ "release-note amendment recovery"
+ ],
+ "failure_class": "missing transition coreachability"
+ },
+ {
+ "name": "ambiguous-detached-controller-alias",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "detached",
+ "identity_status": "ambiguous",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:ambiguous-detached-controller-alias"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "detached",
+ "identity_status": "ambiguous",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-ambiguous-detached-controller-alias",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-ambiguous-detached-controller-alias"
+ },
+ "event": "workspace.cleanup",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "IDENTITY_REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "preserves": "all-resources"
+ },
+ "forbidden_transition": "workspace.cleanup",
+ "source_provenance": [
+ "PRs #164-#167",
+ "PR #185"
+ ],
+ "failure_class": "non-injective reverse identity lookup"
+ },
+ {
+ "name": "activation-wrong-worktree-identity",
+ "initial_plant_facts": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "conflicting",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:activation-wrong-worktree-identity"
+ },
+ "canonical_observation": {
+ "phase": "OBSERVED",
+ "topology": "embedded",
+ "identity_status": "conflicting",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-activation-wrong-worktree-identity",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-activation-wrong-worktree-identity"
+ },
+ "event": "plan.activate",
+ "authority": [
+ "human"
+ ],
+ "expected_decision": "IDENTITY_REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "preserves": "delivery"
+ },
+ "forbidden_transition": "plan.activate",
+ "source_provenance": [
+ "PRs #111-#123"
+ ],
+ "failure_class": "ambiguous repository or worktree identity"
+ },
+ {
+ "name": "runtime-publication-before-lock-release",
+ "initial_plant_facts": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "partially-published",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:runtime-publication-before-lock-release"
+ },
+ "canonical_observation": {
+ "phase": "ACTIVE",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "partially-published",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-runtime-publication-before-lock-release",
+ "kind": "open-or-updated-pr",
+ "delivery_id": "delivery-runtime-publication-before-lock-release"
+ },
+ "event": "publication.preview",
+ "authority": [
+ "repository-policy"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "runtime": "partially-published"
+ },
+ "forbidden_transition": "publication.preview",
+ "source_provenance": [
+ "PR #172",
+ "PR #184"
+ ],
+ "failure_class": "runtime publication race"
+ },
+ {
+ "name": "recovery-outranks-slice-position",
+ "initial_plant_facts": {
+ "phase": "RECOVERY",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "recovery",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "reconcile",
+ "transaction": "local-applied",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:recovery-outranks-slice-position"
+ },
+ "canonical_observation": {
+ "phase": "RECOVERY",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "recovery",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "none",
+ "verification": "current",
+ "recovery": "reconcile",
+ "transaction": "local-applied",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-recovery-outranks-slice-position",
+ "kind": "verified-implementation",
+ "delivery_id": "delivery-recovery-outranks-slice-position"
+ },
+ "event": "recovery.resume",
+ "authority": [
+ "repository-policy"
+ ],
+ "expected_decision": "PRESCRIBED",
+ "expected_admitted_transition": "recovery.resume",
+ "expected_postcondition": {
+ "recovery": "none"
+ },
+ "forbidden_transition": "plan.activate",
+ "source_provenance": [
+ "PRs #124-#138"
+ ],
+ "failure_class": "recovery overridden by slice status"
+ },
+ {
+ "name": "unknown-provider-is-not-complete",
+ "initial_plant_facts": {
+ "phase": "UNRESOLVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "unavailable",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature",
+ "evidence_source": "historical:unknown-provider-is-not-complete"
+ },
+ "canonical_observation": {
+ "phase": "UNRESOLVED",
+ "topology": "embedded",
+ "identity_status": "known",
+ "engagement": "command",
+ "delivery": "active",
+ "workspace": "active",
+ "plan": "locked",
+ "configuration": "verified",
+ "runtime": "verified",
+ "publication": "unavailable",
+ "verification": "current",
+ "recovery": "none",
+ "transaction": "none",
+ "terminal": "nonterminal",
+ "ref": "refs/heads/feature"
+ },
+ "requested_goal": {
+ "id": "goal-unknown-provider-is-not-complete",
+ "kind": "open-or-updated-pr",
+ "delivery_id": "delivery-unknown-provider-is-not-complete"
+ },
+ "event": "publication.execute",
+ "authority": [
+ "human",
+ "external-provider"
+ ],
+ "expected_decision": "REFUSED",
+ "expected_admitted_transition": "",
+ "expected_postcondition": {
+ "publication": "unavailable"
+ },
+ "forbidden_transition": "publication.execute",
+ "source_provenance": [
+ "PRs #145-#150"
+ ],
+ "failure_class": "unknown external state collapsed into complete"
+ }
+ ]
+}
diff --git a/boatstack/update.go b/boatstack/update.go
deleted file mode 100644
index 876c8ee..0000000
--- a/boatstack/update.go
+++ /dev/null
@@ -1,662 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "time"
-)
-
-const (
- latestReleaseEndpoint = "https://api.github.com/repos/operatorstack/boatstack/releases/latest"
- updateCacheTTL = 24 * time.Hour
- updateReminderWindow = 7 * 24 * time.Hour
-)
-
-var (
- updateNow = time.Now
- fetchLatestRelease = defaultFetchLatestRelease
- stableVersionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`)
-)
-
-type ReleaseInfo struct {
- Version string `json:"version"`
- Name string `json:"name,omitempty"`
- Notes string `json:"notes,omitempty"`
- URL string `json:"url"`
-}
-
-type UpdateState struct {
- SchemaVersion int `json:"schema_version"`
- CurrentVersion string `json:"current_version"`
- LatestVersion string `json:"latest_version"`
- ReleaseName string `json:"release_name,omitempty"`
- ReleaseNotes string `json:"release_notes,omitempty"`
- ReleaseURL string `json:"release_url"`
- CheckedAt time.Time `json:"checked_at"`
- LastNotifiedVersion string `json:"last_notified_version,omitempty"`
- LastNotifiedAt time.Time `json:"last_notified_at,omitempty"`
-}
-
-type UpdateCheckOptions struct {
- Repo string
- Force bool
- Notify bool
-}
-
-type UpdateCheckResult struct {
- Status string
- CurrentVersion string
- LatestVersion string
- ReleaseName string
- ReleaseNotes string
- ReleaseURL string
- ShouldNotify bool
- FromCache bool
-}
-
-func parseStableVersion(value string) ([3]int, error) {
- match := stableVersionPattern.FindStringSubmatch(strings.TrimSpace(value))
- if match == nil {
- return [3]int{}, fmt.Errorf("version must be a stable semantic version: %s", value)
- }
- parsed := [3]int{}
- for index := 0; index < 3; index++ {
- number, err := strconv.Atoi(match[index+1])
- if err != nil {
- return [3]int{}, fmt.Errorf("invalid semantic version: %s", value)
- }
- parsed[index] = number
- }
- return parsed, nil
-}
-
-func compareVersions(left, right string) (int, error) {
- a, err := parseStableVersion(left)
- if err != nil {
- return 0, err
- }
- b, err := parseStableVersion(right)
- if err != nil {
- return 0, err
- }
- for index := 0; index < 3; index++ {
- if a[index] < b[index] {
- return -1, nil
- }
- if a[index] > b[index] {
- return 1, nil
- }
- }
- return 0, nil
-}
-
-func normalizedVersion(value string) (string, error) {
- if _, err := parseStableVersion(value); err != nil {
- return "", err
- }
- return "v" + strings.TrimPrefix(strings.TrimSpace(value), "v"), nil
-}
-
-func defaultFetchLatestRelease() (ReleaseInfo, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- request, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseEndpoint, nil)
- if err != nil {
- return ReleaseInfo{}, err
- }
- request.Header.Set("Accept", "application/vnd.github+json")
- request.Header.Set("User-Agent", "operatorstack-boatstack-update-check")
- response, err := http.DefaultClient.Do(request)
- if err != nil {
- return ReleaseInfo{}, err
- }
- defer response.Body.Close()
- if response.StatusCode != http.StatusOK {
- return ReleaseInfo{}, fmt.Errorf("latest release lookup returned HTTP %d", response.StatusCode)
- }
- var payload struct {
- TagName string `json:"tag_name"`
- Name string `json:"name"`
- Body string `json:"body"`
- HTMLURL string `json:"html_url"`
- Draft bool `json:"draft"`
- Prerelease bool `json:"prerelease"`
- }
- responseJSON, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
- if err != nil {
- return ReleaseInfo{}, fmt.Errorf("read latest GitHub release response: %w", err)
- }
- if err := DecodeJSON("look up latest release", "GitHub releases/latest response", responseJSON, &payload); err != nil {
- return ReleaseInfo{}, err
- }
- if payload.Draft || payload.Prerelease {
- return ReleaseInfo{}, fmt.Errorf("latest release response is not a stable published release")
- }
- version, err := normalizedVersion(payload.TagName)
- if err != nil {
- return ReleaseInfo{}, err
- }
- if strings.TrimSpace(payload.HTMLURL) == "" {
- return ReleaseInfo{}, fmt.Errorf("latest release response is missing its URL")
- }
- return ReleaseInfo{
- Version: version, Name: strings.TrimSpace(payload.Name), Notes: strings.TrimSpace(payload.Body),
- URL: strings.TrimSpace(payload.HTMLURL),
- }, nil
-}
-
-func updateStatePath(repo string) string {
- return filepath.Join(repo, ".product-loop", "bin", "update-state.json")
-}
-
-func loadUpdateState(repo string) (UpdateState, error) {
- value, err := os.ReadFile(updateStatePath(repo))
- if err != nil {
- return UpdateState{}, err
- }
- var state UpdateState
- path := updateStatePath(repo)
- if err := DecodeJSON("load Boatstack update state", path, value, &state); err != nil {
- return UpdateState{}, err
- }
- if state.SchemaVersion != 1 {
- return UpdateState{}, fmt.Errorf("unsupported update-state schema")
- }
- return state, nil
-}
-
-func writeUpdateState(repo string, state UpdateState) error {
- path := updateStatePath(repo)
- if err := rejectSymlinkComponents(repo, path); err != nil {
- return err
- }
- value, err := MarshalJSON(state)
- if err != nil {
- return err
- }
- return atomicWrite(path, value)
-}
-
-func resultFromState(state UpdateState, fromCache bool) (UpdateCheckResult, error) {
- comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion)
- if err != nil {
- return UpdateCheckResult{}, err
- }
- status := "current"
- if comparison < 0 {
- status = "available"
- }
- return UpdateCheckResult{
- Status: status, CurrentVersion: state.CurrentVersion, LatestVersion: state.LatestVersion,
- ReleaseName: state.ReleaseName, ReleaseNotes: state.ReleaseNotes,
- ReleaseURL: state.ReleaseURL, FromCache: fromCache,
- }, nil
-}
-
-func CheckForUpdate(options UpdateCheckOptions) (UpdateCheckResult, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return UpdateCheckResult{}, err
- }
- current, err := normalizedVersion(Version)
- if err != nil {
- return UpdateCheckResult{}, fmt.Errorf("cannot check updates for Boatstack %s: %w", Version, err)
- }
- now := updateNow().UTC()
- state, stateErr := loadUpdateState(repo)
- previousState := state
- useCache := stateErr == nil && state.CurrentVersion == current && !options.Force && now.Sub(state.CheckedAt) >= 0 && now.Sub(state.CheckedAt) < updateCacheTTL
- if !useCache {
- release, fetchErr := fetchLatestRelease()
- if fetchErr != nil {
- return UpdateCheckResult{}, fetchErr
- }
- state = UpdateState{
- SchemaVersion: 1, CurrentVersion: current, LatestVersion: release.Version,
- ReleaseName: release.Name, ReleaseNotes: release.Notes,
- ReleaseURL: release.URL, CheckedAt: now,
- }
- if stateErr == nil {
- state.LastNotifiedVersion = previousState.LastNotifiedVersion
- state.LastNotifiedAt = previousState.LastNotifiedAt
- }
- }
- result, err := resultFromState(state, useCache)
- if err != nil {
- return UpdateCheckResult{}, err
- }
- if options.Notify && result.Status == "available" {
- newVersion := state.LastNotifiedVersion != state.LatestVersion
- reminderDue := state.LastNotifiedAt.IsZero() || now.Sub(state.LastNotifiedAt) >= updateReminderWindow
- result.ShouldNotify = newVersion || reminderDue
- if result.ShouldNotify {
- state.LastNotifiedVersion = state.LatestVersion
- state.LastNotifiedAt = now
- }
- }
- if !useCache || result.ShouldNotify {
- if err := writeUpdateState(repo, state); err != nil {
- return UpdateCheckResult{}, err
- }
- }
- return result, nil
-}
-
-func CachedUpdate(repoPath string) (UpdateCheckResult, bool) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return UpdateCheckResult{}, false
- }
- state, err := loadUpdateState(repo)
- if err != nil {
- return UpdateCheckResult{}, false
- }
- result, err := resultFromState(state, true)
- if err != nil || result.Status != "available" {
- return UpdateCheckResult{}, false
- }
- return result, true
-}
-
-// PostShipUpdateNotice is deliberately best-effort: release discovery can add
-// information after a successful publication, but it cannot change that result.
-func PostShipUpdateNotice(repo string, feature string) (UpdateCheckResult, bool) {
- if feature != "" {
- if state, err := LoadDeliveryState(repo, feature); err == nil && state.ActiveIndex+1 < len(state.Slices) {
- return UpdateCheckResult{}, false
- }
- }
- result, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true})
- if err != nil || result.Status != "available" || !result.ShouldNotify {
- return UpdateCheckResult{}, false
- }
- return result, true
-}
-
-func CheckPreviousGeneratedState(repo string) error {
- value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "generated.lock.json"))
- if err != nil {
- return fmt.Errorf("missing generated provenance: %w", err)
- }
- var lock struct {
- Files map[string]string `json:"files"`
- }
- path := filepath.Join(repo, ".product-loop", "generated.lock.json")
- if err := DecodeJSON("check previous generated provenance", path, value, &lock); err != nil {
- return err
- }
- if len(lock.Files) == 0 {
- return fmt.Errorf("invalid generated provenance")
- }
- problems := []string{}
- for relative, expected := range lock.Files {
- current, readErr := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative)))
- if readErr != nil || SHA256Bytes(current) != expected {
- problems = append(problems, relative)
- }
- }
- if len(problems) > 0 {
- sort.Strings(problems)
- return fmt.Errorf("generated files changed since installation: %s", strings.Join(problems, ", "))
- }
- return nil
-}
-
-func committedGeneratedProvenance(repo string) ([]byte, map[string]string, error) {
- relative := ".product-loop/generated.lock.json"
- value, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output()
- if err != nil {
- return nil, nil, fmt.Errorf("cannot recover committed generated provenance: %w", err)
- }
- var lock struct {
- Generator string `json:"generator"`
- Files map[string]string `json:"files"`
- }
- if err := json.Unmarshal(value, &lock); err != nil || lock.Generator != Generator || len(lock.Files) == 0 {
- return nil, nil, fmt.Errorf("committed generated provenance is invalid")
- }
- return value, lock.Files, nil
-}
-
-func CheckExistingInstallProvenance(repo string) error {
- value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"))
- if err != nil {
- return fmt.Errorf("missing previous local install lock: %w", err)
- }
- var lock installLock
- path := filepath.Join(repo, ".product-loop", "bin", "install.lock.json")
- if err := DecodeJSON("check existing install provenance", path, value, &lock); err != nil {
- return err
- }
- if _, err := parseStableVersion(lock.BoatstackVersion); err != nil || strings.TrimSpace(lock.SourceCommit) == "" || strings.EqualFold(strings.TrimSpace(lock.SourceCommit), "unknown") {
- return fmt.Errorf("previous local install lock has invalid release provenance")
- }
- binaryPath, err := resolveRepositoryRelativePath(repo, lock.BinaryPath)
- if err != nil {
- return fmt.Errorf("invalid previous helper path: %w", err)
- }
- if err := rejectSymlinkComponents(repo, binaryPath); err != nil {
- return fmt.Errorf("previous Boatstack helper path is unsafe: %w", err)
- }
- if info, err := os.Lstat(binaryPath); err == nil && (!info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0) {
- return fmt.Errorf("previous Boatstack helper path is unsafe")
- }
- actual, err := SHA256File(binaryPath)
- if err != nil || actual != lock.BinarySHA256 {
- return fmt.Errorf("previous Boatstack helper does not match its install lock")
- }
- return nil
-}
-
-func ValidateUpdateWorkspace(repo string, config ProjectConfig) error {
- result, err := ClassifyInstallationRepair(repo, config.Adapters, false)
- if err != nil {
- return err
- }
- if result.VerificationStatus == "BLOCKED" {
- return fmt.Errorf("Boatstack update state is blocked: %s", strings.Join(result.Blockers, "; "))
- }
- return ValidateUpdateWorkspaceForRepair(repo, config, result, false)
-}
-
-func updateDirtyPaths(repo string) ([]string, error) {
- value, err := exec.Command("git", "-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all").Output()
- if err != nil {
- return nil, err
- }
- records := strings.Split(string(value), "\x00")
- paths := []string{}
- for index := 0; index < len(records); index++ {
- record := records[index]
- if record == "" {
- continue
- }
- if len(record) < 4 || record[2] != ' ' {
- return nil, fmt.Errorf("malformed Git status record")
- }
- paths = append(paths, filepath.ToSlash(record[3:]))
- if (record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C') && index+1 < len(records) {
- index++ // -z emits the original rename/copy path as a second record.
- }
- }
- return paths, nil
-}
-
-func withoutInterceptor(value []byte) ([]byte, error) {
- text := string(value)
- start := strings.Index(text, interceptorHeader)
- end := strings.Index(text, interceptorFooter)
- if start < 0 && end < 0 {
- return value, nil
- }
- if start < 0 || end < start || strings.Count(text, interceptorHeader) != 1 || strings.Count(text, interceptorFooter) != 1 {
- return nil, fmt.Errorf("ambiguous interceptor boundary")
- }
- return []byte(strings.TrimSpace(text[:start] + text[end+len(interceptorFooter):])), nil
-}
-
-type updateOwnedKind string
-
-const (
- updateOwnedConfig updateOwnedKind = "config"
- updateOwnedGenerated updateOwnedKind = "generated"
- updateOwnedHook updateOwnedKind = "hook"
- updateOwnedInterceptor updateOwnedKind = "interceptor"
-)
-
-type updateOwnershipProjection struct {
- kinds map[string]updateOwnedKind
- currentFiles map[string][]byte
- previous map[string]string
- targetKnown bool
-}
-
-func executionInterceptorPath(adapter string) string {
- return map[string]string{"cursor": ".cursorrules", "claude": "CLAUDE.md", "gemini": "GEMINI.md"}[adapter]
-}
-
-func executionInterceptorPaths(adapters []string) []string {
- paths := map[string]bool{}
- for _, adapter := range adapters {
- if path := executionInterceptorPath(adapter); path != "" {
- paths[path] = true
- }
- }
- return sortedKeys(paths)
-}
-
-func newUpdateOwnershipProjection(config ProjectConfig, currentFiles map[string][]byte, previous map[string]string) updateOwnershipProjection {
- projection := updateOwnershipProjection{
- kinds: map[string]updateOwnedKind{
- ".boatstack-project.json": updateOwnedConfig,
- ".product-loop/generated.lock.json": updateOwnedGenerated,
- },
- currentFiles: currentFiles, previous: previous, targetKnown: currentFiles != nil,
- }
- for path := range currentFiles {
- projection.kinds[filepath.ToSlash(path)] = updateOwnedGenerated
- }
- for path := range previous {
- projection.kinds[filepath.ToSlash(path)] = updateOwnedGenerated
- }
- for _, path := range HostHookPaths(config.Adapters) {
- projection.kinds[filepath.ToSlash(path)] = updateOwnedHook
- }
- for _, path := range executionInterceptorPaths(config.Adapters) {
- projection.kinds[filepath.ToSlash(path)] = updateOwnedInterceptor
- }
- return projection
-}
-
-func readUpdateVersion(repo, relative string, head bool) ([]byte, bool, error) {
- if head {
- value, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output()
- if err != nil {
- return nil, false, nil
- }
- return value, true, nil
- }
- path := filepath.Join(repo, filepath.FromSlash(relative))
- if err := rejectSymlinkComponents(repo, path); err != nil {
- return nil, false, err
- }
- value, err := os.ReadFile(path)
- if os.IsNotExist(err) {
- return nil, false, nil
- }
- return value, err == nil, err
-}
-
-func withoutOwnedHostHook(value []byte, exists bool) ([]byte, error) {
- if !exists {
- return []byte("{}"), nil
- }
- var config map[string]any
- if err := json.Unmarshal(value, &config); err != nil {
- return nil, err
- }
- hooks, ok := config["hooks"].(map[string]any)
- if ok {
- for event, raw := range hooks {
- entries, entriesOK := raw.([]any)
- if !entriesOK {
- continue
- }
- kept := []any{}
- for _, entry := range entries {
- if !containsBoatstackHook(entry) {
- kept = append(kept, entry)
- }
- }
- if len(kept) == 0 {
- delete(hooks, event)
- } else {
- hooks[event] = kept
- }
- }
- if len(hooks) == 0 {
- delete(config, "hooks")
- }
- }
- // Cursor's root version is generated alongside an otherwise Boatstack-only
- // hook document. Retain it whenever user-owned configuration also exists.
- if len(config) == 1 && config["version"] != nil {
- delete(config, "version")
- }
- return json.Marshal(config)
-}
-
-func withoutOwnedInterceptor(value []byte, exists bool) ([]byte, error) {
- if !exists {
- return []byte{}, nil
- }
- return withoutInterceptor(value)
-}
-
-func (projection updateOwnershipProjection) verify(repo, relative string) error {
- relative = filepath.ToSlash(relative)
- kind, owned := projection.kinds[relative]
- if !owned {
- return fmt.Errorf("path is outside the prepared Boatstack ownership projection")
- }
- base, baseExists, err := readUpdateVersion(repo, relative, true)
- if err != nil {
- return err
- }
- current, currentExists, err := readUpdateVersion(repo, relative, false)
- if err != nil {
- return err
- }
- switch kind {
- case updateOwnedConfig:
- if !currentExists {
- return fmt.Errorf("project configuration was removed")
- }
- return nil
- case updateOwnedGenerated:
- if expected, ok := projection.currentFiles[relative]; projection.targetKnown && ok {
- if !currentExists || !bytes.Equal(current, expected) {
- return fmt.Errorf("generated path does not match the prepared target")
- }
- return nil
- }
- if relative == ".product-loop/generated.lock.json" && !projection.targetKnown {
- var lock struct {
- Generator string `json:"generator"`
- Files map[string]string `json:"files"`
- }
- if !baseExists || json.Unmarshal(base, &lock) != nil || lock.Generator != Generator || len(lock.Files) == 0 {
- return fmt.Errorf("generated lock lacks installed Boatstack provenance")
- }
- return nil
- }
- expected, wasGenerated := projection.previous[relative]
- if !wasGenerated || !baseExists || SHA256Bytes(base) != expected {
- return fmt.Errorf("generated path lacks matching installed provenance")
- }
- if projection.targetKnown && currentExists {
- return fmt.Errorf("retired generated path was not removed")
- }
- return nil
- case updateOwnedHook:
- baseProjection, baseErr := withoutOwnedHostHook(base, baseExists)
- currentProjection, currentErr := withoutOwnedHostHook(current, currentExists)
- if baseErr != nil || currentErr != nil || !bytes.Equal(baseProjection, currentProjection) {
- return fmt.Errorf("non-Boatstack host-hook configuration changed")
- }
- return nil
- case updateOwnedInterceptor:
- baseProjection, baseErr := withoutOwnedInterceptor(base, baseExists)
- currentProjection, currentErr := withoutOwnedInterceptor(current, currentExists)
- if baseErr != nil || currentErr != nil || !bytes.Equal(baseProjection, currentProjection) {
- return fmt.Errorf("content outside the Boatstack interceptor markers changed")
- }
- return nil
- default:
- return fmt.Errorf("unsupported Boatstack ownership kind")
- }
-}
-
-func dirtyChangeIsOwned(repo, relative string, config ProjectConfig) bool {
- projection := newUpdateOwnershipProjection(config, nil, previousFiles(repo))
- return projection.verify(repo, relative) == nil
-}
-
-func ValidateUpdateWorkspaceForRepair(repo string, config ProjectConfig, repairResult InstallationRepairResult, repair bool) error {
- version, err := normalizedVersion(Version)
- if err != nil {
- return err
- }
- wantBranch := "chore/update-boatstack-" + version
- branch := gitOutput(repo, "branch", "--show-current")
- if branch != wantBranch {
- return fmt.Errorf("update must run on %s; current branch is %s", wantBranch, branch)
- }
- dirtyPaths, statusErr := updateDirtyPaths(repo)
- if statusErr != nil {
- return fmt.Errorf("cannot inspect update worktree: %w", statusErr)
- }
- if len(dirtyPaths) > 0 {
- allowed := map[string]bool{}
- for _, item := range repairResult.Items {
- if item.Classification == RepairOwnedStale || (repair && item.Classification == RepairOwnedDrifted) {
- allowed[item.Path] = true
- }
- }
- unexpected := []string{}
- for _, path := range dirtyPaths {
- if path != "" && (!allowed[path] || !dirtyChangeIsOwned(repo, path, config)) {
- unexpected = append(unexpected, path)
- }
- }
- if len(unexpected) > 0 {
- sort.Strings(unexpected)
- return fmt.Errorf("update branch must start with a clean worktree except for verified owned-state repair; non-repairable changes: %s", strings.Join(unexpected, ", "))
- }
- }
- defaultBranch := strings.TrimSpace(config.Project.DefaultBranch)
- if defaultBranch == "" {
- return fmt.Errorf("project.default_branch is required for updates")
- }
- head := gitOutput(repo, "rev-parse", "HEAD")
- remoteHead := gitOutput(repo, "rev-parse", "origin/"+defaultBranch)
- if head == "" || remoteHead == "" || head != remoteHead {
- return fmt.Errorf("update branch must start from the current origin/%s", defaultBranch)
- }
- if err := CheckPreviousGeneratedState(repo); err != nil {
- allowed := false
- for _, item := range repairResult.Items {
- if item.Classification == RepairOwnedStale || (repair && item.Classification == RepairOwnedDrifted) {
- allowed = true
- }
- }
- if !allowed {
- return err
- }
- }
- if err := CheckInstalledHostHooks(repo, config.Adapters); err != nil && !repair {
- return fmt.Errorf("host-hook drift blocks update: %w", err)
- }
- var schemaProblems []string
- if config.SchemaVersion != currentSchemaVersion() {
- schemaProblems = append(schemaProblems, fmt.Sprintf("schema_version %d is behind current %d", config.SchemaVersion, currentSchemaVersion()))
- }
- if len(schemaProblems) > 0 {
- return fmt.Errorf("config schema is behind; run /boatstack-update: %s", strings.Join(schemaProblems, ", "))
- }
- if err := CheckExistingInstallProvenance(repo); err != nil && !repair {
- return err
- }
- return nil
-}
diff --git a/boatstack/update_publication.go b/boatstack/update_publication.go
deleted file mode 100644
index 28f5867..0000000
--- a/boatstack/update_publication.go
+++ /dev/null
@@ -1,391 +0,0 @@
-package boatstack
-
-import (
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strings"
-)
-
-const updatePublicationSchemaVersion = 1
-
-type UpdatePublicationPreview struct {
- SchemaVersion int `json:"schema_version"`
- Version string `json:"version"`
- BaseBranch string `json:"base_branch"`
- HeadBranch string `json:"head_branch"`
- StartingHeadCommit string `json:"starting_head_commit"`
- ChangedPaths []string `json:"changed_paths"`
- PackageFingerprint string `json:"package_fingerprint"`
- Title string `json:"title"`
- Body string `json:"body"`
- PreviewPath string `json:"preview_path"`
- Fingerprint string `json:"fingerprint"`
- Repair *InstallationRepairResult `json:"repair,omitempty"`
-}
-
-type UpdatePublishOptions struct {
- Repo string
- PreviewPath string
- ExpectedFingerprint string
-}
-
-func updatePreviewDirectory(repo, version string) (string, error) {
- common, err := gitCommonDir(repo)
- if err != nil {
- return "", err
- }
- segment, err := safeCacheSegment(version, "update version")
- if err != nil {
- return "", err
- }
- directory := filepath.Join(common, "boatstack", "updates", segment)
- if err := rejectSymlinkComponents(common, directory); err != nil {
- return "", err
- }
- return directory, nil
-}
-
-func updatePreviewPath(repo, version string) (string, error) {
- directory, err := updatePreviewDirectory(repo, version)
- if err != nil {
- return "", err
- }
- return filepath.Join(directory, "pr-preview.json"), nil
-}
-
-func updateChangedPathsAgainst(repo, base string) ([]string, error) {
- value, err := gitCommand(repo, "diff", "--name-only", "--diff-filter=ACDMR", base)
- if err != nil {
- return nil, err
- }
- untracked, err := gitCommand(repo, "ls-files", "--others", "--exclude-standard")
- if err != nil {
- return nil, err
- }
- seen := map[string]bool{}
- for _, line := range strings.Split(value+"\n"+untracked, "\n") {
- path := filepath.ToSlash(strings.TrimSpace(line))
- if path != "" {
- seen[path] = true
- }
- }
- return sortedKeys(seen), nil
-}
-
-func installedGeneratedPaths(repo string) map[string]bool {
- result := map[string]bool{}
- for path := range previousFiles(repo) {
- result[filepath.ToSlash(path)] = true
- }
- return result
-}
-
-// validateGeneratedCommitReadiness prevents a locally healthy update from
-// publishing a lock that names an untracked file hidden by repository ignore
-// rules. Tracked files remain valid even when a later broad ignore rule matches.
-func validateGeneratedCommitReadiness(repo string) error {
- for path := range installedGeneratedPaths(repo) {
- absolute, err := resolveRepositoryRelativePath(repo, path)
- if err != nil {
- return err
- }
- if _, err := os.Lstat(absolute); os.IsNotExist(err) {
- continue
- } else if err != nil {
- return err
- }
- if _, err := gitCommand(repo, "ls-files", "--error-unmatch", "--", path); err == nil {
- continue
- }
- command := exec.Command("git", "-C", repo, "check-ignore", "-q", "--", path)
- if err := command.Run(); err == nil {
- return fmt.Errorf("generated update path is ignored and untracked: %s; add an explicit repository-owned ignore exception before publishing the update", path)
- } else if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 {
- return fmt.Errorf("cannot verify generated update path ignore state for %s: %w", path, err)
- }
- }
- return nil
-}
-
-func updateOwnedPaths(repo string, config ProjectConfig) map[string]bool {
- owned := installedGeneratedPaths(repo)
- owned[".boatstack-project.json"] = true
- for _, path := range HostHookPaths(config.Adapters) {
- owned[filepath.ToSlash(path)] = true
- }
- for _, adapter := range config.Adapters {
- switch adapter {
- case "cursor":
- owned[".cursorrules"] = true
- case "claude":
- owned["CLAUDE.md"] = true
- case "gemini":
- owned["GEMINI.md"] = true
- }
- }
- return owned
-}
-
-func validateUpdatePublicationPaths(repo, base string, config ProjectConfig, paths []string) error {
- owned := updateOwnedPaths(repo, config)
- unexpected := []string{}
- for _, path := range paths {
- if owned[path] || strings.HasPrefix(path, ".product-loop/") {
- continue
- }
- // A removed generated file is absent from the incoming generated lock.
- // Accept it only when its base content carries Boatstack's marker.
- baseValue, err := gitCommand(repo, "show", base+":"+path)
- if err == nil && strings.Contains(baseValue, Marker) {
- continue
- }
- unexpected = append(unexpected, path)
- }
- if len(unexpected) > 0 {
- sort.Strings(unexpected)
- return fmt.Errorf("update publication contains non-Boatstack paths: %s", strings.Join(unexpected, ", "))
- }
- return nil
-}
-
-func updatePackageFingerprint(repo, base string, paths []string) (string, error) {
- parts := []string{"base=" + base}
- for _, relative := range paths {
- path, err := resolveRepositoryRelativePath(repo, relative)
- if err != nil {
- return "", err
- }
- if info, statErr := os.Lstat(path); statErr == nil {
- if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return "", fmt.Errorf("update path is not a safe regular file: %s", relative)
- }
- hash, hashErr := SHA256File(path)
- if hashErr != nil {
- return "", hashErr
- }
- parts = append(parts, relative+"=file:"+hash)
- } else if os.IsNotExist(statErr) {
- parts = append(parts, relative+"=deleted")
- } else {
- return "", statErr
- }
- }
- return SHA256Bytes([]byte(strings.Join(parts, "\n"))), nil
-}
-
-func updatePreviewFingerprint(preview UpdatePublicationPreview) (string, error) {
- copy := preview
- copy.Fingerprint = ""
- // Publication may deterministically commit the already approved package.
- // The content fingerprint, not the pre-commit HEAD, is the authority.
- copy.StartingHeadCommit = ""
- value, err := MarshalJSON(copy)
- if err != nil {
- return "", err
- }
- return SHA256Bytes(value), nil
-}
-
-func PrepareUpdatePublication(repoPath, requestedVersion string) (UpdatePublicationPreview, error) {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- version, err := normalizedVersion(requestedVersion)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- baseBranch := strings.TrimSpace(config.Project.DefaultBranch)
- baseRef := "origin/" + baseBranch
- if _, err := gitCommand(repo, "rev-parse", "--verify", baseRef+"^{commit}"); err != nil {
- return UpdatePublicationPreview{}, fmt.Errorf("update preview requires fetched %s", baseRef)
- }
- headBranch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current"))
- if headBranch != "chore/update-boatstack-"+version {
- return UpdatePublicationPreview{}, fmt.Errorf("update preview requires branch chore/update-boatstack-%s; current branch is %s", version, headBranch)
- }
- if err := validateGeneratedCommitReadiness(repo); err != nil {
- return UpdatePublicationPreview{}, err
- }
- paths, err := updateChangedPathsAgainst(repo, baseRef)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- if len(paths) == 0 {
- return UpdatePublicationPreview{}, fmt.Errorf("Boatstack update produced no reviewable infrastructure diff")
- }
- if err := validateUpdatePublicationPaths(repo, baseRef, config, paths); err != nil {
- return UpdatePublicationPreview{}, err
- }
- packageFingerprint, err := updatePackageFingerprint(repo, baseRef, paths)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- previewPath, err := updatePreviewPath(repo, version)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- repairReceipt, err := loadInstallationRepairReceipt(repo, version)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- body := "## Why this change\n\nUpdate the repository-owned Boatstack infrastructure to " + version + ".\n\n## What changed\n\nOnly the fingerprinted Boatstack-generated files, host hooks, runtime provenance, and preserved integration state in this update package.\n"
- if repairReceipt != nil {
- body += "\nThe update also repairs fingerprinted Boatstack-owned control state. Repair package: `" + repairReceipt.PackageFingerprint + "`. The pre-repair files are retained in ignored per-worktree Git state at `" + repairReceipt.BackupPath + "`.\n"
- }
- body += "\n## Verification\n\n- Boatstack doctor passed after installation.\n- Generated-file and hook projections are validated by the update transaction.\n\n## Rollback\n\nRevert this infrastructure-only commit and rerun the previously pinned installer.\n"
- preview := UpdatePublicationPreview{
- SchemaVersion: updatePublicationSchemaVersion, Version: version, BaseBranch: baseBranch, HeadBranch: headBranch,
- StartingHeadCommit: gitOutput(repo, "rev-parse", "HEAD"), ChangedPaths: paths, PackageFingerprint: packageFingerprint,
- Title: "Update Boatstack to " + version,
- Body: body,
- PreviewPath: previewPath,
- Repair: repairReceipt,
- }
- preview.Fingerprint, err = updatePreviewFingerprint(preview)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- value, err := MarshalJSON(preview)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- if len(value) == 0 {
- return UpdatePublicationPreview{}, fmt.Errorf("refusing to write an empty update preview")
- }
- if err := atomicWriteMode(previewPath, value, 0o600); err != nil {
- return UpdatePublicationPreview{}, err
- }
- return preview, nil
-}
-
-func LoadUpdatePublicationPreview(path string) (UpdatePublicationPreview, error) {
- value, err := os.ReadFile(path)
- if err != nil {
- return UpdatePublicationPreview{}, err
- }
- if len(strings.TrimSpace(string(value))) == 0 {
- return UpdatePublicationPreview{}, fmt.Errorf("update preview is empty")
- }
- var preview UpdatePublicationPreview
- if err := DecodeJSON("load update publication preview", path, value, &preview); err != nil {
- return UpdatePublicationPreview{}, err
- }
- if preview.SchemaVersion != updatePublicationSchemaVersion || preview.Fingerprint == "" || preview.PackageFingerprint == "" || len(preview.ChangedPaths) == 0 {
- return UpdatePublicationPreview{}, fmt.Errorf("update preview identity is invalid")
- }
- expected, err := updatePreviewFingerprint(preview)
- if err != nil || expected != preview.Fingerprint {
- return UpdatePublicationPreview{}, fmt.Errorf("update preview fingerprint is invalid")
- }
- return preview, nil
-}
-
-func PublishUpdatePublication(options UpdatePublishOptions) (string, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return "", err
- }
- preview, err := LoadUpdatePublicationPreview(options.PreviewPath)
- if err != nil {
- return "", err
- }
- if options.ExpectedFingerprint == "" || options.ExpectedFingerprint != preview.Fingerprint {
- return "", fmt.Errorf("update publication fingerprint does not match the exact preview confirmed by the human")
- }
- current, err := PrepareUpdatePublication(repo, preview.Version)
- if err != nil {
- return "", err
- }
- if current.Fingerprint != preview.Fingerprint || current.PackageFingerprint != preview.PackageFingerprint {
- return "", fmt.Errorf("update package changed after confirmation; regenerate the preview")
- }
- if err := ghAvailable(repo); err != nil {
- return "", err
- }
- existingURL, exists, err := existingPRURL(repo)
- if err != nil {
- return "", err
- }
- target := "github-update-pr:" + preview.HeadBranch
- receipt, err := PrepareOperation(OperationPrepareOptions{
- Repo: repo, Kind: "publish-update-pr", Scope: OperationScope{Worktree: filepath.Base(repo), HeadBranch: preview.HeadBranch},
- Target: target, PackageFingerprint: preview.PackageFingerprint, AuthorizationFingerprint: preview.Fingerprint,
- RetryClass: "RECONCILE_FIRST", MaxAttempts: 3,
- ExpectedPostcondition: "origin contains the exact update commit and one pull request contains the fingerprinted update body",
- })
- if err != nil {
- return "", err
- }
- if receipt.State == OperationSucceeded {
- return receipt.Observation.Evidence, nil
- }
- if receipt.State == OperationReconcileRequired {
- result, detail, evidence := "OBSERVED_ABSENT", "no pull request exists for the update branch", preview.HeadBranch
- if exists {
- result, detail, evidence = "OBSERVED_PARTIAL", "the update PR exists; resume exact remaining postconditions", existingURL
- }
- if receipt, err = RecordOperationReconciliation(repo, receipt.OperationID, result, detail, evidence); err != nil {
- return "", err
- }
- }
- attemptKey := SHA256Bytes([]byte("publish-update-pr\x00" + preview.Fingerprint))
- begin, err := BeginOperation(repo, receipt.OperationID, attemptKey, "boatstack-helper publish-update-pr")
- if err != nil {
- if errors.Is(err, ErrOperationInFlight) {
- return "", fmt.Errorf("the identical update publication is already executing; inspect operation-status instead of repeating it")
- }
- return "", err
- }
- unknown := func(cause error, url string) (string, error) {
- _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "UNKNOWN", "update publication ended without a verifiable complete postcondition", url)
- return "", cause
- }
- if strings.TrimSpace(gitOutput(repo, "status", "--porcelain")) != "" {
- arguments := append([]string{"add", "--"}, preview.ChangedPaths...)
- if _, err := gitCommand(repo, arguments...); err != nil {
- return unknown(err, existingURL)
- }
- if _, err := gitCommand(repo, "commit", "-m", "chore: update Boatstack to "+preview.Version); err != nil {
- return unknown(err, existingURL)
- }
- }
- if _, err := gitCommand(repo, "push", "--set-upstream", "origin", preview.HeadBranch); err != nil {
- return unknown(err, existingURL)
- }
- temporary, err := os.CreateTemp("", "boatstack-update-pr-*.md")
- if err != nil {
- return unknown(err, existingURL)
- }
- temporaryPath := temporary.Name()
- defer os.Remove(temporaryPath)
- if _, err := temporary.WriteString(preview.Body); err != nil {
- temporary.Close()
- return unknown(err, existingURL)
- }
- if err := temporary.Close(); err != nil {
- return unknown(err, existingURL)
- }
- url := existingURL
- if !exists {
- url, err = commandOutput(repo, "gh", "pr", "create", "--base", preview.BaseBranch, "--head", preview.HeadBranch, "--title", preview.Title, "--body-file", temporaryPath)
- if err != nil {
- return unknown(err, "")
- }
- } else if _, err := commandOutput(repo, "gh", "pr", "edit", existingURL, "--title", preview.Title, "--body-file", temporaryPath); err != nil {
- return unknown(err, existingURL)
- }
- url = strings.TrimSpace(url)
- if _, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "update branch and pull request observed", url); err != nil {
- return "", err
- }
- return url, nil
-}
diff --git a/boatstack/update_publication_test.go b/boatstack/update_publication_test.go
deleted file mode 100644
index cd1d3a1..0000000
--- a/boatstack/update_publication_test.go
+++ /dev/null
@@ -1,245 +0,0 @@
-package boatstack
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-func updatePublicationTestRepo(t *testing.T, version string) string {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Adapters = []string{"cursor"}
- configValue, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- generatedPath := ".cursor/commands/boatstack-update.md"
- generatedValue := []byte("\nold\n")
- for path, value := range map[string][]byte{
- ".product-loop/project.json": configValue,
- generatedPath: generatedValue,
- "README.md": []byte("fixture\n"),
- } {
- absolute := filepath.Join(repo, filepath.FromSlash(path))
- if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(absolute, value, 0o644); err != nil {
- t.Fatal(err)
- }
- }
- lock, err := MarshalJSON(map[string]any{"files": map[string]string{generatedPath: SHA256Bytes(generatedValue)}})
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "generated.lock.json"), lock, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "base")
- remote := filepath.Join(t.TempDir(), "origin.git")
- if output, err := execCommand("git", "init", "--bare", remote); err != nil {
- t.Fatalf("git init --bare: %v: %s", err, output)
- }
- runGit(t, repo, "remote", "add", "origin", remote)
- runGit(t, repo, "push", "-u", "origin", "main")
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-"+version)
- if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(generatedPath)), []byte("\nnew\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- return repo
-}
-
-func execCommand(name string, arguments ...string) (string, error) {
- command := exec.Command(name, arguments...)
- value, err := command.CombinedOutput()
- return string(value), err
-}
-
-func TestPrepareUpdatePublicationIsAtomicAndRejectsProductPaths(t *testing.T) {
- repo := updatePublicationTestRepo(t, "v9.8.7")
- statusBefore := gitOutput(repo, "status", "--porcelain=v1", "--untracked-files=all")
- preview, err := PrepareUpdatePublication(repo, "v9.8.7")
- if err != nil {
- t.Fatal(err)
- }
- if preview.Fingerprint == "" || preview.PackageFingerprint == "" || len(preview.ChangedPaths) != 1 || preview.ChangedPaths[0] != ".cursor/commands/boatstack-update.md" {
- t.Fatalf("unexpected preview: %+v", preview)
- }
- value, err := os.ReadFile(preview.PreviewPath)
- common, commonErr := gitCommonDir(repo)
- relativeToCommon, relativeErr := filepath.Rel(common, preview.PreviewPath)
- outsideCommon := relativeErr != nil || relativeToCommon == ".." || strings.HasPrefix(relativeToCommon, ".."+string(filepath.Separator))
- statusAfter := gitOutput(repo, "status", "--porcelain=v1", "--untracked-files=all")
- if err != nil || commonErr != nil || len(value) == 0 || outsideCommon || statusAfter != statusBefore {
- t.Fatalf("preview was not a complete Git-common artifact: path=%s common=%s relative=%s size=%d read_err=%v common_err=%v rel_err=%v worktree_changed=%t", preview.PreviewPath, common, relativeToCommon, len(value), err, commonErr, relativeErr, statusAfter != statusBefore)
- }
- if err := os.WriteFile(filepath.Join(repo, "product.go"), []byte("package product\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := PrepareUpdatePublication(repo, "v9.8.7"); err == nil || !strings.Contains(err.Error(), "non-Boatstack paths") {
- t.Fatalf("product path entered update package: %v", err)
- }
-}
-
-// control-law: published-generated-lock-cannot-name-ignored-untracked-output
-func TestPrepareUpdatePublicationRejectsIgnoredGeneratedOutput(t *testing.T) {
- repo := updatePublicationTestRepo(t, "v9.8.7")
- ignoredPath := ".agents/skills/build/SKILL.md"
- if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("build/\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".gitignore")
- runGit(t, repo, "commit", "-m", "ignore build directories")
- runGit(t, repo, "push", "origin", "HEAD:main")
- absolute := filepath.Join(repo, filepath.FromSlash(ignoredPath))
- if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil {
- t.Fatal(err)
- }
- value := []byte("generated\n")
- if err := os.WriteFile(absolute, value, 0o644); err != nil {
- t.Fatal(err)
- }
- lock, err := MarshalJSON(map[string]any{"files": map[string]string{
- ".cursor/commands/boatstack-update.md": SHA256Bytes([]byte("\nnew\n")),
- ignoredPath: SHA256Bytes(value),
- }})
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "generated.lock.json"), lock, 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := PrepareUpdatePublication(repo, "v9.8.7"); err == nil || !strings.Contains(err.Error(), ignoredPath) {
- t.Fatalf("ignored generated output was accepted: %v", err)
- }
-}
-
-func TestUpdatePreviewCarriesFingerprintRepairProvenance(t *testing.T) {
- repo := updatePublicationTestRepo(t, "v9.8.7")
- result := InstallationRepairResult{
- SchemaVersion: 1, VerificationStatus: "REPAIR_AVAILABLE", InstalledVersion: "v9.8.6",
- TargetVersion: "v9.8.7", Direction: "UPGRADE", PackageFingerprint: strings.Repeat("a", 64),
- HeadBranch: "chore/update-boatstack-v9.8.7", StartingHeadCommit: gitOutput(repo, "rev-parse", "HEAD"),
- Items: []InstallationRepairItem{{Path: ".cursor/commands/boatstack-update.md", Classification: RepairOwnedDrifted, Reason: "fixture drift"}},
- }
- backup, err := writeInstallationRepairBackup(repo, result)
- if err != nil {
- t.Fatal(err)
- }
- preview, err := PrepareUpdatePublication(repo, "v9.8.7")
- if err != nil {
- t.Fatal(err)
- }
- if preview.Repair == nil || preview.Repair.PackageFingerprint != result.PackageFingerprint || preview.Repair.BackupPath != "boatstack/repair-backups/"+result.PackageFingerprint || !strings.Contains(preview.Body, result.PackageFingerprint) {
- t.Fatalf("repair provenance missing from update preview: %#v", preview)
- }
- if backup == "" {
- t.Fatal("repair backup path was not returned to the local operator")
- }
-}
-
-func TestPublishUpdatePublicationOwnsCommitPushAndSinglePR(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("fake gh fixture uses a POSIX shell")
- }
- repo := updatePublicationTestRepo(t, "v9.8.7")
- preview, err := PrepareUpdatePublication(repo, "v9.8.7")
- if err != nil {
- t.Fatal(err)
- }
- fakeDir := t.TempDir()
- marker := filepath.Join(fakeDir, "created")
- script := filepath.Join(fakeDir, "gh")
- scriptBody := `#!/bin/sh
-if [ "$1" = "auth" ]; then exit 0; fi
-if [ "$1" = "pr" ] && [ "$2" = "view" ]; then
- if [ -f "$BOATSTACK_UPDATE_CREATED" ]; then echo "https://github.com/example/repo/pull/42"; exit 0; fi
- echo "no pull requests found for branch" >&2; exit 1
-fi
-if [ "$1" = "pr" ] && [ "$2" = "create" ]; then
- touch "$BOATSTACK_UPDATE_CREATED"
- echo "https://github.com/example/repo/pull/42"
- exit 0
-fi
-exit 1
-`
- if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
- t.Setenv("BOATSTACK_UPDATE_CREATED", marker)
- url, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint})
- if err != nil {
- t.Fatal(err)
- }
- if url != "https://github.com/example/repo/pull/42" || !fileExists(marker) {
- t.Fatalf("unexpected publication: %s", url)
- }
- if status := strings.TrimSpace(gitOutput(repo, "status", "--porcelain")); status != "" {
- t.Fatalf("publisher left dirty state: %s", status)
- }
- if subject := gitOutput(repo, "log", "-1", "--pretty=%s"); subject != "chore: update Boatstack to v9.8.7" {
- t.Fatalf("publisher did not own the exact commit: %s", subject)
- }
- secondURL, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint})
- if err != nil || secondURL != url {
- t.Fatalf("terminal receipt did not suppress duplicate publication: %s %v", secondURL, err)
- }
-}
-
-func TestInterruptedUpdatePublicationReconcilesExistingPRWithoutDuplicate(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("fake gh fixture uses a POSIX shell")
- }
- repo := updatePublicationTestRepo(t, "v9.8.6")
- preview, err := PrepareUpdatePublication(repo, "v9.8.6")
- if err != nil {
- t.Fatal(err)
- }
- fakeDir := t.TempDir()
- marker := filepath.Join(fakeDir, "created")
- count := filepath.Join(fakeDir, "count")
- script := filepath.Join(fakeDir, "gh")
- scriptBody := `#!/bin/sh
-if [ "$1" = "auth" ]; then exit 0; fi
-if [ "$1" = "pr" ] && [ "$2" = "view" ]; then
- if [ -f "$BOATSTACK_UPDATE_CREATED" ]; then echo "https://github.com/example/repo/pull/43"; exit 0; fi
- echo "no pull requests found for branch" >&2; exit 1
-fi
-if [ "$1" = "pr" ] && [ "$2" = "create" ]; then
- touch "$BOATSTACK_UPDATE_CREATED"
- echo x >> "$BOATSTACK_UPDATE_COUNT"
- echo "connection closed after request" >&2
- exit 1
-fi
-if [ "$1" = "pr" ] && [ "$2" = "edit" ]; then exit 0; fi
-exit 1
-`
- if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
- t.Setenv("BOATSTACK_UPDATE_CREATED", marker)
- t.Setenv("BOATSTACK_UPDATE_COUNT", count)
- if _, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint}); err == nil {
- t.Fatal("interrupted GitHub response unexpectedly reported success")
- }
- url, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint})
- if err != nil || url != "https://github.com/example/repo/pull/43" {
- t.Fatalf("reconciliation did not recover observed PR: %s %v", url, err)
- }
- value, err := os.ReadFile(count)
- if err != nil || strings.Count(string(value), "x") != 1 {
- t.Fatalf("publication was duplicated: %q %v", value, err)
- }
-}
diff --git a/boatstack/update_test.go b/boatstack/update_test.go
deleted file mode 100644
index c6311d6..0000000
--- a/boatstack/update_test.go
+++ /dev/null
@@ -1,500 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-type updateRoundTripFunc func(*http.Request) (*http.Response, error)
-
-func (function updateRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
- return function(request)
-}
-
-func withUpdateGlobals(t *testing.T, version string, now time.Time, fetch func() (ReleaseInfo, error)) {
- t.Helper()
- oldVersion := Version
- oldCommit := SourceCommit
- oldChecksums := ChecksumsSHA256
- oldNow := updateNow
- oldFetch := fetchLatestRelease
- Version = version
- SourceCommit = "update-test-" + strings.TrimPrefix(version, "v")
- ChecksumsSHA256 = "update-test-checksums"
- updateNow = func() time.Time { return now }
- fetchLatestRelease = fetch
- t.Cleanup(func() {
- Version = oldVersion
- SourceCommit = oldCommit
- ChecksumsSHA256 = oldChecksums
- updateNow = oldNow
- fetchLatestRelease = oldFetch
- })
-}
-
-func updateCacheRepo(t *testing.T) string {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- return repo
-}
-
-func TestStableVersionComparison(t *testing.T) {
- for _, test := range []struct {
- left, right string
- want int
- }{
- {"v0.4.0", "v0.5.0", -1},
- {"0.5.0", "v0.5.0", 0},
- {"v1.0.0", "v0.9.9", 1},
- } {
- got, err := compareVersions(test.left, test.right)
- if err != nil || got != test.want {
- t.Fatalf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want)
- }
- }
- for _, invalid := range []string{"", "latest", "v0.5.0-rc.1", "v0.5", "v1.2.3.4"} {
- if _, err := parseStableVersion(invalid); err == nil {
- t.Fatalf("parseStableVersion accepted %q", invalid)
- }
- }
-}
-
-func TestLatestReleaseResponseValidation(t *testing.T) {
- oldClient := http.DefaultClient
- t.Cleanup(func() { http.DefaultClient = oldClient })
- for _, test := range []struct {
- name, body string
- status int
- transport error
- wantErr bool
- }{
- {"stable", `{"tag_name":"v0.5.0","name":"v0.5.0","body":"Release notes","html_url":"https://example.invalid/v0.5.0"}`, 200, nil, false},
- {"prerelease", `{"tag_name":"v0.5.0-rc.1","prerelease":true,"html_url":"https://example.invalid/rc"}`, 200, nil, true},
- {"malformed", `{`, 200, nil, true},
- {"rate limit", `{}`, 429, nil, true},
- {"timeout", ``, 0, errors.New("request timed out"), true},
- } {
- t.Run(test.name, func(t *testing.T) {
- http.DefaultClient = &http.Client{Transport: updateRoundTripFunc(func(*http.Request) (*http.Response, error) {
- if test.transport != nil {
- return nil, test.transport
- }
- return &http.Response{
- StatusCode: test.status,
- Body: io.NopCloser(strings.NewReader(test.body)),
- Header: make(http.Header),
- }, nil
- })}
- release, err := defaultFetchLatestRelease()
- if test.wantErr && err == nil {
- t.Fatalf("defaultFetchLatestRelease accepted %s: %#v", test.name, release)
- }
- if !test.wantErr && (err != nil || release.Version != "v0.5.0" || release.Notes != "Release notes") {
- t.Fatalf("stable release = %#v, %v", release, err)
- }
- })
- }
-}
-
-func TestUpdateCheckCachesAndBoundsNotifications(t *testing.T) {
- repo := updateCacheRepo(t)
- now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
- latest := ReleaseInfo{Version: "v0.5.0", Name: "Boatstack v0.5.0", URL: "https://github.com/operatorstack/boatstack/releases/tag/v0.5.0"}
- fetches := 0
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) {
- fetches++
- return latest, nil
- })
-
- first, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true})
- if err != nil || first.Status != "available" || !first.ShouldNotify || first.FromCache {
- t.Fatalf("first check = %#v, %v", first, err)
- }
- if fetches != 1 {
- t.Fatalf("first check fetched %d times", fetches)
- }
-
- updateNow = func() time.Time { return now.Add(2 * time.Hour) }
- second, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true})
- if err != nil || second.ShouldNotify || !second.FromCache {
- t.Fatalf("cached check = %#v, %v", second, err)
- }
- if fetches != 1 {
- t.Fatalf("cached check fetched %d times", fetches)
- }
-
- updateNow = func() time.Time { return now.Add(8 * 24 * time.Hour) }
- reminder, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true})
- if err != nil || !reminder.ShouldNotify {
- t.Fatalf("weekly reminder = %#v, %v", reminder, err)
- }
- if fetches != 2 {
- t.Fatalf("expired cache fetched %d times", fetches)
- }
-
- latest.Version = "v0.6.0"
- latest.Name = "Boatstack v0.6.0"
- latest.URL = "https://github.com/operatorstack/boatstack/releases/tag/v0.6.0"
- updateNow = func() time.Time { return now.Add(8*24*time.Hour + time.Hour) }
- newRelease, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Force: true, Notify: true})
- if err != nil || newRelease.LatestVersion != "v0.6.0" || !newRelease.ShouldNotify {
- t.Fatalf("new release = %#v, %v", newRelease, err)
- }
-}
-
-func TestUpdateCheckCurrentAndFailures(t *testing.T) {
- repo := updateCacheRepo(t)
- now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.5.0", now, func() (ReleaseInfo, error) {
- return ReleaseInfo{Version: "v0.5.0", URL: "https://example.invalid/v0.5.0"}, nil
- })
- result, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Force: true, Notify: true})
- if err != nil || result.Status != "current" || result.ShouldNotify {
- t.Fatalf("current check = %#v, %v", result, err)
- }
- if _, ok := CachedUpdate(repo); ok {
- t.Fatal("doctor cache exposed a current release as an update")
- }
-
- fetchLatestRelease = func() (ReleaseInfo, error) { return ReleaseInfo{}, errors.New("rate limited") }
- if _, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Force: true}); err == nil {
- t.Fatal("forced check hid its network failure")
- }
- if err := os.Remove(updateStatePath(repo)); err != nil {
- t.Fatal(err)
- }
- if notice, ok := PostShipUpdateNotice(repo, ""); ok {
- t.Fatalf("release lookup failure changed post-ship behavior: %#v", notice)
- }
- if cached, ok := CachedUpdate(repo); ok {
- t.Fatalf("failed release discovery wrote a misleading cache: %#v", cached)
- }
-}
-
-func updateInstalledRepo(t *testing.T) (string, map[string]IntegrationState) {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"scripts":{"test":"node --test"}}`), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: io.Discard}); err != nil {
- t.Fatal(err)
- }
- configPath := filepath.Join(repo, ".boatstack-project.json")
- config, _, err := LoadConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- gstack := config.Integrations["gstack"]
- gstack.Requested = true
- config.Integrations["gstack"] = gstack
- rawConfig, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil {
- t.Fatal(err)
- }
- bundle, err := BuildExportBundle(configPath, config, rawConfig, "boatstack")
- if err != nil {
- t.Fatal(err)
- }
- if err := WriteExport(repo, bundle.Files); err != nil {
- t.Fatal(err)
- }
- states := map[string]IntegrationState{
- "gstack": {Requested: true, Status: "installed", Version: GStackRef, Detail: "fixture installation"},
- "spec-kit": {Requested: false, Status: "not_selected", Version: SpecKitVersion},
- }
- var prior installLock
- lockValue, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"))
- if err != nil || json.Unmarshal(lockValue, &prior) != nil {
- t.Fatalf("read install lock: %v", err)
- }
- binaryPath, err := resolveRepositoryRelativePath(repo, prior.BinaryPath)
- if err != nil {
- t.Fatal(err)
- }
- binaryHash, err := SHA256File(binaryPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil {
- t.Fatal(err)
- }
- cursorHooks := filepath.Join(repo, ".cursor", "hooks.json")
- hooks := map[string]any{}
- hooksValue, err := os.ReadFile(cursorHooks)
- if err != nil || json.Unmarshal(hooksValue, &hooks) != nil {
- t.Fatalf("read Cursor hooks: %v", err)
- }
- hooks["user_setting"] = "preserve-me"
- updatedHooks, err := MarshalJSON(hooks)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(cursorHooks, updatedHooks, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", ".")
- runGit(t, repo, "commit", "-m", "install Boatstack")
- remote := filepath.Join(t.TempDir(), "origin.git")
- if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
- t.Fatalf("git init --bare: %v: %s", err, output)
- }
- runGit(t, repo, "remote", "add", "origin", remote)
- runGit(t, repo, "push", "--set-upstream", "origin", "main")
- return repo, states
-}
-
-func TestUpdateRequiresCleanCurrentDedicatedBranch(t *testing.T) {
- now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
-
- if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "chore/update-boatstack-v0.5.0") {
- t.Fatalf("default branch was not blocked: %v", err)
- }
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- if err := ValidateUpdateWorkspace(repo, config); err != nil {
- t.Fatalf("healthy update workspace was rejected: %v", err)
- }
- head := runGit(t, repo, "rev-parse", "HEAD")
- tree := runGit(t, repo, "write-tree")
- remoteAdvance := runGit(t, repo, "commit-tree", tree, "-p", head, "-m", "remote advance")
- runGit(t, repo, "update-ref", "refs/remotes/origin/main", remoteAdvance)
- if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "current origin/main") {
- t.Fatalf("stale default branch was not blocked: %v", err)
- }
- runGit(t, repo, "update-ref", "refs/remotes/origin/main", head)
- if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "clean worktree") {
- t.Fatalf("dirty update was not blocked: %v", err)
- }
- if err := os.Remove(filepath.Join(repo, "dirty.txt")); err != nil {
- t.Fatal(err)
- }
- generated := filepath.Join(repo, ".product-loop", "workflow.md")
- value, err := os.ReadFile(generated)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(generated, append(value, []byte("\ndrift\n")...), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := CheckPreviousGeneratedState(repo); err == nil || !strings.Contains(err.Error(), "workflow.md") {
- t.Fatalf("generated drift was not detected: %v", err)
- }
- if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "clean worktree") {
- t.Fatalf("tracked generated drift was not blocked before mutation: %v", err)
- }
-}
-
-func TestRunUpdateRejectsInvalidWorkspaceBeforePreparingOperation(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
-
- err := RunUpdate(InitOptions{Repo: repo, Yes: true, Output: io.Discard})
- if err == nil || !strings.Contains(err.Error(), "chore/update-boatstack-v0.5.0") {
- t.Fatalf("update on main did not fail at the branch precondition: %v", err)
- }
- receipts, receiptErr := operationReceipts(repo)
- if receiptErr != nil {
- t.Fatal(receiptErr)
- }
- for _, receipt := range receipts {
- if receipt.Kind == "install-update" && receipt.Target == "boatstack-install:v0.5.0" {
- t.Fatalf("invalid workspace consumed a durable update attempt: %#v", receipt)
- }
- }
-}
-
-func replaceInterceptorBody(t *testing.T, repo, relative, body string) {
- t.Helper()
- path := filepath.Join(repo, relative)
- value, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- start := strings.Index(string(value), interceptorHeader)
- end := strings.Index(string(value), interceptorFooter)
- if start < 0 || end < start {
- t.Fatalf("%s has no complete interceptor boundary", relative)
- }
- updated := string(value[:start]) + interceptorHeader + body + interceptorFooter + string(value[end+len(interceptorFooter):])
- if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestUpdateDiffScopeUsesMarkerBoundariesForEveryInterceptor(t *testing.T) {
- repo, _ := updateInstalledRepo(t)
- config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json"))
- if err != nil {
- t.Fatal(err)
- }
- config.Adapters = append(config.Adapters, "gemini")
- if err := injectExecutionInterceptor(repo, "GEMINI.md"); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "GEMINI.md")
- runGit(t, repo, "commit", "-m", "add Gemini interceptor fixture")
-
- for _, relative := range executionInterceptorPaths(config.Adapters) {
- replaceInterceptorBody(t, repo, relative, "old Boatstack boundary")
- }
- if _, err := checkUpdateDiffScope(repo, map[string][]byte{}, map[string]string{}, config); err != nil {
- t.Fatalf("marker-bounded interceptor migration was rejected: %v", err)
- }
-
- claudePath := filepath.Join(repo, "CLAUDE.md")
- value, err := os.ReadFile(claudePath)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(claudePath, append(value, []byte("user-owned change\n")...), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := checkUpdateDiffScope(repo, map[string][]byte{}, map[string]string{}, config); err == nil || !strings.Contains(err.Error(), "CLAUDE.md") {
- t.Fatalf("change outside interceptor markers was accepted: %v", err)
- }
-}
-
-func TestRunUpdateMigratesStaleInterceptorsWithoutScopeContradiction(t *testing.T) {
- now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, _ := updateInstalledRepo(t)
- for _, relative := range []string{".cursorrules", "CLAUDE.md"} {
- replaceInterceptorBody(t, repo, relative, "old Boatstack boundary")
- }
- runGit(t, repo, "add", ".cursorrules", "CLAUDE.md")
- runGit(t, repo, "commit", "-m", "record stale interceptors")
- runGit(t, repo, "push", "origin", "main")
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
-
- var output bytes.Buffer
- if err := RunUpdate(InitOptions{Repo: repo, Repair: true, Yes: true, Output: &output}); err != nil {
- t.Fatalf("stale interceptor update failed: %v\n%s", err, output.String())
- }
- for _, relative := range []string{".cursorrules", "CLAUDE.md"} {
- value, err := os.ReadFile(filepath.Join(repo, relative))
- if err != nil || !strings.Contains(string(value), strings.TrimSpace(ExecutionBoundaryDX)) || strings.Contains(string(value), "old Boatstack boundary") {
- t.Fatalf("%s was not migrated to the target boundary: %v", relative, err)
- }
- }
-}
-
-func TestUpdateDirtyPathsPreserveSpacesAndRejectRenames(t *testing.T) {
- repo := updateCacheRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "space name.txt"), []byte("fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- paths, err := updateDirtyPaths(repo)
- if err != nil || len(paths) != 1 || paths[0] != "space name.txt" {
- t.Fatalf("NUL-delimited status paths were not preserved: %#v %v", paths, err)
- }
-}
-
-func TestDoctorReadsCachedUpdateWithoutNetwork(t *testing.T) {
- now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) {
- panic("doctor initiated release traffic")
- })
- repo, _ := updateInstalledRepo(t)
- if err := writeUpdateState(repo, UpdateState{
- SchemaVersion: 1,
- CurrentVersion: "v0.4.0",
- LatestVersion: "v0.5.0",
- ReleaseURL: "https://example.invalid/v0.5.0",
- CheckedAt: now,
- }); err != nil {
- t.Fatal(err)
- }
- if err := Doctor(repo); err != nil {
- t.Fatal(err)
- }
- if cached, ok := CachedUpdate(repo); !ok || cached.LatestVersion != "v0.5.0" {
- t.Fatalf("cached update missing after offline doctor: %#v, %t", cached, ok)
- }
-}
-
-func TestRunUpdatePreservesConfigurationAndIntegrations(t *testing.T) {
- now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
- withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil })
- repo, beforeStates := updateInstalledRepo(t)
- configPath := filepath.Join(repo, ".boatstack-project.json")
- beforeConfig, err := os.ReadFile(configPath)
- if err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0")
- Version = "v0.5.0"
- SourceCommit = "update-test-0.5.0"
- var output bytes.Buffer
- if err := RunUpdate(InitOptions{Repo: repo, Yes: true, Output: &output}); err != nil {
- t.Fatal(err)
- }
- if got := runGit(t, repo, "rev-parse", "HEAD"); got != runGit(t, repo, "rev-parse", "origin/main") {
- t.Fatal("update preparation committed before open update PR")
- }
- afterConfig, err := os.ReadFile(configPath)
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(beforeConfig, afterConfig) {
- t.Fatal("update rewrote project configuration")
- }
- config, _, err := LoadConfig(configPath)
- if err != nil {
- t.Fatal(err)
- }
- afterStates, err := readInstalledIntegrations(repo, config)
- if err != nil {
- t.Fatal(err)
- }
- for name, before := range beforeStates {
- if afterStates[name] != before {
- t.Fatalf("integration %s changed: %#v -> %#v", name, before, afterStates[name])
- }
- }
- hooksValue, err := os.ReadFile(filepath.Join(repo, ".cursor", "hooks.json"))
- if err != nil || !strings.Contains(string(hooksValue), "preserve-me") {
- t.Fatalf("update removed user-owned host settings: %v", err)
- }
- for _, expected := range []string{"updated to v0.5.0", "no product files changed", "open update PR", "never merge automatically"} {
- if !strings.Contains(output.String(), expected) {
- t.Fatalf("update output is missing %q: %s", expected, output.String())
- }
- }
- for _, changed := range updateChangedPaths(repo) {
- if changed == "package.json" || strings.HasSuffix(changed, ".go") {
- t.Fatalf("update touched product path %s", changed)
- }
- }
-}
diff --git a/boatstack/v2_kernel.go b/boatstack/v2_kernel.go
new file mode 100644
index 0000000..e2e0d47
--- /dev/null
+++ b/boatstack/v2_kernel.go
@@ -0,0 +1,211 @@
+package boatstack
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/operatorstack/boatstack/boatstack/internal/effects"
+ "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/kernel/supervisor"
+ "github.com/operatorstack/boatstack/boatstack/internal/plant"
+ "github.com/operatorstack/boatstack/boatstack/internal/surfaces"
+)
+
+var (
+ Version = "v2.0.0-dev"
+ SourceCommit = "unknown"
+ ChecksumsSHA256 = "development"
+)
+
+// V2Kernel is a product facade over the authoritative engine and its concrete
+// plant/effect ports. It owns no independent durable or lifecycle state.
+type V2Kernel struct {
+ registry catalog.Registry
+ resolver plant.Resolver
+ observer plant.Observer
+ engine engine.Engine
+ clock effects.Clock
+}
+
+func NewV2Kernel(externalStateRoot string) (V2Kernel, error) {
+ clock := effects.Clock{}
+ resolver, err := plant.NewResolver(externalStateRoot)
+ if err != nil {
+ return V2Kernel{}, err
+ }
+ observer, err := plant.NewObserver(resolver, clock)
+ if err != nil {
+ return V2Kernel{}, err
+ }
+ locker, err := effects.NewLocker(resolver)
+ if err != nil {
+ return V2Kernel{}, err
+ }
+ journal, err := effects.NewJournal(resolver, clock)
+ if err != nil {
+ return V2Kernel{}, err
+ }
+ receipts, err := effects.NewReceiptStore(resolver, clock)
+ if err != nil {
+ return V2Kernel{}, err
+ }
+ driver, err := effects.NewDriver(resolver, clock, effects.NewNativeBoundary())
+ if err != nil {
+ return V2Kernel{}, err
+ }
+ registry := catalog.Default()
+ runtimeEngine, err := engine.New(registry, observer, clock, locker, journal, driver, receipts)
+ if err != nil {
+ return V2Kernel{}, err
+ }
+ return V2Kernel{registry: registry, resolver: resolver, observer: observer, engine: runtimeEngine, clock: clock}, nil
+}
+
+func (k V2Kernel) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) {
+ response := surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation}
+ if err := request.Validate(k.clock.Now()); err != nil {
+ response.Error = err.Error()
+ return response, err
+ }
+ if request.Operation == surfaces.OperationCatalog {
+ response.Catalog = k.registry.All()
+ return response, nil
+ }
+ invocation, err := k.resolver.ResolveInvocation(ctx, request.Repository, request.Host, request.CorrelationID)
+ if err != nil {
+ response.Error = err.Error()
+ return response, err
+ }
+ if request.RepositoryAuthority {
+ request.Authority, err = k.deriveRepositoryAuthority(ctx, invocation, request.Authority)
+ if err != nil {
+ response.Error = err.Error()
+ return response, err
+ }
+ }
+ switch request.Operation {
+ case surfaces.OperationResolve:
+ resolution, resolveErr := k.engine.Resolve(ctx, engine.ResolveRequest{Invocation: invocation, Goal: request.Goal, Authority: request.Authority, Requested: request.TransitionID})
+ if resolveErr != nil {
+ response.Error = resolveErr.Error()
+ return response, resolveErr
+ }
+ response.Goal, response.Snapshot, response.Decision = resolution.Goal, &resolution.Snapshot, &resolution.Decision
+ return response, nil
+ case surfaces.OperationApply, surfaces.OperationRecover:
+ result, applyErr := k.engine.Apply(ctx, engine.ApplyRequest{
+ ResolveRequest: engine.ResolveRequest{Invocation: invocation, Goal: request.Goal, Authority: request.Authority, Requested: request.TransitionID},
+ FlowID: request.FlowID, Parameters: request.Parameters, IdempotencyKey: request.IdempotencyKey, AdmissionLifetime: 2 * time.Minute,
+ })
+ response.Goal, response.Snapshot = result.Goal, &result.Target
+ if result.Decision.Kind != "" {
+ response.Decision = &result.Decision
+ }
+ if result.Admission.ID != "" {
+ response.Admission = &result.Admission
+ }
+ if result.Receipt.ID != "" {
+ response.Receipt = &result.Receipt
+ }
+ response.Replayed = result.Replayed
+ if applyErr != nil {
+ response.Error = applyErr.Error()
+ if result.Target.Fingerprint == "" {
+ response.Snapshot = &result.Source
+ }
+ return response, applyErr
+ }
+ return response, nil
+ case surfaces.OperationDoctor:
+ observation, observeErr := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation})
+ if observeErr != nil {
+ response.Doctor = &surfaces.DoctorReport{Healthy: false, TransitionCount: k.registry.Len(), Detail: observeErr.Error()}
+ response.Error = observeErr.Error()
+ return response, observeErr
+ }
+ snapshot, canonicalErr := model.Canonicalize(observation)
+ if canonicalErr != nil {
+ response.Doctor = &surfaces.DoctorReport{Healthy: false, TransitionCount: k.registry.Len(), Detail: canonicalErr.Error()}
+ response.Error = canonicalErr.Error()
+ return response, canonicalErr
+ }
+ response.Snapshot = &snapshot
+ response.Doctor = &surfaces.DoctorReport{Healthy: k.registry.Len() == catalog.DefaultTransitionCount, TransitionCount: k.registry.Len(), Snapshot: snapshot.Fingerprint, Detail: "V2 kernel, observation, and catalog are valid"}
+ return response, nil
+ case surfaces.OperationEvents:
+ layout, _, layoutErr := k.resolver.ResolveLayout(ctx, invocation)
+ if layoutErr != nil {
+ return response, layoutErr
+ }
+ events, readErr := readV2Events(layout.EventPath)
+ if readErr != nil {
+ response.Error = readErr.Error()
+ return response, readErr
+ }
+ response.Events = events
+ return response, nil
+ case surfaces.OperationGuard:
+ observation, observeErr := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation})
+ if observeErr != nil {
+ response.Error = observeErr.Error()
+ return response, observeErr
+ }
+ snapshot, canonicalErr := model.Canonicalize(observation)
+ if canonicalErr != nil {
+ response.Error = canonicalErr.Error()
+ return response, canonicalErr
+ }
+ intent := surfaces.ClassifyCommandIntent(request.Command)
+ guard := supervisor.New(k.registry).Guard(snapshot, intent)
+ response.Snapshot, response.Guard = &snapshot, &guard
+ return response, nil
+ default:
+ return response, fmt.Errorf("unsupported surface operation %q", request.Operation)
+ }
+}
+
+func (k V2Kernel) deriveRepositoryAuthority(ctx context.Context, invocation model.InvocationContext, bundle protocol.AuthorityBundle) (protocol.AuthorityBundle, error) {
+ for _, receipt := range bundle.Receipts {
+ if receipt.Class == catalog.AuthorityRepository {
+ return protocol.AuthorityBundle{}, fmt.Errorf("repository authority must be derived once by the V2 kernel")
+ }
+ }
+ observation, err := k.observer.Observe(ctx, ports.ObservationRequest{Invocation: invocation})
+ if err != nil {
+ return protocol.AuthorityBundle{}, err
+ }
+ snapshot, err := model.Canonicalize(observation)
+ if err != nil {
+ return protocol.AuthorityBundle{}, err
+ }
+ return protocol.DeriveRepositoryAuthority(snapshot, bundle, k.clock.Now())
+}
+
+func readV2Events(path string) ([]map[string]any, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer file.Close()
+ var events []map[string]any
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ var event map[string]any
+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
+ return nil, err
+ }
+ events = append(events, event)
+ }
+ return events, scanner.Err()
+}
diff --git a/boatstack/visual_attach_conformance_test.go b/boatstack/visual_attach_conformance_test.go
deleted file mode 100644
index a608464..0000000
--- a/boatstack/visual_attach_conformance_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package boatstack
-
-import (
- "strings"
- "testing"
-)
-
-// control-law: prescriptive-closure-every-stage-names-a-runnable-command
-// control-law: turn-ends-only-at-the-operator-frontier
-//
-// A published-open slice with an owed visual publication never resolves to a
-// dark prescription: visual_pending prescribes the agent-owned attach-evidence
-// retry, and legacy manual_required maps to the same hosted retry,
-// and both fire under BOTH terminals because the attachment completes the
-// publication itself — it is not merge pursuit.
-
-func publishedOpenStatus(visualPublication string) NextStatus {
- return NextStatus{
- VerificationStatus: "VERIFIED",
- ObservedStage: "PUBLISHED", Lifecycle: "PUBLISHED_OPEN", Feature: "demo",
- PRURL: "https://github.com/example/repo/pull/7", VisualPublication: visualPublication,
- }
-}
-
-func TestOwedVisualAttachmentNeverResolvesDark(t *testing.T) {
- t.Run("visual_pending_prescribes_the_retry", func(t *testing.T) {
- cmd, followUp := prescribeVisualAttach(".", publishedOpenStatus("visual_pending"))
- if cmd == nil || cmd.Verb != "attach-evidence" || !cmd.AutoDerivable {
- t.Fatalf("visual_pending did not prescribe the derivable retry: %+v", cmd)
- }
- if strings.Join(cmd.Args, " ") != "--feature demo" {
- t.Fatalf("retry arguments are not state-derived: %v", cmd.Args)
- }
- if cmd.Transition != MarkerPublishedAttach {
- t.Fatalf("retry must carry the attach marker, got %s", cmd.Transition)
- }
- if followUp == "" {
- t.Fatal("the retry prescription owes its manual-fallback follow-up")
- }
- })
-
- t.Run("manual_required_prescribes_the_hosted_retry", func(t *testing.T) {
- cmd, _ := prescribeVisualAttach(".", publishedOpenStatus("manual_required"))
- if cmd == nil || cmd.Verb != "attach-evidence" || !cmd.AutoDerivable {
- t.Fatalf("manual_required did not prescribe the hosted retry: %+v", cmd)
- }
- if strings.Join(cmd.Args, " ") != "--feature demo" {
- t.Fatalf("legacy retry arguments are not state-derived: %v", cmd.Args)
- }
- })
-
- t.Run("attach_fires_under_the_published_default_terminal", func(t *testing.T) {
- repo := nextTestRepo(t)
- next, err := nextControlFromStatus(repo, publishedOpenStatus("visual_pending"))
- if err != nil {
- t.Fatal(err)
- }
- if next.Terminal != TerminalPublished {
- t.Fatalf("fixture must exercise the published default, got %s", next.Terminal)
- }
- if next.Prescribed == nil || next.Prescribed.Verb != "attach-evidence" {
- t.Fatalf("owed attachment resolved dark under the published terminal: %+v", next.Prescribed)
- }
- if next.Actor != NextActorAgent {
- t.Fatalf("the derivable retry is the agent's step, got %s", next.Actor)
- }
- })
-
- t.Run("no_owed_attachment_prescribes_nothing", func(t *testing.T) {
- if cmd, _ := prescribeVisualAttach(".", publishedOpenStatus("")); cmd != nil {
- t.Fatalf("nothing is owed but something was prescribed: %+v", cmd)
- }
- })
-
- t.Run("goal_escape_still_demotes_and_stops", func(t *testing.T) {
- status := publishedOpenStatus("visual_pending")
- status.GoalEscape = "pr_closed"
- if cmd, _ := prescribeVisualAttach(".", status); cmd != nil {
- t.Fatalf("a fired escape must prescribe nothing: %+v", cmd)
- }
- })
-
- t.Run("attach_marker_is_never_auto_driven", func(t *testing.T) {
- cmd, _ := prescribeVisualAttach(".", publishedOpenStatus("visual_pending"))
- if canAutoDrive(cmd, autoDrivableTransitions) {
- t.Fatal("the attach retry must be prescribed, never driven")
- }
- })
-}
diff --git a/boatstack/visual_delivery_strengthening_test.go b/boatstack/visual_delivery_strengthening_test.go
deleted file mode 100644
index 888b72b..0000000
--- a/boatstack/visual_delivery_strengthening_test.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package boatstack
-
-import (
- "net/http"
- "net/http/httptest"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-// control-law: registered-visual-surfaces-share-one-impact-decision
-func TestVisualSurfaceChangesAreScreenshotCandidates(t *testing.T) {
- got := visualSurfaceChangedPaths([]string{"apps/web/page.tsx", "api/server.go"}, []VisualSurface{{ID: "web", Paths: []string{"apps/web/**"}}})
- if len(got) != 1 || got[0] != "apps/web/page.tsx" {
- t.Fatalf("unexpected candidates: %#v", got)
- }
-}
-
-// control-law: scenario-verification-requires-a-receipt
-func TestPNGOnlyAndReceiptHarnessStatusesStaySeparate(t *testing.T) {
- dir := t.TempDir()
- missing := filepath.Join(dir, "missing.json")
- if _, err := loadVisualScenarioReceipt(missing, "checkout"); err == nil {
- t.Fatal("missing receipt must not verify a scenario")
- }
- receiptPath := filepath.Join(dir, "receipt.json")
- receipt := []byte(`{"scenario_id":"checkout","reached_state_or_url":"/checkout","checks":[{"name":"total visible","result":"PASS"}],"overall_result":"PASS"}`)
- if err := atomicWrite(receiptPath, receipt); err != nil {
- t.Fatal(err)
- }
- got, err := loadVisualScenarioReceipt(receiptPath, "checkout")
- if err != nil || got.OverallResult != "PASS" {
- t.Fatalf("receipt was not verified: %#v %v", got, err)
- }
-}
-
-// control-law: external-upload-requires-human-privacy-review
-func TestExternalUploadRefusesWithoutHumanPrivacyReview(t *testing.T) {
- repo := visualTestRepo(t)
- runGit(t, repo, "remote", "add", "origin", "https://github.com/o/n.git")
- manifest := PRVisualEvidenceManifest{Key: "x", Items: []PRVisualEvidenceItem{{ScenarioID: "s", PrivacyStatus: "clean"}}}
- _, err := (ExternalHostVisualEvidencePublisher{}).PublishVisualEvidence(repo, "https://github.com/o/n/pull/1", "", manifest)
- if err == nil || !strings.Contains(err.Error(), "HUMAN_REVIEWED") {
- t.Fatalf("expected privacy refusal, got %v", err)
- }
-}
-
-// control-law: only-verified-hosted-links-enter-the-comment
-func TestHostedURLVerificationRejectsInvalidResponse(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }))
- defer server.Close()
- if err := verifyHostedVisualURL(externalHostSpec{endpoint: server.URL, label: "example.invalid"}, server.URL+"/missing.png"); err == nil {
- t.Fatal("invalid hosted URL was accepted")
- }
-}
-
-func TestHostedURLVerificationRejectsUnexpectedDomain(t *testing.T) {
- err := verifyHostedVisualURL(externalHostSpec{endpoint: "https://litterbox.catbox.moe/upload", label: "litter.catbox.moe"}, "https://example.com/image.png")
- if err == nil || !strings.Contains(err.Error(), "unexpected domain") {
- t.Fatalf("unexpected host URL was accepted: %v", err)
- }
-}
-
-func TestExternalCommentIncludesJourneyContext(t *testing.T) {
- manifest := PRVisualEvidenceManifest{Key: "x", Scenarios: []PRVisualScenario{{ID: "s", Entry: "/start", State: "ready", Viewport: "800x600", Surface: "web", UserContext: "new customer", UserGoal: "finish checkout", JourneyStep: "confirm order", ReviewerContext: "verify price clarity", Expected: []string{"total visible"}}}, Items: []PRVisualEvidenceItem{{ScenarioID: "s"}}}
- body := composeExternalHostComment(externalHostSpec{label: "host"}, "72h", map[string]string{"s": "https://host/s.png"}, manifest)
- for _, want := range []string{"new customer", "finish checkout", "confirm order", "verify price clarity", "/start", "ready", "web"} {
- if !strings.Contains(body, want) {
- t.Fatalf("missing %q in comment:\n%s", want, body)
- }
- }
-}
-
-func TestExternalCommentCannotInjectUnverifiedImageLinks(t *testing.T) {
- manifest := PRVisualEvidenceManifest{Key: "x", Scenarios: []PRVisualScenario{{ID: "s", Viewport: "800x600", Expected: []string{""}}}, Items: []PRVisualEvidenceItem{{ScenarioID: "s"}}}
- body := composeExternalHostComment(externalHostSpec{label: "host"}, "72h", map[string]string{"s": "https://host/s.png"}, manifest)
- if strings.Count(body, "") {
- t.Fatalf("comment admitted an unverified image link:\n%s", body)
- }
-}
-
-func TestScenarioAndCommandChangesInvalidateEvidenceIdentity(t *testing.T) {
- config := ProjectConfig{Project: Project{Commands: map[string]string{"visual": "capture-v1"}}}
- scenarios := []PRVisualScenario{{ID: "s", Entry: "/", State: "ready", Viewport: "800x600", Expected: []string{"visible"}}}
- s1, c1, err := currentVisualEvidenceIdentity(scenarios, config)
- if err != nil {
- t.Fatal(err)
- }
- scenarios[0].UserGoal = "complete purchase"
- s2, _, err := currentVisualEvidenceIdentity(scenarios, config)
- if err != nil || s1 == s2 {
- t.Fatal("scenario change did not stale identity")
- }
- config.Project.Commands["visual"] = "capture-v2"
- _, c2, err := currentVisualEvidenceIdentity(scenarios, config)
- if err != nil || c1 == c2 {
- t.Fatal("command change did not stale identity")
- }
-}
-
-func TestPublicationStateDoesNotChangeEvidenceFingerprint(t *testing.T) {
- manifest := PRVisualEvidenceManifest{SchemaVersion: visualEvidenceSchemaVersion, Key: "x", Policy: "require", Relevance: "not_relevant", RelevanceSource: "managed-plan", Reason: "nonvisual", Status: "NOT_APPLICABLE"}
- first, err := visualManifestFingerprint(manifest)
- if err != nil {
- t.Fatal(err)
- }
- manifest.Publication = PRVisualPublication{State: "visual_pending", PRURL: "https://github.com/o/n/pull/1", CommentURL: "https://github.com/o/n/pull/1#issuecomment-2"}
- second, err := visualManifestFingerprint(manifest)
- if err != nil || first != second {
- t.Fatal("publication retry changed evidence identity")
- }
-}
-
-// control-law: partial-host-failure-preserves-pr-and-evidence-identity
-func TestPartialExternalUploadLeavesVisualPendingOnSameFingerprint(t *testing.T) {
- repo := visualTestRepo(t)
- runGit(t, repo, "remote", "add", "origin", "https://github.com/o/n.git")
- dir := t.TempDir()
- paths := []string{filepath.Join(dir, "one.png"), filepath.Join(dir, "two.png")}
- for _, path := range paths {
- writeTestPNG(t, path)
- }
- now := time.Now().UTC().Truncate(time.Second).Format(time.RFC3339)
- manifest, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{
- Key: "partial", Policy: "require", Relevance: "relevant", RelevanceSource: "managed-plan", Status: "PASS",
- SourceCommit: "head", ProductDiffSHA256: strings.Repeat("a", 64), ScenarioDefinitionSHA256: strings.Repeat("b", 64), CaptureCommandSHA256: strings.Repeat("c", 64),
- Scenarios: []PRVisualScenario{{ID: "one", Entry: "/", State: "one", Viewport: "800x600", Expected: []string{"one"}}, {ID: "two", Entry: "/", State: "two", Viewport: "800x600", Expected: []string{"two"}}},
- Items: []PRVisualEvidenceItem{{ScenarioID: "one", Path: paths[0], Viewport: "800x600", CapturedAt: now, Status: "CAPTURED", PrivacyStatus: "human-reviewed", VerificationStatus: "CAPTURED"}, {ScenarioID: "two", Path: paths[1], Viewport: "800x600", CapturedAt: now, Status: "CAPTURED", PrivacyStatus: "human-reviewed", VerificationStatus: "CAPTURED"}},
- })
- if err != nil {
- t.Fatal(err)
- }
- uploads := 0
- var server *httptest.Server
- server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet {
- w.Header().Set("Content-Type", "image/png")
- _, _ = w.Write([]byte("png"))
- return
- }
- uploads++
- if uploads == 2 {
- http.Error(w, "partial failure", http.StatusServiceUnavailable)
- return
- }
- _, _ = w.Write([]byte(server.URL + "/one.png"))
- }))
- defer server.Close()
- original := visualExternalHosts["litterbox"]
- visualExternalHosts["litterbox"] = externalHostSpec{endpoint: server.URL, label: "local", withExpiry: true}
- defer func() { visualExternalHosts["litterbox"] = original }()
- prURL := "https://github.com/o/n/pull/7"
- err = attachVisualEvidence(repo, prURL, manifest, ExternalHostVisualEvidencePublisher{}, "require")
- if err == nil {
- t.Fatal("partial upload unexpectedly published")
- }
- pending, loadErr := LoadPRVisualEvidence(repo, manifest.Key)
- if loadErr != nil {
- t.Fatal(loadErr)
- }
- if pending.Publication.State != "visual_pending" || pending.Publication.PRURL != prURL || pending.Fingerprint != manifest.Fingerprint {
- t.Fatalf("partial failure lost PR or evidence identity: %#v", pending)
- }
-}
diff --git a/boatstack/visual_evidence.go b/boatstack/visual_evidence.go
deleted file mode 100644
index 854f043..0000000
--- a/boatstack/visual_evidence.go
+++ /dev/null
@@ -1,670 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "context"
- "fmt"
- "image/png"
- "net/http"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-)
-
-const visualEvidenceSchemaVersion = 1
-
-type PRVisualScenario struct {
- ID string `json:"id"`
- Entry string `json:"entry"`
- State string `json:"state"`
- Viewport string `json:"viewport"`
- Expected []string `json:"expected"`
- // Surface optionally names the product surface this scenario captures
- // (e.g. "web", "ops"), selecting a surface-scoped harness command
- // (project.commands["visual:"]) over the global one. omitempty
- // keeps existing manifest fingerprints byte-stable.
- Surface string `json:"surface,omitempty"`
- UserContext string `json:"user_context,omitempty"`
- UserGoal string `json:"user_goal,omitempty"`
- JourneyStep string `json:"journey_step,omitempty"`
- ReviewerContext string `json:"reviewer_context,omitempty"`
-}
-
-type PRVisualScenarioCheck struct {
- Name string `json:"name"`
- Result string `json:"result"`
-}
-
-// PRVisualScenarioReceipt is optional harness output. A valid receipt proves the
-// named scenario checks; PNG validity alone never creates this status.
-type PRVisualScenarioReceipt struct {
- ScenarioID string `json:"scenario_id"`
- Reached string `json:"reached_state_or_url"`
- Checks []PRVisualScenarioCheck `json:"checks"`
- OverallResult string `json:"overall_result"`
-}
-
-type PRVisualEvidenceItem struct {
- ScenarioID string `json:"scenario_id"`
- Path string `json:"path"`
- SHA256 string `json:"sha256"`
- MIMEType string `json:"mime_type"`
- Width int `json:"width"`
- Height int `json:"height"`
- DurationMS int `json:"duration_ms"`
- Viewport string `json:"viewport"`
- CapturedAt string `json:"captured_at"`
- Status string `json:"status"`
- PrivacyStatus string `json:"privacy_status"`
- VerificationStatus string `json:"verification_status,omitempty"`
- Receipt *PRVisualScenarioReceipt `json:"receipt,omitempty"`
-}
-
-type PRVisualPublication struct {
- State string `json:"state"`
- PRURL string `json:"pr_url,omitempty"`
- CommentURL string `json:"comment_url,omitempty"`
- UpdatedAt string `json:"updated_at,omitempty"`
- Detail string `json:"detail,omitempty"`
-}
-
-type PRVisualEvidenceManifest struct {
- SchemaVersion int `json:"schema_version"`
- Key string `json:"key"`
- Policy string `json:"policy"`
- Relevance string `json:"relevance"`
- RelevanceSource string `json:"relevance_source"`
- Reason string `json:"reason,omitempty"`
- Status string `json:"status"`
- SourceCommit string `json:"source_commit"`
- ProductDiffSHA256 string `json:"product_diff_sha256"`
- ScenarioDefinitionSHA256 string `json:"scenario_definition_sha256,omitempty"`
- CaptureCommandSHA256 string `json:"capture_command_sha256,omitempty"`
- Scenarios []PRVisualScenario `json:"scenarios,omitempty"`
- Items []PRVisualEvidenceItem `json:"items,omitempty"`
- Publication PRVisualPublication `json:"publication"`
- Fingerprint string `json:"fingerprint"`
-}
-
-// PRVisualPrivacyReview is deliberately separate from the capture manifest.
-// Human review authorizes the exact immutable pixels without rewriting their
-// evidence identity.
-type PRVisualPrivacyReview struct {
- SchemaVersion int `json:"schema_version"`
- Key string `json:"key"`
- EvidenceFingerprint string `json:"evidence_fingerprint"`
- PNGHashes []string `json:"png_sha256"`
- ReviewerIdentity string `json:"reviewer_identity"`
- ReviewedAt string `json:"reviewed_at"`
- Fingerprint string `json:"fingerprint"`
-}
-
-type PRVisualCapabilityReceipt struct {
- SchemaVersion int `json:"schema_version"`
- BoatstackVersion string `json:"boatstack_version"`
- LockfileSHA256 string `json:"lockfile_sha256,omitempty"`
- LaunchCommandHash string `json:"launch_command_sha256,omitempty"`
- BrowserVersion string `json:"browser_version,omitempty"`
- FrameworkConfigSHA string `json:"framework_config_sha256,omitempty"`
- HealthStatus string `json:"health_status"`
- VerifiedAt string `json:"verified_at"`
-}
-
-type PRVisualCaptureCapability struct {
- Kind string `json:"kind"`
- Command string `json:"command,omitempty"`
-}
-
-// ResolvePRVisualCaptureCapability implements the portable capability cut for the
-// visual capability. It selects repository-owned tooling (via the generic
-// ResolveCapability spine) before host or machine-local capabilities. The
-// browser-specific fallbacks below the repository cut are visual-only.
-func ResolvePRVisualCaptureCapability(repo string, config ProjectConfig, hostBrowser bool, suppliedLaunch string, expectedReceipt PRVisualCapabilityReceipt) (PRVisualCaptureCapability, error) {
- resolution, err := ResolveCapability("visual", config)
- if err != nil {
- return PRVisualCaptureCapability{}, err
- }
- if resolution.Kind == "repository-command" {
- return PRVisualCaptureCapability{Kind: "repository-command", Command: resolution.Command}, nil
- }
- if hostBrowser {
- return PRVisualCaptureCapability{Kind: "host-browser"}, nil
- }
- if suppliedLaunch = strings.TrimSpace(suppliedLaunch); suppliedLaunch != "" {
- return PRVisualCaptureCapability{Kind: "supplied-launch", Command: suppliedLaunch}, nil
- }
- if _, err := LoadPRVisualCapability(repo, expectedReceipt); err == nil {
- return PRVisualCaptureCapability{Kind: "machine-runtime"}, nil
- }
- return PRVisualCaptureCapability{Kind: "unavailable"}, nil
-}
-
-func ProbePRVisualReadiness(parent context.Context, url string, timeout time.Duration) error {
- if timeout <= 0 {
- timeout = 15 * time.Second
- }
- ctx, cancel := context.WithTimeout(parent, timeout)
- defer cancel()
- request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return err
- }
- response, err := (&http.Client{Timeout: timeout}).Do(request)
- if err != nil {
- return fmt.Errorf("visual readiness probe failed: %w", err)
- }
- defer response.Body.Close()
- if response.StatusCode < 200 || response.StatusCode >= 400 {
- return fmt.Errorf("visual readiness probe returned HTTP %d", response.StatusCode)
- }
- return nil
-}
-
-func normalizedPRVisualEvidencePolicy(value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return "off"
- }
- return value
-}
-
-// visualEscalationApplies decides when the configured suggest policy ships
-// with require semantics: the approved plan declares visual relevance with
-// concrete scenarios. A plan that promises pixels cannot ship without them.
-// The escalation is deliberately independent of capture-capability
-// availability — a missing harness is a provisioning gap the publication
-// denial names, never a license to ship unverified. `off` remains the global
-// opt-out (the predicate never fires) and a not_relevant plan decision (with
-// its reason) remains the per-feature escape for genuinely nonvisual changes.
-// control-law: plan-approved-scenarios-imply-require
-func visualEscalationApplies(configured, relevance string, scenarioCount int) bool {
- return configured == "suggest" && relevance == "relevant" && scenarioCount > 0
-}
-
-func visualEvidenceKey(mode, feature, head string) (string, error) {
- key := feature
- if mode == "ad-hoc" {
- key = previewSlug(head)
- }
- return safeCacheSegment(key, "visual evidence key")
-}
-
-func visualEvidenceDirectory(repo, key string) (string, error) {
- key, err := safeCacheSegment(key, "visual evidence key")
- if err != nil {
- return "", err
- }
- common, err := gitCommonDir(repo)
- if err != nil {
- return "", err
- }
- return filepath.Join(common, "boatstack", "visual-evidence", key), nil
-}
-
-func visualEvidenceManifestPath(repo, key string) (string, error) {
- directory, err := visualEvidenceDirectory(repo, key)
- if err != nil {
- return "", err
- }
- return filepath.Join(directory, "manifest.json"), nil
-}
-
-func visualPrivacyReviewPath(repo, key string) (string, error) {
- directory, err := visualEvidenceDirectory(repo, key)
- if err != nil {
- return "", err
- }
- return filepath.Join(directory, "privacy-review.json"), nil
-}
-
-func visualPrivacyReviewFingerprint(review PRVisualPrivacyReview) (string, error) {
- copy := review
- copy.Fingerprint = ""
- raw, err := MarshalJSON(copy)
- if err != nil {
- return "", err
- }
- return SHA256Bytes(raw), nil
-}
-
-func manifestPNGHashes(manifest PRVisualEvidenceManifest) []string {
- hashes := make([]string, 0, len(manifest.Items))
- for _, item := range manifest.Items {
- hashes = append(hashes, item.SHA256)
- }
- sort.Strings(hashes)
- return hashes
-}
-
-func validateVisualPrivacyReview(review PRVisualPrivacyReview, manifest PRVisualEvidenceManifest) error {
- if review.SchemaVersion != visualEvidenceSchemaVersion || review.Key != manifest.Key || review.EvidenceFingerprint != manifest.Fingerprint {
- return fmt.Errorf("visual privacy review is stale for the current evidence manifest")
- }
- if strings.TrimSpace(review.ReviewerIdentity) == "" {
- return fmt.Errorf("visual privacy review requires reviewer_identity")
- }
- if _, err := time.Parse(time.RFC3339, review.ReviewedAt); err != nil {
- return fmt.Errorf("visual privacy reviewed_at must be RFC3339: %w", err)
- }
- expected := manifestPNGHashes(manifest)
- actual := append([]string(nil), review.PNGHashes...)
- sort.Strings(actual)
- if len(expected) != len(actual) {
- return fmt.Errorf("visual privacy review does not bind every current PNG")
- }
- for i := range expected {
- if expected[i] != actual[i] {
- return fmt.Errorf("visual privacy review is stale because screenshot pixels changed")
- }
- }
- fingerprint, err := visualPrivacyReviewFingerprint(review)
- if err != nil {
- return err
- }
- if review.Fingerprint != fingerprint {
- return fmt.Errorf("visual privacy review fingerprint is stale")
- }
- return nil
-}
-
-// RecordPRVisualPrivacyReview records one human decision over the exact current
-// manifest fingerprint and PNG hashes. It never modifies capture evidence.
-// control-law: pixels-require-exact-human-privacy-receipt-before-github
-func RecordPRVisualPrivacyReview(repo, key, evidenceFingerprint, reviewerIdentity string) (PRVisualPrivacyReview, error) {
- repo, err := ResolveRepository(repo)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- manifest, err := LoadPRVisualEvidence(repo, key)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- if strings.TrimSpace(evidenceFingerprint) == "" || evidenceFingerprint != manifest.Fingerprint {
- return PRVisualPrivacyReview{}, fmt.Errorf("evidence fingerprint does not match the exact visual manifest under review")
- }
- if len(manifest.Items) == 0 {
- return PRVisualPrivacyReview{}, fmt.Errorf("visual privacy review requires at least one PNG")
- }
- if current, loadErr := LoadPRVisualPrivacyReview(repo, key); loadErr == nil && current.EvidenceFingerprint == manifest.Fingerprint && current.ReviewerIdentity == strings.TrimSpace(reviewerIdentity) {
- return current, nil
- }
- review := PRVisualPrivacyReview{
- SchemaVersion: visualEvidenceSchemaVersion, Key: manifest.Key, EvidenceFingerprint: manifest.Fingerprint,
- PNGHashes: manifestPNGHashes(manifest), ReviewerIdentity: strings.TrimSpace(reviewerIdentity),
- ReviewedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
- }
- review.Fingerprint, err = visualPrivacyReviewFingerprint(review)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- if err := validateVisualPrivacyReview(review, manifest); err != nil {
- return PRVisualPrivacyReview{}, err
- }
- path, err := visualPrivacyReviewPath(repo, key)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- raw, err := MarshalJSON(review)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- if err := atomicWriteMode(path, raw, 0o600); err != nil {
- return PRVisualPrivacyReview{}, err
- }
- return review, nil
-}
-
-func LoadPRVisualPrivacyReview(repo, key string) (PRVisualPrivacyReview, error) {
- manifest, err := LoadPRVisualEvidence(repo, key)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- path, err := visualPrivacyReviewPath(repo, key)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- raw, err := os.ReadFile(path)
- if err != nil {
- return PRVisualPrivacyReview{}, err
- }
- var review PRVisualPrivacyReview
- if err := DecodeJSON("load PR visual privacy review", path, raw, &review); err != nil {
- return PRVisualPrivacyReview{}, err
- }
- if err := validateVisualPrivacyReview(review, manifest); err != nil {
- return PRVisualPrivacyReview{}, err
- }
- return review, nil
-}
-
-// ResolvePRVisualPrivacyStatus keeps legacy human-reviewed imports valid while
-// requiring automated clean captures to obtain a separate exact-pixel receipt.
-func ResolvePRVisualPrivacyStatus(repo string, manifest *PRVisualEvidenceManifest) (string, string, error) {
- if manifest == nil || manifest.Relevance != "relevant" || len(manifest.Items) == 0 {
- return "NOT_APPLICABLE", "", nil
- }
- allHumanReviewed := true
- for _, item := range manifest.Items {
- allHumanReviewed = allHumanReviewed && item.PrivacyStatus == "human-reviewed"
- }
- if allHumanReviewed {
- return "PASS", SHA256Bytes([]byte("legacy-human-reviewed\x00" + manifest.Fingerprint)), nil
- }
- review, err := LoadPRVisualPrivacyReview(repo, manifest.Key)
- if err == nil {
- return "PASS", review.Fingerprint, nil
- }
- path, pathErr := visualPrivacyReviewPath(repo, manifest.Key)
- if pathErr == nil && os.IsNotExist(err) {
- return "REVIEW_REQUIRED", "", nil
- }
- if pathErr == nil {
- if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
- return "REVIEW_REQUIRED", "", nil
- }
- }
- if strings.Contains(err.Error(), "visual privacy review is stale") || strings.Contains(err.Error(), "screenshot pixels changed") || strings.Contains(err.Error(), "does not bind every current PNG") {
- return "REVIEW_REQUIRED", "", nil
- }
- return "", "", err
-}
-
-func visualCapabilityPath(repo string) (string, error) {
- common, err := gitCommonDir(repo)
- if err != nil {
- return "", err
- }
- return filepath.Join(common, "boatstack", "visual-evidence", "capability.json"), nil
-}
-
-func visualManifestFingerprint(manifest PRVisualEvidenceManifest) (string, error) {
- copy := manifest
- copy.Fingerprint = ""
- // Publication is retry state, not evidence identity. A failed upload or
- // comment update must retry the same package fingerprint and comment.
- copy.Publication = PRVisualPublication{}
- raw, err := MarshalJSON(copy)
- if err != nil {
- return "", err
- }
- return SHA256Bytes(raw), nil
-}
-
-func validateVisualManifest(manifest PRVisualEvidenceManifest) error {
- if manifest.SchemaVersion != visualEvidenceSchemaVersion {
- return fmt.Errorf("visual evidence schema_version must be %d", visualEvidenceSchemaVersion)
- }
- if _, err := safeCacheSegment(manifest.Key, "visual evidence key"); err != nil {
- return err
- }
- policy := normalizedPRVisualEvidencePolicy(manifest.Policy)
- if policy != "off" && policy != "suggest" && policy != "require" {
- return fmt.Errorf("visual evidence policy must be off, suggest, or require")
- }
- if manifest.Relevance != "relevant" && manifest.Relevance != "not_relevant" && manifest.Relevance != "unresolved" {
- return fmt.Errorf("visual evidence relevance must be relevant, not_relevant, or unresolved")
- }
- if manifest.RelevanceSource != "managed-plan" && manifest.RelevanceSource != "human-provided" && manifest.RelevanceSource != "repository-evidenced" && manifest.RelevanceSource != "agent-proposed" {
- return fmt.Errorf("unsupported visual evidence relevance source")
- }
- if manifest.Relevance == "not_relevant" && strings.TrimSpace(manifest.Reason) == "" {
- return fmt.Errorf("not-relevant visual evidence requires a reason")
- }
- if len(manifest.Scenarios) > 3 || len(manifest.Items) > 3 {
- return fmt.Errorf("visual evidence supports at most three scenarios and screenshots")
- }
- allowedStatus := map[string]bool{"PASS": true, "PASS_WITH_GAPS": true, "NOT_VERIFIED": true, "NOT_APPLICABLE": true, "BLOCKED": true}
- if !allowedStatus[manifest.Status] {
- return fmt.Errorf("unsupported visual evidence status %q", manifest.Status)
- }
- seenScenarios := map[string]bool{}
- scenarioViewports := map[string]string{}
- for _, scenario := range manifest.Scenarios {
- if scenario.ID == "" || seenScenarios[scenario.ID] || scenario.Entry == "" || scenario.State == "" || scenario.Viewport == "" || len(scenario.Expected) == 0 {
- return fmt.Errorf("visual evidence scenarios require unique ids, entry, state, viewport, and expected outcomes")
- }
- seenScenarios[scenario.ID] = true
- scenarioViewports[scenario.ID] = scenario.Viewport
- }
- seenItems := map[string]bool{}
- for _, item := range manifest.Items {
- if !seenScenarios[item.ScenarioID] || seenItems[item.ScenarioID] || item.MIMEType != "image/png" || item.DurationMS != 0 || item.SHA256 == "" || item.Width < 1 || item.Height < 1 {
- return fmt.Errorf("visual evidence items must reference a scenario and describe a valid PNG")
- }
- seenItems[item.ScenarioID] = true
- if item.Status != "captured" && item.Status != "CAPTURED" {
- return fmt.Errorf("visual evidence items require CAPTURED status")
- }
- if item.Viewport != scenarioViewports[item.ScenarioID] {
- return fmt.Errorf("visual evidence items require captured status and the approved scenario viewport")
- }
- if item.PrivacyStatus != "clean" && item.PrivacyStatus != "human-reviewed" {
- return fmt.Errorf("visual evidence items require privacy_status clean or human-reviewed")
- }
- if _, err := time.Parse(time.RFC3339, item.CapturedAt); err != nil {
- return fmt.Errorf("visual evidence captured_at must be RFC3339: %w", err)
- }
- if item.VerificationStatus == "" {
- item.VerificationStatus = "CAPTURED"
- }
- if item.VerificationStatus != "CAPTURED" && item.VerificationStatus != "SCENARIO_VERIFIED" {
- return fmt.Errorf("visual evidence verification_status must be CAPTURED or SCENARIO_VERIFIED")
- }
- if item.VerificationStatus == "SCENARIO_VERIFIED" {
- if item.Receipt == nil || item.Receipt.ScenarioID != item.ScenarioID || strings.TrimSpace(item.Receipt.Reached) == "" || len(item.Receipt.Checks) == 0 || !strings.EqualFold(item.Receipt.OverallResult, "PASS") {
- return fmt.Errorf("SCENARIO_VERIFIED requires a valid passing receipt for the same scenario")
- }
- for _, check := range item.Receipt.Checks {
- if strings.TrimSpace(check.Name) == "" || !strings.EqualFold(check.Result, "PASS") {
- return fmt.Errorf("SCENARIO_VERIFIED requires named passing checks")
- }
- }
- } else if item.Receipt != nil {
- return fmt.Errorf("a scenario receipt must produce SCENARIO_VERIFIED")
- }
- }
- if manifest.Status == "PASS" && (manifest.Relevance != "relevant" || len(manifest.Items) != len(manifest.Scenarios)) {
- return fmt.Errorf("PASS visual evidence requires one screenshot for every relevant scenario")
- }
- return nil
-}
-
-// SavePRVisualEvidence copies exact PNG bytes into Git-common Boatstack state,
-// normalizes their metadata, and atomically records a fingerprinted manifest.
-func SavePRVisualEvidence(repo string, manifest PRVisualEvidenceManifest) (PRVisualEvidenceManifest, error) {
- repo, err := ResolveRepository(repo)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- manifest.SchemaVersion = visualEvidenceSchemaVersion
- manifest.Policy = normalizedPRVisualEvidencePolicy(manifest.Policy)
- directory, err := visualEvidenceDirectory(repo, manifest.Key)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- if err := rejectSymlinkComponents(filepath.Dir(filepath.Dir(directory)), directory); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- if previous, loadErr := LoadPRVisualEvidence(repo, manifest.Key); loadErr == nil && manifest.Publication.CommentURL == "" {
- manifest.Publication.PRURL = previous.Publication.PRURL
- manifest.Publication.CommentURL = previous.Publication.CommentURL
- if manifest.Publication.State == "" {
- manifest.Publication.State = "pending"
- }
- }
- for index := range manifest.Items {
- item := &manifest.Items[index]
- info, err := os.Lstat(item.Path)
- if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return PRVisualEvidenceManifest{}, fmt.Errorf("visual evidence PNG is missing or unsafe: %s", item.Path)
- }
- value, err := os.ReadFile(item.Path)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- configuration, err := png.DecodeConfig(bytes.NewReader(value))
- if err != nil {
- return PRVisualEvidenceManifest{}, fmt.Errorf("visual evidence must be a valid PNG: %w", err)
- }
- hash := SHA256Bytes(value)
- destination := filepath.Join(directory, "assets", hash+".png")
- if err := atomicWriteMode(destination, value, 0o600); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- item.Path = destination
- item.SHA256 = hash
- item.MIMEType = "image/png"
- item.Width = configuration.Width
- item.Height = configuration.Height
- item.DurationMS = 0
- }
- sort.Slice(manifest.Items, func(i, j int) bool { return manifest.Items[i].ScenarioID < manifest.Items[j].ScenarioID })
- manifest.Fingerprint = ""
- if err := validateVisualManifest(manifest); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- manifest.Fingerprint, err = visualManifestFingerprint(manifest)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- raw, err := MarshalJSON(manifest)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- path, err := visualEvidenceManifestPath(repo, manifest.Key)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- if err := atomicWriteMode(path, raw, 0o600); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- return manifest, nil
-}
-
-func ImportPRVisualEvidence(repo, manifestPath string) (PRVisualEvidenceManifest, error) {
- raw, err := os.ReadFile(manifestPath)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- var manifest PRVisualEvidenceManifest
- if err := DecodeJSON("import PR visual evidence", manifestPath, raw, &manifest); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- return SavePRVisualEvidence(repo, manifest)
-}
-
-func LoadPRVisualEvidence(repo, key string) (PRVisualEvidenceManifest, error) {
- path, err := visualEvidenceManifestPath(repo, key)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- raw, err := os.ReadFile(path)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- var manifest PRVisualEvidenceManifest
- if err := DecodeJSON("load PR visual evidence", path, raw, &manifest); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- if err := validateVisualManifest(manifest); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- expected, err := visualManifestFingerprint(manifest)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- if manifest.Fingerprint != expected {
- return PRVisualEvidenceManifest{}, fmt.Errorf("visual evidence manifest fingerprint is stale")
- }
- for _, item := range manifest.Items {
- if hash, err := SHA256File(item.Path); err != nil || hash != item.SHA256 {
- return PRVisualEvidenceManifest{}, fmt.Errorf("visual evidence screenshot is missing or stale: %s", item.ScenarioID)
- }
- }
- return manifest, nil
-}
-
-func recordPRVisualPublication(repo string, manifest PRVisualEvidenceManifest, publication PRVisualPublication) (PRVisualEvidenceManifest, error) {
- manifest.Publication = publication
- manifest.Fingerprint = ""
- if err := validateVisualManifest(manifest); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- var err error
- manifest.Fingerprint, err = visualManifestFingerprint(manifest)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- raw, err := MarshalJSON(manifest)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- path, err := visualEvidenceManifestPath(repo, manifest.Key)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- if err := atomicWriteMode(path, raw, 0o600); err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- return manifest, nil
-}
-
-func RecordPRVisualPublication(repo, key, prURL, commentURL string) (PRVisualEvidenceManifest, error) {
- if strings.TrimSpace(prURL) == "" || strings.TrimSpace(commentURL) == "" {
- return PRVisualEvidenceManifest{}, fmt.Errorf("PR and visual evidence comment URLs are required")
- }
- manifest, err := LoadPRVisualEvidence(repo, key)
- if err != nil {
- return PRVisualEvidenceManifest{}, err
- }
- return recordPRVisualPublication(repo, manifest, PRVisualPublication{
- State: "published", PRURL: strings.TrimSpace(prURL), CommentURL: strings.TrimSpace(commentURL),
- UpdatedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
- })
-}
-
-func SavePRVisualCapability(repo string, receipt PRVisualCapabilityReceipt) error {
- receipt.SchemaVersion = visualEvidenceSchemaVersion
- receipt.BoatstackVersion = Version
- if receipt.VerifiedAt == "" {
- receipt.VerifiedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339)
- }
- if receipt.HealthStatus != "ready" && receipt.HealthStatus != "unavailable" {
- return fmt.Errorf("visual capability health_status must be ready or unavailable")
- }
- path, err := visualCapabilityPath(repo)
- if err != nil {
- return err
- }
- raw, err := MarshalJSON(receipt)
- if err != nil {
- return err
- }
- return atomicWriteMode(path, raw, 0o600)
-}
-
-func LoadPRVisualCapability(repo string, expected PRVisualCapabilityReceipt) (PRVisualCapabilityReceipt, error) {
- path, err := visualCapabilityPath(repo)
- if err != nil {
- return PRVisualCapabilityReceipt{}, err
- }
- raw, err := os.ReadFile(path)
- if err != nil {
- return PRVisualCapabilityReceipt{}, err
- }
- var actual PRVisualCapabilityReceipt
- if err := DecodeJSON("load PR visual capability", path, raw, &actual); err != nil {
- return PRVisualCapabilityReceipt{}, err
- }
- if actual.SchemaVersion != visualEvidenceSchemaVersion || actual.BoatstackVersion != Version || actual.HealthStatus != "ready" ||
- actual.LockfileSHA256 != expected.LockfileSHA256 || actual.LaunchCommandHash != expected.LaunchCommandHash ||
- actual.BrowserVersion != expected.BrowserVersion || actual.FrameworkConfigSHA != expected.FrameworkConfigSHA {
- return PRVisualCapabilityReceipt{}, fmt.Errorf("visual evidence capability receipt is stale")
- }
- if _, err := time.Parse(time.RFC3339, actual.VerifiedAt); err != nil {
- return PRVisualCapabilityReceipt{}, fmt.Errorf("visual capability verified_at must be RFC3339: %w", err)
- }
- return actual, nil
-}
diff --git a/boatstack/visual_evidence_test.go b/boatstack/visual_evidence_test.go
deleted file mode 100644
index f7f1dab..0000000
--- a/boatstack/visual_evidence_test.go
+++ /dev/null
@@ -1,310 +0,0 @@
-package boatstack
-
-import (
- "context"
- "image"
- "image/color"
- "image/png"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func visualTestRepo(t *testing.T) string {
- t.Helper()
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "config", "user.name", "Boatstack Test")
- runGit(t, repo, "config", "user.email", "boatstack@example.invalid")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, repo, "add", "README.md")
- runGit(t, repo, "commit", "-m", "base")
- return repo
-}
-
-func TestPRVisualCapabilityCutCoversRepositoryAndHostBrowserConsumers(t *testing.T) {
- repo := visualTestRepo(t)
- config := testConfig()
- config.Project.Commands["e2e"] = "npm run e2e"
- capability, err := ResolvePRVisualCaptureCapability(repo, config, true, "npm run dev", PRVisualCapabilityReceipt{})
- if err != nil || capability.Kind != "repository-command" {
- t.Fatalf("repository capability did not win: %#v %v", capability, err)
- }
- delete(config.Project.Commands, "e2e")
- capability, err = ResolvePRVisualCaptureCapability(repo, config, true, "npm run dev", PRVisualCapabilityReceipt{})
- if err != nil || capability.Kind != "host-browser" {
- t.Fatalf("host browser consumer was not selected: %#v %v", capability, err)
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- response.WriteHeader(http.StatusNoContent)
- }))
- defer server.Close()
- if err := ProbePRVisualReadiness(context.Background(), server.URL, time.Second); err != nil {
- t.Fatalf("representative dev-server readiness failed: %v", err)
- }
-}
-
-func writeTestPNG(t *testing.T, path string) {
- t.Helper()
- file, err := os.Create(path)
- if err != nil {
- t.Fatal(err)
- }
- canvas := image.NewRGBA(image.Rect(0, 0, 4, 3))
- canvas.Set(1, 1, color.RGBA{R: 240, G: 160, B: 20, A: 255})
- if err := png.Encode(file, canvas); err != nil {
- file.Close()
- t.Fatal(err)
- }
- if err := file.Close(); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestPRVisualEvidenceIsMachineLocalFreshAndExact(t *testing.T) {
- repo := visualTestRepo(t)
- pngPath := filepath.Join(t.TempDir(), "warning.png")
- writeTestPNG(t, pngPath)
- manifest, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{
- Key: "feature-warning", Policy: "suggest", Relevance: "relevant", RelevanceSource: "human-provided",
- Status: "PASS", SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), ProductDiffSHA256: strings.Repeat("a", 64),
- Scenarios: []PRVisualScenario{{ID: "warning", Entry: "/onboarding", State: "picker open", Viewport: "1440x900", Expected: []string{"warning visible"}}},
- Items: []PRVisualEvidenceItem{{ScenarioID: "warning", Path: pngPath, Viewport: "1440x900", CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Status: "captured", PrivacyStatus: "human-reviewed"}},
- Publication: PRVisualPublication{State: "pending"},
- })
- if err != nil {
- t.Fatal(err)
- }
- if manifest.Items[0].Width != 4 || manifest.Items[0].Height != 3 || !strings.Contains(manifest.Items[0].Path, filepath.Join("boatstack", "visual-evidence")) {
- t.Fatalf("unexpected normalized visual evidence: %#v", manifest.Items[0])
- }
- if status := runGit(t, repo, "status", "--short"); status != "" {
- t.Fatalf("visual evidence changed the product tree: %s", status)
- }
- loaded, err := LoadPRVisualEvidence(repo, "feature-warning")
- if err != nil || loaded.Fingerprint != manifest.Fingerprint {
- t.Fatalf("fresh visual evidence did not reload: %#v %v", loaded, err)
- }
- if err := os.WriteFile(loaded.Items[0].Path, []byte("changed"), 0o600); err != nil {
- t.Fatal(err)
- }
- if _, err := LoadPRVisualEvidence(repo, "feature-warning"); err == nil || !strings.Contains(err.Error(), "stale") {
- t.Fatalf("changed screenshot was not rejected: %v", err)
- }
-}
-
-func TestPRVisualEvidenceRequiresPrivacyReview(t *testing.T) {
- repo := visualTestRepo(t)
- pngPath := filepath.Join(t.TempDir(), "warning.png")
- writeTestPNG(t, pngPath)
- _, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{
- Key: "feature-warning", Policy: "suggest", Relevance: "relevant", RelevanceSource: "human-provided",
- Status: "PASS", SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), ProductDiffSHA256: strings.Repeat("a", 64),
- Scenarios: []PRVisualScenario{{ID: "warning", Entry: "/onboarding", State: "picker open", Viewport: "1440x900", Expected: []string{"warning visible"}}},
- Items: []PRVisualEvidenceItem{{ScenarioID: "warning", Path: pngPath, Viewport: "1440x900", CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Status: "captured"}},
- Publication: PRVisualPublication{State: "pending"},
- })
- if err == nil || !strings.Contains(err.Error(), "privacy_status") {
- t.Fatalf("missing privacy review was not rejected: %v", err)
- }
-}
-
-func TestAutomatedVisualPrivacyReviewBindsExactPixelsAndReplaysIdempotently(t *testing.T) {
- repo := visualTestRepo(t)
- pngPath := filepath.Join(t.TempDir(), "warning.png")
- writeTestPNG(t, pngPath)
- manifest, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{
- Key: "automated-warning", Policy: "require", Relevance: "relevant", RelevanceSource: "repository-evidenced",
- Status: "PASS", SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), ProductDiffSHA256: strings.Repeat("a", 64),
- Scenarios: []PRVisualScenario{{ID: "warning", Entry: "/onboarding", State: "picker open", Viewport: "1440x900", Expected: []string{"warning visible"}}},
- Items: []PRVisualEvidenceItem{{ScenarioID: "warning", Path: pngPath, Viewport: "1440x900", CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Status: "captured", PrivacyStatus: "clean"}},
- Publication: PRVisualPublication{State: "pending"},
- })
- if err != nil {
- t.Fatal(err)
- }
- if status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &manifest); err != nil || status != "REVIEW_REQUIRED" || fingerprint != "" {
- t.Fatalf("automated capture bypassed human review: %s %q %v", status, fingerprint, err)
- }
- if _, err := RecordPRVisualPrivacyReview(repo, manifest.Key, strings.Repeat("0", 64), "reviewer@example.invalid"); err == nil {
- t.Fatal("mismatched evidence fingerprint was accepted")
- }
- review, err := RecordPRVisualPrivacyReview(repo, manifest.Key, manifest.Fingerprint, "reviewer@example.invalid")
- if err != nil {
- t.Fatal(err)
- }
- replayed, err := RecordPRVisualPrivacyReview(repo, manifest.Key, manifest.Fingerprint, "reviewer@example.invalid")
- if err != nil || replayed.Fingerprint != review.Fingerprint || replayed.ReviewedAt != review.ReviewedAt {
- t.Fatalf("exact review replay was not idempotent: %#v %#v %v", review, replayed, err)
- }
- if status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &manifest); err != nil || status != "PASS" || fingerprint != review.Fingerprint {
- t.Fatalf("current privacy review was not accepted: %s %q %v", status, fingerprint, err)
- }
- changedPath := filepath.Join(t.TempDir(), "changed.png")
- file, err := os.Create(changedPath)
- if err != nil {
- t.Fatal(err)
- }
- canvas := image.NewRGBA(image.Rect(0, 0, 4, 3))
- canvas.Set(1, 1, color.RGBA{R: 10, G: 20, B: 240, A: 255})
- if err := png.Encode(file, canvas); err != nil {
- file.Close()
- t.Fatal(err)
- }
- if err := file.Close(); err != nil {
- t.Fatal(err)
- }
- changed, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{
- Key: manifest.Key, Policy: manifest.Policy, Relevance: manifest.Relevance, RelevanceSource: manifest.RelevanceSource,
- Status: manifest.Status, SourceCommit: manifest.SourceCommit, ProductDiffSHA256: manifest.ProductDiffSHA256,
- Scenarios: manifest.Scenarios,
- Items: []PRVisualEvidenceItem{{ScenarioID: "warning", Path: changedPath, Viewport: "1440x900", CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Status: "captured", PrivacyStatus: "clean"}},
- Publication: PRVisualPublication{State: "pending"},
- })
- if err != nil {
- t.Fatal(err)
- }
- if status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &changed); err != nil || status != "REVIEW_REQUIRED" || fingerprint != "" {
- t.Fatalf("changed current pixels did not require a fresh review: %s %q %v", status, fingerprint, err)
- }
- if _, err := LoadPRVisualPrivacyReview(repo, manifest.Key); err == nil || !strings.Contains(err.Error(), "stale") {
- t.Fatalf("changed pixels did not invalidate privacy review: %v", err)
- }
-}
-
-func TestPRVisualCapabilityReceiptInvalidatesChangedInputs(t *testing.T) {
- repo := visualTestRepo(t)
- receipt := PRVisualCapabilityReceipt{
- LockfileSHA256: "lock", LaunchCommandHash: "launch", BrowserVersion: "browser-1",
- FrameworkConfigSHA: "config", HealthStatus: "ready",
- }
- if err := SavePRVisualCapability(repo, receipt); err != nil {
- t.Fatal(err)
- }
- if _, err := LoadPRVisualCapability(repo, receipt); err != nil {
- t.Fatal(err)
- }
- receipt.BrowserVersion = "browser-2"
- if _, err := LoadPRVisualCapability(repo, receipt); err == nil || !strings.Contains(err.Error(), "stale") {
- t.Fatalf("changed capability input was not rejected: %v", err)
- }
-}
-
-type fakeVisualPublisher struct {
- commentURL string
- err error
- existing string
-}
-
-func (publisher *fakeVisualPublisher) PublishVisualEvidence(repo, prURL, existingCommentURL string, manifest PRVisualEvidenceManifest) (string, error) {
- publisher.existing = existingCommentURL
- return publisher.commentURL, publisher.err
-}
-
-func savedVisualManifest(t *testing.T, repo, key string) PRVisualEvidenceManifest {
- t.Helper()
- pngPath := filepath.Join(t.TempDir(), "warning.png")
- writeTestPNG(t, pngPath)
- manifest, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{
- Key: key, Policy: "suggest", Relevance: "relevant", RelevanceSource: "repository-evidenced",
- Status: "PASS", SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), ProductDiffSHA256: strings.Repeat("b", 64),
- Scenarios: []PRVisualScenario{{ID: "warning", Entry: "/onboarding", State: "picker open", Viewport: "1440x900", Expected: []string{"warning visible"}}},
- Items: []PRVisualEvidenceItem{{ScenarioID: "warning", Path: pngPath, Viewport: "1440x900", CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Status: "captured", PrivacyStatus: "human-reviewed"}},
- Publication: PRVisualPublication{State: "pending"},
- })
- if err != nil {
- t.Fatal(err)
- }
- return manifest
-}
-
-func TestPRVisualPublisherReusesOneCommentAndRecordsPendingFailure(t *testing.T) {
- repo := visualTestRepo(t)
- manifest := savedVisualManifest(t, repo, "feature-warning")
- context := PRContext{PRVisualEvidencePolicy: "suggest", PRVisualEvidenceStatus: "PASS", PRVisualEvidence: &manifest}
- publisher := &fakeVisualPublisher{commentURL: "https://github.com/example/repo/pull/1#issuecomment-2"}
- if err := publishPRVisualEvidence(repo, "https://github.com/example/repo/pull/1", context, publisher); err != nil {
- t.Fatal(err)
- }
- published, err := LoadPRVisualEvidence(repo, manifest.Key)
- if err != nil || published.Publication.State != "published" {
- t.Fatalf("publication was not recorded: %#v %v", published.Publication, err)
- }
- updated := savedVisualManifest(t, repo, "feature-warning")
- context.PRVisualEvidence = &updated
- if err := publishPRVisualEvidence(repo, "https://github.com/example/repo/pull/1", context, publisher); err != nil {
- t.Fatal(err)
- }
- if publisher.existing != published.Publication.CommentURL {
- t.Fatalf("existing evidence comment was not reused: %q", publisher.existing)
- }
-
- pending := savedVisualManifest(t, repo, "feature-failure")
- context.PRVisualEvidence = &pending
- failing := &fakeVisualPublisher{err: os.ErrPermission}
- if err := publishPRVisualEvidence(repo, "https://github.com/example/repo/pull/2", context, failing); err == nil || !strings.Contains(err.Error(), "fix forward") {
- t.Fatalf("publication failure was not routed to fix-forward: %v", err)
- }
- failed, err := LoadPRVisualEvidence(repo, pending.Key)
- if err != nil || failed.Publication.State != "visual_pending" {
- t.Fatalf("visual-pending state was not retained: %#v %v", failed.Publication, err)
- }
-}
-
-// Invariant: the attach retry completes exactly the owed publication — the
-// confirmed fingerprinted package against its recorded PR — and an already
-// published attachment is a no-op that never re-consults the publisher.
-func TestRetryVisualAttachmentCompletesOwedPublication(t *testing.T) {
- repo := visualTestRepo(t)
- manifest := savedVisualManifest(t, repo, "feature-warning")
- context := PRContext{PRVisualEvidencePolicy: "suggest", PRVisualEvidenceStatus: "PASS", PRVisualEvidence: &manifest}
- prURL := "https://github.com/example/repo/pull/3"
- if err := publishPRVisualEvidence(repo, prURL, context, &fakeVisualPublisher{err: os.ErrPermission}); err == nil {
- t.Fatal("fixture publication was expected to fail into visual_pending")
- }
- retried, err := RetryVisualAttachment(repo, "feature-warning", &fakeVisualPublisher{commentURL: "https://github.com/example/repo/pull/3#issuecomment-9"})
- if err != nil {
- t.Fatal(err)
- }
- if retried.Publication.State != "published" || retried.Publication.PRURL != prURL || retried.Publication.CommentURL == "" {
- t.Fatalf("retry did not complete the owed publication: %#v", retried.Publication)
- }
- if retried.Fingerprint != manifest.Fingerprint {
- t.Fatalf("retry changed the evidence fingerprint: before=%s after=%s", manifest.Fingerprint, retried.Fingerprint)
- }
- again, err := RetryVisualAttachment(repo, "feature-warning", &fakeVisualPublisher{err: os.ErrPermission})
- if err != nil || again.Publication.State != "published" {
- t.Fatalf("published attachment must be an idempotent no-op: %#v %v", again.Publication, err)
- }
-}
-
-// Refusals: the retry never usurps first publication (publish-pr owns it) and
-// a missing publisher routes to the manual recording verb by name.
-func TestRetryVisualAttachmentRefusesWhatItDoesNotOwn(t *testing.T) {
- repo := visualTestRepo(t)
- if _, err := RetryVisualAttachment(repo, "missing-feature", &fakeVisualPublisher{commentURL: "x"}); err == nil || !strings.Contains(err.Error(), "no recorded visual evidence") {
- t.Fatalf("missing manifest was not refused: %v", err)
- }
- manifest := savedVisualManifest(t, repo, "feature-warning")
- if _, err := RetryVisualAttachment(repo, "feature-warning", &fakeVisualPublisher{commentURL: "x"}); err == nil || !strings.Contains(err.Error(), "publish-pr") {
- t.Fatalf("pre-publication manifest was not routed to publish-pr: %v", err)
- }
- context := PRContext{PRVisualEvidencePolicy: "suggest", PRVisualEvidenceStatus: "PASS", PRVisualEvidence: &manifest}
- if err := publishPRVisualEvidence(repo, "https://github.com/example/repo/pull/4", context, nil); err == nil || !strings.Contains(err.Error(), "external visual evidence is pending") {
- t.Fatalf("missing external publisher did not preserve a pending PR: %v", err)
- }
- if _, err := RetryVisualAttachment(repo, "feature-warning", nil); err == nil || !strings.Contains(err.Error(), "external visual publisher") {
- t.Fatalf("missing publisher must preserve the hosted retry: %v", err)
- }
- recovered, err := RetryVisualAttachment(repo, "feature-warning", &fakeVisualPublisher{commentURL: "https://github.com/example/repo/pull/4#issuecomment-1"})
- if err != nil || recovered.Publication.State != "published" {
- t.Fatalf("legacy owed state with a live publisher should still recover: %#v %v", recovered.Publication, err)
- }
-}
diff --git a/boatstack/visual_publisher.go b/boatstack/visual_publisher.go
deleted file mode 100644
index 9a72dfa..0000000
--- a/boatstack/visual_publisher.go
+++ /dev/null
@@ -1,345 +0,0 @@
-package boatstack
-
-import (
- "bytes"
- "fmt"
- "html"
- "io"
- "mime/multipart"
- "net/http"
- "net/url"
- "os"
- "path/filepath"
- "regexp"
- "strings"
-)
-
-// GitVisualEvidencePublisher publishes fingerprinted PNG bytes to a pull request
-// without a signed-in host browser or a manual drag-drop. GitHub exposes no public
-// API that mints user-attachments CDN URLs, so instead of uploading through the web
-// UI this publisher commits the exact bytes to a dedicated, Boatstack-owned evidence
-// branch on origin and references them from one Boatstack-owned PR comment via
-// immutable raw.githubusercontent.com URLs pinned to the commit SHA.
-//
-// The approach only renders for public repositories: raw.githubusercontent.com does
-// not serve private content to anonymous markdown renderers. SelectVisualPublisher
-// therefore declines to return this publisher for a non-public origin, leaving the
-// existing manual-attachment fallback in place rather than emitting broken images.
-type GitVisualEvidencePublisher struct{}
-
-var (
- prNumberPattern = regexp.MustCompile(`/pull/(\d+)`)
- commentIDPattern = regexp.MustCompile(`issuecomment-(\d+)`)
- originSlugSSH = regexp.MustCompile(`^git@github\.com:([^/]+)/(.+?)(?:\.git)?$`)
- originSlugHTTP = regexp.MustCompile(`^https?://github\.com/([^/]+)/(.+?)(?:\.git)?$`)
-)
-
-// SelectVisualPublisher always chooses external hosting. Screenshot bytes never
-// enter Git or a pull-request attachment path.
-func SelectVisualPublisher(repo string) PRVisualEvidencePublisher {
- resolved, err := ResolveRepository(repo)
- if err != nil {
- return nil
- }
- if err := ghAvailable(resolved); err != nil {
- return nil
- }
- if _, _, err := originRepoSlug(resolved); err != nil {
- return nil
- }
- if publish := visualPublishConfig(resolved); publish != nil {
- return ExternalHostVisualEvidencePublisher{Host: publish.Host, Expiry: publish.Expiry}
- }
- return ExternalHostVisualEvidencePublisher{Host: defaultExternalHost, Expiry: defaultExternalExpiry}
-}
-
-// visualPublishConfig reads the repository's visual-evidence publish preferences from
-// the generated project config, returning nil when the config is absent, unreadable,
-// or leaves the block unset so the caller uses the external-host defaults.
-func visualPublishConfig(repo string) *VisualEvidencePublish {
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- return nil
- }
- return config.Workflow.VisualEvidencePublish
-}
-
-// PublishVisualEvidence remains as a compatibility entry point but uses the
-// external-host path. No screenshot publisher writes image bytes to Git.
-func (GitVisualEvidencePublisher) PublishVisualEvidence(repo, prURL, existingCommentURL string, manifest PRVisualEvidenceManifest) (string, error) {
- return (ExternalHostVisualEvidencePublisher{Host: defaultExternalHost, Expiry: defaultExternalExpiry}).PublishVisualEvidence(repo, prURL, existingCommentURL, manifest)
-}
-
-// upsertEvidenceComment posts the composed body to exactly one Boatstack-owned
-// comment: it reuses the recorded comment when known, otherwise finds the prior
-// comment by its hidden marker so a lost URL never orphans a duplicate.
-func upsertEvidenceComment(repo, owner, name, prNumber, existingCommentURL, key, body string) (string, error) {
- commentID := commentIDFromURL(existingCommentURL)
- if commentID == "" {
- marker := visualEvidenceCommentMarker(key)
- found, err := commandOutput(repo, "gh", "api", "--paginate",
- fmt.Sprintf("repos/%s/%s/issues/%s/comments", owner, name, prNumber),
- "--jq", `.[] | select(.body | contains("`+marker+`")) | .id`)
- if err != nil {
- return "", err
- }
- if lines := strings.Fields(found); len(lines) > 0 {
- commentID = lines[0]
- }
- }
- bodyFile, err := os.CreateTemp("", "boatstack-evidence-comment-*.md")
- if err != nil {
- return "", err
- }
- bodyPath := bodyFile.Name()
- defer os.Remove(bodyPath)
- if _, err := bodyFile.WriteString(body); err != nil {
- bodyFile.Close()
- return "", err
- }
- if err := bodyFile.Close(); err != nil {
- return "", err
- }
- if commentID != "" {
- return commandOutput(repo, "gh", "api", "--method", "PATCH",
- fmt.Sprintf("repos/%s/%s/issues/comments/%s", owner, name, commentID),
- "-F", "body=@"+bodyPath, "--jq", ".html_url")
- }
- return commandOutput(repo, "gh", "api", "--method", "POST",
- fmt.Sprintf("repos/%s/%s/issues/%s/comments", owner, name, prNumber),
- "-F", "body=@"+bodyPath, "--jq", ".html_url")
-}
-
-func originRepoSlug(repo string) (string, string, error) {
- remote, err := commandOutput(repo, "git", "-C", repo, "remote", "get-url", "origin")
- if err != nil {
- return "", "", err
- }
- remote = strings.TrimSpace(remote)
- for _, pattern := range []*regexp.Regexp{originSlugSSH, originSlugHTTP} {
- if match := pattern.FindStringSubmatch(remote); match != nil {
- return match[1], strings.TrimSuffix(match[2], "/"), nil
- }
- }
- return "", "", fmt.Errorf("origin %q is not a recognizable GitHub repository", remote)
-}
-
-func prNumberFromURL(prURL string) (string, error) {
- if match := prNumberPattern.FindStringSubmatch(prURL); match != nil {
- return match[1], nil
- }
- return "", fmt.Errorf("cannot determine the pull-request number from %q", prURL)
-}
-
-func commentIDFromURL(commentURL string) string {
- if match := commentIDPattern.FindStringSubmatch(commentURL); match != nil {
- return match[1]
- }
- return ""
-}
-
-func visualEvidenceCommentMarker(key string) string {
- return ""
-}
-
-// externalHostSpec describes an anonymous image host used by external-host mode.
-type externalHostSpec struct {
- endpoint string
- label string
- withExpiry bool // the host auto-deletes uploads after a caller-chosen window
-}
-
-// visualExternalHosts enumerates the supported anonymous hosts. Both accept the same
-// multipart form (reqtype=fileupload, fileToUpload=) and return the hosted URL
-// as plain text — a URL GitHub's camo proxy can fetch unauthenticated so the comment
-// renders inline. It is a var, not a const table, so tests can point an endpoint at a
-// local httptest server.
-var visualExternalHosts = map[string]externalHostSpec{
- "litterbox": {endpoint: "https://litterbox.catbox.moe/resources/internals/api.php", label: "litter.catbox.moe", withExpiry: true},
- "catbox": {endpoint: "https://catbox.moe/user/api.php", label: "files.catbox.moe", withExpiry: false},
-}
-
-const (
- defaultExternalHost = "litterbox"
- defaultExternalExpiry = "72h"
-)
-
-// ExternalHostVisualEvidencePublisher renders visual evidence inline on ANY repo —
-// including a private one — by uploading the exact PNG bytes to an anonymous expiring
-// host whose returned URL GitHub's camo proxy fetches unauthenticated. It is opt-in
-// only (workflow.visual_evidence_publish.mode="external-host") because it publishes
-// screenshot bytes to a third party; the comment carries a standing reminder naming
-// the host and its expiry so reviewers know the images are external and temporary.
-type ExternalHostVisualEvidencePublisher struct {
- Host string
- Expiry string
-}
-
-// PublishVisualEvidence uploads the manifest's exact PNG bytes to the configured
-// anonymous host, then posts or updates the single Boatstack-owned comment on the PR
-// with inline images and the standing hosting reminder.
-func (p ExternalHostVisualEvidencePublisher) PublishVisualEvidence(repo, prURL, existingCommentURL string, manifest PRVisualEvidenceManifest) (string, error) {
- resolved, err := ResolveRepository(repo)
- if err != nil {
- return "", err
- }
- owner, name, err := originRepoSlug(resolved)
- if err != nil {
- return "", err
- }
- prNumber, err := prNumberFromURL(prURL)
- if err != nil {
- return "", err
- }
- if len(manifest.Items) == 0 {
- return "", fmt.Errorf("visual evidence has no screenshots to publish")
- }
- for _, item := range manifest.Items {
- if item.PrivacyStatus != "human-reviewed" {
- return "", fmt.Errorf("refusing external upload for scenario %s without HUMAN_REVIEWED privacy status", item.ScenarioID)
- }
- }
- host := strings.TrimSpace(p.Host)
- if host == "" {
- host = defaultExternalHost
- }
- spec, ok := visualExternalHosts[host]
- if !ok {
- return "", fmt.Errorf("unknown external evidence host %q", host)
- }
- expiry := strings.TrimSpace(p.Expiry)
- if expiry == "" {
- expiry = defaultExternalExpiry
- }
- urls := make(map[string]string, len(manifest.Items))
- for _, item := range manifest.Items {
- url, err := uploadToExternalHost(spec, expiry, item.Path)
- if err != nil {
- return "", err
- }
- if err := verifyHostedVisualURL(spec, url); err != nil {
- return "", fmt.Errorf("verify hosted URL for %s: %w", item.ScenarioID, err)
- }
- urls[item.ScenarioID] = url
- }
- body := composeExternalHostComment(spec, expiry, urls, manifest)
- return upsertEvidenceComment(resolved, owner, name, prNumber, existingCommentURL, manifest.Key, body)
-}
-
-func verifyHostedVisualURL(spec externalHostSpec, value string) error {
- parsed, err := url.Parse(value)
- if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Hostname() == "" {
- return fmt.Errorf("host returned an invalid absolute HTTP URL")
- }
- endpoint, endpointErr := url.Parse(spec.endpoint)
- localTestHost := endpointErr == nil && (endpoint.Hostname() == "127.0.0.1" || endpoint.Hostname() == "localhost" || endpoint.Hostname() == "::1")
- if !localTestHost && !strings.EqualFold(parsed.Hostname(), spec.label) {
- return fmt.Errorf("host returned URL for unexpected domain %q", parsed.Hostname())
- }
- request, err := http.NewRequest(http.MethodGet, value, nil)
- if err != nil {
- return err
- }
- response, err := http.DefaultClient.Do(request)
- if err != nil {
- return err
- }
- defer response.Body.Close()
- if response.StatusCode < 200 || response.StatusCode >= 400 {
- return fmt.Errorf("hosted URL returned HTTP %d", response.StatusCode)
- }
- contentType := strings.ToLower(response.Header.Get("Content-Type"))
- if contentType != "" && !strings.HasPrefix(contentType, "image/") {
- return fmt.Errorf("hosted URL returned non-image content type %q", contentType)
- }
- return nil
-}
-
-// uploadToExternalHost POSTs one PNG to an anonymous host and returns the hosted URL.
-// The host answers with the URL as plain text; any non-200 or non-URL body is a
-// failure so the caller can fix forward without emitting a broken image.
-func uploadToExternalHost(spec externalHostSpec, expiry, pngPath string) (string, error) {
- contents, err := os.ReadFile(pngPath)
- if err != nil {
- return "", err
- }
- var payload bytes.Buffer
- form := multipart.NewWriter(&payload)
- if err := form.WriteField("reqtype", "fileupload"); err != nil {
- return "", err
- }
- if spec.withExpiry {
- if err := form.WriteField("time", expiry); err != nil {
- return "", err
- }
- }
- part, err := form.CreateFormFile("fileToUpload", filepath.Base(pngPath))
- if err != nil {
- return "", err
- }
- if _, err := part.Write(contents); err != nil {
- return "", err
- }
- if err := form.Close(); err != nil {
- return "", err
- }
- request, err := http.NewRequest(http.MethodPost, spec.endpoint, &payload)
- if err != nil {
- return "", err
- }
- request.Header.Set("Content-Type", form.FormDataContentType())
- response, err := http.DefaultClient.Do(request)
- if err != nil {
- return "", err
- }
- defer response.Body.Close()
- raw, err := io.ReadAll(response.Body)
- if err != nil {
- return "", err
- }
- url := strings.TrimSpace(string(raw))
- if response.StatusCode != http.StatusOK || !strings.HasPrefix(url, "http") {
- return "", fmt.Errorf("upload to %s failed (HTTP %d): %s", spec.label, response.StatusCode, boundedObservation(url))
- }
- return url, nil
-}
-
-// composeExternalHostComment renders the single Boatstack-owned comment for
-// external-host mode: the idempotency marker, the trust fingerprints, one inline
-// image per scenario pinned to its hosted URL, and a standing reminder naming the
-// third-party host and (when the host expires uploads) the expiry window.
-func composeExternalHostComment(spec externalHostSpec, expiry string, urls map[string]string, manifest PRVisualEvidenceManifest) string {
- var builder strings.Builder
- builder.WriteString(visualEvidenceCommentMarker(manifest.Key) + "\n")
- builder.WriteString("### Visual evidence\n\n")
- builder.WriteString("Screenshots are human-review evidence, not mechanical proof.\n\n")
- builder.WriteString(fmt.Sprintf("Source commit `%s` · product diff `%s` · fingerprint `%s`\n\n", manifest.SourceCommit, manifest.ProductDiffSHA256, manifest.Fingerprint))
- rendered := 0
- for _, scenario := range manifest.Scenarios {
- url, ok := urls[scenario.ID]
- if !ok {
- continue
- }
- caption := visualCommentText(strings.Join(scenario.Expected, "; "))
- builder.WriteString(fmt.Sprintf("**%s** — %s (`%s`)\n\n", visualCommentText(scenario.ID), caption, visualCommentText(scenario.Viewport)))
- builder.WriteString(fmt.Sprintf("User context: %s \nUser goal: %s \nJourney step: %s \nReviewer context: %s \nEntry: `%s` · State: `%s` · Surface: `%s`\n\n", visualCommentText(scenario.UserContext), visualCommentText(scenario.UserGoal), visualCommentText(scenario.JourneyStep), visualCommentText(scenario.ReviewerContext), visualCommentText(scenario.Entry), visualCommentText(scenario.State), visualCommentText(scenario.Surface)))
- builder.WriteString(fmt.Sprintf("\n\n", visualCommentText(scenario.ID), url))
- rendered++
- }
- if rendered == 0 {
- builder.WriteString("_No captured scenarios to display._\n")
- }
- builder.WriteString("---\n\n")
- if spec.withExpiry {
- builder.WriteString(fmt.Sprintf("📌 These images are hosted on **%s** and auto-expire in **%s** — merge or re-run before then. They are uploaded to a third-party anonymous host, so do not use this mode for sensitive screenshots.\n", spec.label, expiry))
- } else {
- builder.WriteString(fmt.Sprintf("📌 These images are hosted on **%s** (permanent, public). They are uploaded to a third-party anonymous host, so do not use this mode for sensitive screenshots.\n", spec.label))
- }
- return builder.String()
-}
-
-func visualCommentText(value string) string {
- value = strings.Join(strings.Fields(value), " ")
- value = html.EscapeString(value)
- return strings.NewReplacer("\\", "\\\\", "!", "\\!", "[", "\\[", "]", "\\]", "`", "\\`").Replace(value)
-}
diff --git a/boatstack/visual_publisher_test.go b/boatstack/visual_publisher_test.go
deleted file mode 100644
index cc7906a..0000000
--- a/boatstack/visual_publisher_test.go
+++ /dev/null
@@ -1,268 +0,0 @@
-package boatstack
-
-import (
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-func TestOriginRepoSlugParsesSSHAndHTTPS(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- cases := map[string]struct{ owner, name string }{
- "git@github.com:example-org/sample-app.git": {"example-org", "sample-app"},
- "https://github.com/operatorstack/boatstack.git": {"operatorstack", "boatstack"},
- "https://github.com/operatorstack/boatstack": {"operatorstack", "boatstack"},
- }
- runGit(t, repo, "remote", "add", "origin", "https://github.com/placeholder/placeholder.git")
- for remote, want := range cases {
- runGit(t, repo, "remote", "set-url", "origin", remote)
- owner, name, err := originRepoSlug(repo)
- if err != nil {
- t.Fatalf("%s: %v", remote, err)
- }
- if owner != want.owner || name != want.name {
- t.Fatalf("%s: got %s/%s, want %s/%s", remote, owner, name, want.owner, want.name)
- }
- }
-}
-
-func TestOriginRepoSlugRejectsNonGitHub(t *testing.T) {
- repo := t.TempDir()
- runGit(t, repo, "init", "-b", "main")
- runGit(t, repo, "remote", "add", "origin", "https://gitlab.com/acme/widget.git")
- if _, _, err := originRepoSlug(repo); err == nil {
- t.Fatal("non-GitHub origin should be rejected")
- }
-}
-
-func TestPRNumberFromURL(t *testing.T) {
- got, err := prNumberFromURL("https://github.com/operatorstack/boatstack/pull/143")
- if err != nil || got != "143" {
- t.Fatalf("got %q, %v", got, err)
- }
- if _, err := prNumberFromURL("https://github.com/operatorstack/boatstack"); err == nil {
- t.Fatal("a URL without a pull number should error")
- }
-}
-
-func TestCommentIDFromURL(t *testing.T) {
- if got := commentIDFromURL("https://github.com/o/n/pull/143#issuecomment-987654"); got != "987654" {
- t.Fatalf("got %q", got)
- }
- if got := commentIDFromURL(""); got != "" {
- t.Fatalf("empty URL should yield no id, got %q", got)
- }
-}
-
-func TestUploadToExternalHostPostsFormAndReturnsURL(t *testing.T) {
- dir := t.TempDir()
- png := filepath.Join(dir, "VS-1.png")
- writeTestPNG(t, png)
-
- var gotReqtype, gotTime, gotFilename string
- var gotBytes int
- var server *httptest.Server
- server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotReqtype = r.FormValue("reqtype")
- gotTime = r.FormValue("time")
- file, header, err := r.FormFile("fileToUpload")
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- defer file.Close()
- gotFilename = header.Filename
- contents := make([]byte, 4096)
- n, _ := file.Read(contents)
- gotBytes = n
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("https://litter.catbox.moe/abc123.png\n"))
- }))
- defer server.Close()
-
- spec := externalHostSpec{endpoint: server.URL, label: "litter.catbox.moe", withExpiry: true}
- url, err := uploadToExternalHost(spec, "24h", png)
- if err != nil {
- t.Fatalf("upload: %v", err)
- }
- if url != "https://litter.catbox.moe/abc123.png" {
- t.Fatalf("unexpected URL (trailing newline not trimmed?): %q", url)
- }
- if gotReqtype != "fileupload" {
- t.Fatalf("reqtype = %q, want fileupload", gotReqtype)
- }
- if gotTime != "24h" {
- t.Fatalf("expiry field = %q, want 24h", gotTime)
- }
- if gotFilename != "VS-1.png" {
- t.Fatalf("filename = %q, want VS-1.png", gotFilename)
- }
- if gotBytes == 0 {
- t.Fatal("no PNG bytes reached the host")
- }
-}
-
-func TestUploadToExternalHostOmitsTimeForPermanentHost(t *testing.T) {
- dir := t.TempDir()
- png := filepath.Join(dir, "VS-1.png")
- writeTestPNG(t, png)
-
- sawTimeField := false
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.FormValue("time") != "" {
- sawTimeField = true
- }
- _, _ = w.Write([]byte("https://files.catbox.moe/xyz.png"))
- }))
- defer server.Close()
-
- spec := externalHostSpec{endpoint: server.URL, label: "files.catbox.moe", withExpiry: false}
- if _, err := uploadToExternalHost(spec, "72h", png); err != nil {
- t.Fatalf("upload: %v", err)
- }
- if sawTimeField {
- t.Fatal("a permanent host must not receive an expiry time field")
- }
-}
-
-func TestUploadToExternalHostRejectsNonURLResponse(t *testing.T) {
- dir := t.TempDir()
- png := filepath.Join(dir, "VS-1.png")
- writeTestPNG(t, png)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte("something broke"))
- }))
- defer server.Close()
-
- spec := externalHostSpec{endpoint: server.URL, label: "litter.catbox.moe", withExpiry: true}
- if _, err := uploadToExternalHost(spec, "24h", png); err == nil {
- t.Fatal("a non-200 / non-URL response must be a failure, not a broken image")
- }
-}
-
-func TestComposeExternalHostCommentRendersInlineWithExpiryReminder(t *testing.T) {
- manifest := PRVisualEvidenceManifest{
- Key: "firm-status",
- SourceCommit: "src123",
- ProductDiffSHA256: "diff456",
- Fingerprint: "fp789",
- Scenarios: []PRVisualScenario{
- {ID: "VS-1", Viewport: "1440x900", Expected: []string{"portal card is blue"}},
- {ID: "VS-2", Viewport: "1440x900", Expected: []string{"amber badge"}},
- },
- Items: []PRVisualEvidenceItem{{ScenarioID: "VS-1"}, {ScenarioID: "VS-2"}},
- }
- urls := map[string]string{
- "VS-1": "https://litter.catbox.moe/a.png",
- "VS-2": "https://litter.catbox.moe/b.png",
- }
- spec := externalHostSpec{label: "litter.catbox.moe", withExpiry: true}
- body := composeExternalHostComment(spec, "24h", urls, manifest)
-
- if !strings.HasPrefix(body, visualEvidenceCommentMarker("firm-status")) {
- t.Fatal("comment must open with the idempotency marker")
- }
- for _, want := range []string{
- "src123", "diff456", "fp789", // trust fingerprints
- "", // inline, not a click-through link
- "",
- "litter.catbox.moe", // host named
- "24h", // expiry window named
- "third-party", // standing privacy reminder
- "human-review evidence", // not-mechanical-proof caveat
- } {
- if !strings.Contains(body, want) {
- t.Fatalf("comment body missing %q\n---\n%s", want, body)
- }
- }
-}
-
-func TestComposeExternalHostCommentPermanentHostOmitsExpiry(t *testing.T) {
- manifest := PRVisualEvidenceManifest{
- Key: "firm-status",
- Scenarios: []PRVisualScenario{{ID: "VS-1", Viewport: "1440x900", Expected: []string{"x"}}},
- Items: []PRVisualEvidenceItem{{ScenarioID: "VS-1"}},
- }
- urls := map[string]string{"VS-1": "https://files.catbox.moe/a.png"}
- spec := externalHostSpec{label: "files.catbox.moe", withExpiry: false}
- body := composeExternalHostComment(spec, "72h", urls, manifest)
- if strings.Contains(body, "auto-expire") {
- t.Fatal("a permanent host must not claim an expiry window")
- }
- if !strings.Contains(body, "permanent") || !strings.Contains(body, "third-party") {
- t.Fatalf("permanent host still needs its standing reminder\n---\n%s", body)
- }
-}
-
-func TestExternalHostPublishVisualEvidenceUploadsAndUpserts(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("fake gh relies on a POSIX shell script on PATH")
- }
- repo := visualTestRepo(t)
- runGit(t, repo, "remote", "add", "origin", "https://github.com/o/n.git")
-
- png := filepath.Join(repo, "VS-1.png")
- writeTestPNG(t, png)
-
- var server *httptest.Server
- server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet {
- w.Header().Set("Content-Type", "image/png")
- _, _ = w.Write([]byte("png"))
- return
- }
- if _, _, err := r.FormFile("fileToUpload"); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- _, _ = w.Write([]byte(server.URL + "/hosted.png"))
- }))
- defer server.Close()
-
- original := visualExternalHosts["litterbox"]
- visualExternalHosts["litterbox"] = externalHostSpec{endpoint: server.URL, label: "litter.catbox.moe", withExpiry: true}
- defer func() { visualExternalHosts["litterbox"] = original }()
-
- fakeDir := t.TempDir()
- script := filepath.Join(fakeDir, "gh")
- scriptBody := `#!/bin/sh
-if [ "$1" = "api" ]; then
- for a in "$@"; do
- if [ "$a" = "POST" ]; then echo "https://github.com/o/n/pull/9#issuecomment-555"; exit 0; fi
- if [ "$a" = "PATCH" ]; then echo "https://github.com/o/n/pull/9#issuecomment-555"; exit 0; fi
- done
- exit 0
-fi
-exit 1
-`
- if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
-
- manifest := PRVisualEvidenceManifest{
- Key: "firm-status",
- SourceCommit: "src",
- ProductDiffSHA256: "diff",
- Fingerprint: "fp",
- Scenarios: []PRVisualScenario{{ID: "VS-1", Viewport: "1440x900", Expected: []string{"blue"}}},
- Items: []PRVisualEvidenceItem{{ScenarioID: "VS-1", Path: png, PrivacyStatus: "human-reviewed"}},
- }
- // Keep the returned hosted URL on the local verifier.
- visualExternalHosts["litterbox"] = externalHostSpec{endpoint: server.URL, label: "litter.catbox.moe", withExpiry: true}
- publisher := ExternalHostVisualEvidencePublisher{Host: "litterbox", Expiry: "24h"}
- commentURL, err := publisher.PublishVisualEvidence(repo, "https://github.com/o/n/pull/9", "", manifest)
- if err != nil {
- t.Fatalf("publish: %v", err)
- }
- if commentURL != "https://github.com/o/n/pull/9#issuecomment-555" {
- t.Fatalf("unexpected comment URL: %s", commentURL)
- }
-}
diff --git a/boatstack/workspace.go b/boatstack/workspace.go
deleted file mode 100644
index c1723b2..0000000
--- a/boatstack/workspace.go
+++ /dev/null
@@ -1,1256 +0,0 @@
-package boatstack
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
-)
-
-const workspaceSchemaVersion = 2
-
-// workspaceGit and workspaceGh are indirected so tests can substitute
-// deterministic git and GitHub CLI behavior. They default to the same helpers
-// the rest of the package uses.
-var (
- workspaceGit = gitCommand
- workspaceGh = func(repo string, arguments ...string) (string, error) {
- return commandOutput(repo, "gh", arguments...)
- }
- workspacePackageCopy = copyFeaturePackage
- workspaceSourcePackageRemove = os.RemoveAll
- workspaceDetachedAlias = registerDetachedWorkspaceAlias
- workspaceAfterDestination = func(string) error { return nil }
- workspaceAfterDetachedAlias = func(string) error { return nil }
-)
-
-// ResolvedWorkspace is the workspace policy with empty fields filled from the
-// documented defaults. Enabled is never defaulted: a config without a workspace
-// block, or with enabled=false, keeps Boatstack's prior hands-off behavior.
-type ResolvedWorkspace struct {
- Enabled bool
- Mode string
- Cleanup string
- CleanupAfter string
- Reap string
-}
-
-func resolveWorkspace(workspace Workspace) ResolvedWorkspace {
- resolved := ResolvedWorkspace{
- Enabled: workspace.Enabled,
- Mode: workspace.Mode,
- Cleanup: workspace.Cleanup,
- CleanupAfter: workspace.CleanupAfter,
- Reap: workspace.Reap,
- }
- if resolved.Mode == "" {
- resolved.Mode = "worktree"
- }
- if resolved.Cleanup == "" {
- resolved.Cleanup = "confirm"
- }
- if resolved.CleanupAfter == "" {
- resolved.CleanupAfter = "merge"
- }
- if resolved.Reap == "" {
- resolved.Reap = "confirm"
- }
- return resolved
-}
-
-// workspaceEnabled reports whether workspace management is on, swallowing config
-// errors as "off" so read-only callers never fail on a malformed project file.
-func workspaceEnabled(repo string) bool {
- policy, err := loadWorkspacePolicy(repo)
- if err != nil {
- return false
- }
- return policy.Enabled
-}
-
-// reapEnabled reports whether the post-merge reap sweep is active — workspace
-// management is on and workspace.reap is not "off". It swallows config errors as
-// "off" so the read-only next surface never fails on a malformed project file.
-func reapEnabled(repo string) bool {
- policy, err := loadWorkspacePolicy(repo)
- if err != nil {
- return false
- }
- return policy.Enabled && policy.Reap != "off"
-}
-
-// needsFreshCut reports whether the caller still has to enter the feature's
-// branch workspace. Existence is not readiness: a detached HEAD, the base branch,
-// another feature branch, or a sibling worktree all require the managed
-// transition so the planning package and execution directory move together.
-func needsFreshCut(repo, feature string) bool {
- branch := branchForFeature(feature)
- if branch == "" {
- return false
- }
- current, _ := workspaceGit(repo, "branch", "--show-current")
- return strings.TrimSpace(current) != branch
-}
-
-// isMainWorktree reports whether repo is checked out in the repository's main
-// worktree, whose Git directory aliases the common directory. A linked worktree's
-// Git directory is .git/worktrees/, so the two differ there.
-func isMainWorktree(repo string) bool {
- gitDir, err := worktreeGitDir(repo)
- if err != nil {
- return false
- }
- common, err := gitCommonDir(repo)
- if err != nil {
- return false
- }
- return gitDir == common
-}
-
-// guardManagedActivationWorktree refuses to activate a managed delivery from the
-// main worktree once the feature already has a cut workspace. In worktree mode the
-// delivery must be built inside its cut worktree; activating on the base branch in
-// the main worktree strands compiled artifacts and a competing per-worktree
-// delivery ledger on the base branch (the split-brain this guards against). It is
-// inert unless workspace management is on in worktree mode, a workspace for the
-// feature already exists, and the caller is on the base branch in the main
-// worktree — so the normal flow (cut first, then activate inside the worktree) is
-// unaffected.
-func guardManagedActivationWorktree(repo string, config ProjectConfig, feature string) error {
- policy := resolveWorkspace(config.Workspace)
- if !policy.Enabled || policy.Mode != "worktree" {
- return nil
- }
- branch := branchForFeature(feature)
- if branch == "" {
- return nil
- }
- worktreePath := worktreePathForBranch(repo, branch)
- if worktreePath == "" && !branchExists(repo, branch) {
- return nil // no workspace cut yet — this is the normal pre-cut path
- }
- if !isMainWorktree(repo) {
- return nil // already inside a linked worktree
- }
- base := defaultPRBase(repo)
- if current, _ := workspaceGit(repo, "branch", "--show-current"); strings.TrimSpace(current) != base {
- return nil // not on the base branch
- }
- if worktreePath != "" {
- return fmt.Errorf("feature %q already has a managed workspace at %s; activate and build there, not on the base branch %q. Run: cd %s and re-run — managed delivery must run in its cut worktree", feature, worktreePath, base, worktreePath)
- }
- return fmt.Errorf("feature %q already has a managed branch %q; activate and build in its worktree, not on the base branch %q — managed delivery must run in its cut worktree", feature, branch, base)
-}
-
-func loadWorkspacePolicy(repo string) (ResolvedWorkspace, error) {
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- return ResolvedWorkspace{}, err
- }
- return resolveWorkspace(config.Workspace), nil
-}
-
-// branchForFeature derives the branch name for a feature slug when the caller
-// does not supply an explicit branch.
-func branchForFeature(feature string) string {
- slug := previewSlug(feature)
- if slug == "" {
- return ""
- }
- return "feat/" + slug
-}
-
-// WorkspaceCutOptions requests a fresh per-feature workspace cut from the
-// up-to-date default branch.
-type WorkspaceCutOptions struct {
- Repo string
- Feature string
- Branch string
-}
-
-// WorkspaceCut is the deterministic result of a fresh-cut request.
-type WorkspaceCut struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"`
- Mode string `json:"mode,omitempty"`
- ControllerMode string `json:"controller_mode,omitempty"`
- Outcome string `json:"outcome,omitempty"` // created | adopted | current
- BaseBranch string `json:"base_branch,omitempty"`
- BaseCommit string `json:"base_commit,omitempty"`
- Branch string `json:"branch,omitempty"`
- WorktreePath string `json:"worktree_path,omitempty"`
- SourceRepository string `json:"source_repository,omitempty"`
- DestinationRepo string `json:"destination_repository,omitempty"`
- PlanFingerprint string `json:"plan_fingerprint,omitempty"`
- Created bool `json:"created"`
- Reason string `json:"reason"`
-}
-
-func blockedCut(reason string) WorkspaceCut {
- return WorkspaceCut{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason}
-}
-
-type workspaceTransition struct {
- branchCreated bool
- branchSwitched bool
- worktreeCreated bool
- detachedAlias bool
- originalBranch string
- originalHead string
-}
-
-func rollbackWorkspaceTransition(repo, branch, worktreePath string, transition workspaceTransition) {
- if transition.detachedAlias {
- _ = unregisterDetachedWorkspaceAlias(worktreePath)
- }
- if transition.worktreeCreated && worktreePath != "" {
- _, _ = workspaceGit(repo, "worktree", "remove", "--force", worktreePath)
- }
- if transition.branchSwitched {
- if transition.originalBranch != "" {
- _, _ = workspaceGit(repo, "switch", transition.originalBranch)
- } else if transition.originalHead != "" {
- _, _ = workspaceGit(repo, "checkout", "--detach", transition.originalHead)
- }
- }
- if transition.branchCreated {
- _, _ = workspaceGit(repo, "branch", "-D", branch)
- }
-}
-
-func featurePackageFingerprint(repo, directory string) (string, error) {
- planPath := filepath.Join(directory, "plan.md")
- if !fileExists(planPath) {
- return "", nil
- }
- check, err := CheckPlanForRepository(repo, planPath)
- if err != nil {
- return "", err
- }
- return check.Fingerprint, nil
-}
-
-func featurePackageDigest(directory string) (string, error) {
- digest := sha256.New()
- err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- relative, err := filepath.Rel(directory, path)
- if err != nil || relative == "." {
- return err
- }
- info, err := entry.Info()
- if err != nil {
- return err
- }
- if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
- return fmt.Errorf("planning package contains unsupported entry %s", relative)
- }
- kind := "file"
- if info.IsDir() {
- kind = "directory"
- }
- _, _ = digest.Write([]byte(kind + "\x00" + filepath.ToSlash(relative) + "\x00"))
- if info.IsDir() {
- return nil
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- _, _ = digest.Write(value)
- _, _ = digest.Write([]byte{0})
- return nil
- })
- if err != nil {
- return "", err
- }
- return hex.EncodeToString(digest.Sum(nil)), nil
-}
-
-func copyFeaturePackage(source, destination string) error {
- parent := filepath.Dir(destination)
- if err := os.MkdirAll(parent, 0o755); err != nil {
- return err
- }
- temporary, err := os.MkdirTemp(parent, ".boatstack-workspace-transfer-")
- if err != nil {
- return err
- }
- defer os.RemoveAll(temporary)
- err = filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- relative, err := filepath.Rel(source, path)
- if err != nil || relative == "." {
- return err
- }
- target := filepath.Join(temporary, relative)
- info, err := entry.Info()
- if err != nil {
- return err
- }
- if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
- return fmt.Errorf("planning package contains unsupported entry %s", relative)
- }
- if info.IsDir() {
- return os.MkdirAll(target, info.Mode().Perm())
- }
- value, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
- return err
- }
- return os.WriteFile(target, value, info.Mode().Perm())
- })
- if err != nil {
- return err
- }
- return os.Rename(temporary, destination)
-}
-
-func dirtyOutsideFeature(repo, feature string) (bool, error) {
- output, err := workspaceGit(repo, "status", "--porcelain=v1", "--untracked-files=all")
- if err != nil {
- return false, err
- }
- prefix := filepath.ToSlash(filepath.Join(productLoopDirName, "features", feature)) + "/"
- for _, line := range strings.Split(output, "\n") {
- if strings.TrimSpace(line) == "" {
- continue
- }
- path := strings.TrimSpace(line[2:])
- if arrow := strings.LastIndex(path, " -> "); arrow >= 0 {
- path = path[arrow+4:]
- }
- path = strings.Trim(path, "\"")
- normalized := filepath.ToSlash(path)
- if !strings.HasPrefix(normalized, prefix) {
- return true, nil
- }
- }
- return false, nil
-}
-
-// transferFeaturePackage moves a validated embedded planning package into the
-// destination worktree. The source remains authoritative until the destination
-// fingerprint verifies. A failed source cleanup removes the new copy, so the
-// boundary never leaves two authoritative packages.
-func transferFeaturePackage(sourceRepo, destinationRepo, feature string, controllerMode SupervisionMode) (string, error) {
- if feature == "" {
- return "", nil
- }
- source := WorkspaceFor(sourceRepo).FeatureDir(feature)
- if source == "" || !dirExists(source) {
- destination := WorkspaceFor(destinationRepo).FeatureDir(feature)
- if destination == "" || !dirExists(destination) {
- return "", nil
- }
- return featurePackageFingerprint(destinationRepo, destination)
- }
- sourceFingerprint, err := featurePackageFingerprint(sourceRepo, source)
- if err != nil {
- return "", fmt.Errorf("source planning package is invalid: %w", err)
- }
- if sourceFingerprint == "" {
- return "", nil
- }
- sourceDigest, err := featurePackageDigest(source)
- if err != nil {
- return "", fmt.Errorf("source planning package cannot be fingerprinted: %w", err)
- }
- destination := WorkspaceFor(destinationRepo).FeatureDir(feature)
- if filepath.Clean(source) == filepath.Clean(destination) {
- return sourceFingerprint, nil
- }
- if dirExists(destination) {
- destinationFingerprint, fingerprintErr := featurePackageFingerprint(destinationRepo, destination)
- destinationDigest, digestErr := featurePackageDigest(destination)
- if fingerprintErr != nil || digestErr != nil || destinationFingerprint != sourceFingerprint || destinationDigest != sourceDigest {
- return "", fmt.Errorf("destination workspace contains a conflicting planning package")
- }
- if controllerMode == SupervisionEmbedded {
- if err := workspaceSourcePackageRemove(source); err != nil {
- return "", fmt.Errorf("remove transferred source package: %w", err)
- }
- }
- return sourceFingerprint, nil
- }
- if err := workspacePackageCopy(source, destination); err != nil {
- return "", fmt.Errorf("copy planning package: %w", err)
- }
- destinationFingerprint, err := featurePackageFingerprint(destinationRepo, destination)
- destinationDigest, digestErr := featurePackageDigest(destination)
- if err != nil || digestErr != nil || destinationFingerprint != sourceFingerprint || destinationDigest != sourceDigest {
- _ = os.RemoveAll(destination)
- return "", fmt.Errorf("destination planning package fingerprint did not verify")
- }
- if controllerMode == SupervisionEmbedded {
- if err := workspaceSourcePackageRemove(source); err != nil {
- _ = os.RemoveAll(destination)
- return "", fmt.Errorf("remove transferred source package: %w", err)
- }
- }
- return sourceFingerprint, nil
-}
-
-// CutFeatureWorkspace establishes the feature's execution workspace and carries
-// its validated planning package across the boundary. Existing branches are
-// adopted only when unowned and byte-identical to the fetched base.
-func CutFeatureWorkspace(options WorkspaceCutOptions) (WorkspaceCut, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return blockedCut(err.Error()), nil
- }
- if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) {
- return blockedCut("This repository has no Boatstack project installation."), nil
- }
- policy, err := loadWorkspacePolicy(repo)
- if err != nil {
- return blockedCut("Boatstack could not read the workspace policy: " + err.Error()), nil
- }
- if !policy.Enabled {
- return blockedCut("Workspace management is disabled (workspace.enabled=false)."), nil
- }
-
- branch := strings.TrimSpace(options.Branch)
- if branch == "" {
- branch = branchForFeature(options.Feature)
- }
- if branch == "" {
- return blockedCut("A feature slug or explicit branch is required to cut a workspace."), nil
- }
-
- base := defaultPRBase(repo)
- if branch == base {
- return blockedCut(fmt.Sprintf("Refusing to cut a workspace named after the base branch %q.", base)), nil
- }
-
- // Freshen the base from origin when a remote is available; a local-only
- // repository is still cuttable from its local base.
- if _, originErr := workspaceGit(repo, "remote", "get-url", "origin"); originErr == nil {
- if _, fetchErr := workspaceGit(repo, "fetch", "origin"); fetchErr != nil {
- return blockedCut("Boatstack could not fetch origin before cutting: " + fetchErr.Error()), nil
- }
- }
- baseCommit, err := resolveBaseCommit(repo, base)
- if err != nil {
- return blockedCut(err.Error()), nil
- }
-
- sourceContext, err := ResolveWorkspaceContext(repo)
- if err != nil {
- return blockedCut(err.Error()), nil
- }
- result := WorkspaceCut{
- SchemaVersion: workspaceSchemaVersion,
- VerificationStatus: "VERIFIED",
- Mode: policy.Mode,
- ControllerMode: string(sourceContext.Mode),
- BaseBranch: base,
- BaseCommit: strings.TrimSpace(baseCommit),
- Branch: branch,
- SourceRepository: repo,
- }
- originalBranch, _ := workspaceGit(repo, "branch", "--show-current")
- originalHead, _ := workspaceGit(repo, "rev-parse", "HEAD^{commit}")
- transition := workspaceTransition{
- originalBranch: strings.TrimSpace(originalBranch),
- originalHead: strings.TrimSpace(originalHead),
- }
- destinationRepo := repo
-
- switch policy.Mode {
- case "branch":
- current, _ := workspaceGit(repo, "branch", "--show-current")
- if strings.TrimSpace(current) == branch {
- headCommit, _ := workspaceGit(repo, "rev-parse", "HEAD^{commit}")
- if strings.TrimSpace(headCommit) != strings.TrimSpace(baseCommit) {
- return blockedCut(fmt.Sprintf("Current branch %q diverges from the fetched base.", branch)), nil
- }
- result.Outcome = "current"
- } else if branchExists(repo, branch) {
- branchCommit, _ := workspaceGit(repo, "rev-parse", "refs/heads/"+branch+"^{commit}")
- if strings.TrimSpace(branchCommit) != strings.TrimSpace(baseCommit) {
- return blockedCut(fmt.Sprintf("Branch %q diverges from the fetched base and cannot be adopted.", branch)), nil
- }
- owner, ownerErr := activeDeliveryOwningBranch(repo, branch)
- if ownerErr != nil {
- return blockedCut(fmt.Sprintf("Boatstack could not verify whether branch %q has an active owner: %v", branch, ownerErr)), nil
- }
- if owner != "" {
- return blockedCut(fmt.Sprintf("Branch %q is owned by an active delivery and cannot be adopted.", branch)), nil
- }
- if _, err := workspaceGit(repo, "switch", branch); err != nil {
- return blockedCut("Boatstack could not adopt the branch: " + err.Error()), nil
- }
- transition.branchSwitched = true
- result.Outcome = "adopted"
- } else {
- if _, err := workspaceGit(repo, "switch", "-c", branch, baseCommit); err != nil {
- return blockedCut("Boatstack could not create the branch: " + err.Error()), nil
- }
- transition.branchCreated = true
- transition.branchSwitched = true
- result.Created = true
- result.Outcome = "created"
- }
- default: // "worktree"
- path := worktreePathForBranch(repo, branch)
- if path != "" {
- branchCommit, _ := workspaceGit(repo, "rev-parse", "refs/heads/"+branch+"^{commit}")
- if strings.TrimSpace(branchCommit) != strings.TrimSpace(baseCommit) {
- return blockedCut(fmt.Sprintf("Branch %q diverges from the fetched base and cannot be reused.", branch)), nil
- }
- owner, ownerErr := activeDeliveryOwningBranch(repo, branch)
- if ownerErr != nil {
- return blockedCut(fmt.Sprintf("Boatstack could not verify whether branch %q has an active owner: %v", branch, ownerErr)), nil
- }
- if owner != "" {
- return blockedCut(fmt.Sprintf("Branch %q is owned by an active delivery and cannot be reused.", branch)), nil
- }
- destinationRepo = path
- result.Outcome = "current"
- } else {
- path = filepath.Join(repo, ".product-loop", "worktrees", previewSlug(branch))
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return blockedCut("Boatstack could not prepare the worktree directory: " + err.Error()), nil
- }
- if branchExists(repo, branch) {
- branchCommit, _ := workspaceGit(repo, "rev-parse", "refs/heads/"+branch+"^{commit}")
- if strings.TrimSpace(branchCommit) != strings.TrimSpace(baseCommit) {
- return blockedCut(fmt.Sprintf("Branch %q diverges from the fetched base and cannot be adopted.", branch)), nil
- }
- owner, ownerErr := activeDeliveryOwningBranch(repo, branch)
- if ownerErr != nil {
- return blockedCut(fmt.Sprintf("Boatstack could not verify whether branch %q has an active owner: %v", branch, ownerErr)), nil
- }
- if owner != "" {
- return blockedCut(fmt.Sprintf("Branch %q is owned by an active delivery and cannot be adopted.", branch)), nil
- }
- if _, err := workspaceGit(repo, "worktree", "add", path, branch); err != nil {
- return blockedCut("Boatstack could not adopt the branch into a worktree: " + err.Error()), nil
- }
- result.Outcome = "adopted"
- } else {
- if _, err := workspaceGit(repo, "worktree", "add", "-b", branch, path, baseCommit); err != nil {
- return blockedCut("Boatstack could not add the worktree: " + err.Error()), nil
- }
- transition.branchCreated = true
- result.Created = true
- result.Outcome = "created"
- }
- transition.worktreeCreated = true
- destinationRepo = path
- }
- result.WorktreePath = path
- }
-
- if err := workspaceAfterDestination(destinationRepo); err != nil {
- rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition)
- return blockedCut("Boatstack could not verify the destination workspace: " + err.Error()), nil
- }
- if dirty, dirtyErr := dirtyOutsideFeature(destinationRepo, options.Feature); dirtyErr != nil || dirty {
- rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition)
- return blockedCut("Destination workspace contains changes outside the managed feature package."), nil
- }
- if sourceContext.Mode == SupervisionDetached && filepath.Clean(destinationRepo) != filepath.Clean(repo) {
- added, err := workspaceDetachedAlias(repo, destinationRepo)
- if err != nil {
- rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition)
- return blockedCut("Boatstack could not bind detached controller state to the destination: " + err.Error()), nil
- }
- transition.detachedAlias = added
- if err := workspaceAfterDetachedAlias(destinationRepo); err != nil {
- rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition)
- return blockedCut("Boatstack could not verify detached controller state after registration: " + err.Error()), nil
- }
- }
- fingerprint, err := transferFeaturePackage(repo, destinationRepo, options.Feature, sourceContext.Mode)
- if err != nil {
- rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition)
- return blockedCut("Boatstack could not transfer the planning package: " + err.Error()), nil
- }
- result.PlanFingerprint = fingerprint
- result.DestinationRepo = destinationRepo
- result.Reason = fmt.Sprintf("Workspace %q is ready at %s; continue Boatstack from that repository path.", branch, destinationRepo)
- return result, nil
-}
-
-type workspaceLifecyclePhase string
-
-const (
- workspaceActive workspaceLifecyclePhase = "ACTIVE"
- workspacePublished workspaceLifecyclePhase = "PUBLISHED"
- workspaceLanded workspaceLifecyclePhase = "LANDED"
- workspaceAbandoned workspaceLifecyclePhase = "ABANDONED"
- workspaceAttentionRequired workspaceLifecyclePhase = "ATTENTION_REQUIRED"
-)
-
-// workspaceLifecycleAssessment is the single authority boundary for workspace
-// completion. Published and Landed are deliberately separate: Git ancestry may
-// confirm landing only after a PR or completed managed delivery proves that the
-// branch entered the publication lifecycle.
-type workspaceLifecycleAssessment struct {
- Phase workspaceLifecyclePhase
- Source string
- Published bool
- Landed bool
- Reason string
-}
-
-func workspaceFeatureForBranch(branch string) string {
- feature := strings.TrimPrefix(strings.TrimSpace(branch), "feat/")
- if feature == branch || !featureSlugPattern.MatchString(feature) {
- return ""
- }
- return feature
-}
-
-func workspaceBranchLanded(repo, branch, base string) bool {
- if strings.TrimSpace(branch) == "" {
- return false
- }
- for _, target := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} {
- if _, err := workspaceGit(repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, target); err == nil {
- return true
- }
- }
- return false
-}
-
-func managedWorkspaceLifecycle(repo, branch, base string) (workspaceLifecycleAssessment, bool) {
- feature := workspaceFeatureForBranch(branch)
- if feature == "" {
- return workspaceLifecycleAssessment{}, false
- }
- owner := repo
- if worktree := worktreePathForBranch(repo, branch); worktree != "" {
- owner = worktree
- }
- statePath, err := deliveryStatePath(owner, feature)
- if err != nil || !fileExists(statePath) {
- return workspaceLifecycleAssessment{}, false
- }
- state, err := CurrentDeliveryState(owner, feature)
- if err != nil || !stateMatchesBranch(state, branch) {
- return workspaceLifecycleAssessment{
- Phase: workspaceAttentionRequired, Source: "delivery",
- Reason: "Managed delivery evidence is present but cannot be verified for this branch.",
- }, true
- }
- if len(state.Slices) == 0 || state.ActiveIndex < len(state.Slices) {
- return workspaceLifecycleAssessment{
- Phase: workspaceActive, Source: "delivery",
- Reason: "Managed delivery work is still active.",
- }, true
- }
- for _, slice := range state.Slices {
- if slice.Status != StatusPublished || strings.TrimSpace(slice.PRURL) == "" {
- return workspaceLifecycleAssessment{
- Phase: workspaceAttentionRequired, Source: "delivery",
- Reason: "Completed delivery state lacks durable publication evidence.",
- }, true
- }
- if strings.EqualFold(strings.TrimSpace(slice.PRState), "CLOSED") {
- return workspaceLifecycleAssessment{
- Phase: workspaceAttentionRequired, Source: "delivery", Published: true,
- Reason: "A published pull request closed without a verified merge.",
- }, true
- }
- }
- allLanded := true
- for _, slice := range state.Slices {
- if strings.EqualFold(strings.TrimSpace(slice.PRState), "MERGED") {
- continue
- }
- head := strings.TrimSpace(slice.HeadBranch)
- if head == "" {
- head = branch
- }
- if !workspaceBranchLanded(repo, head, base) {
- allLanded = false
- break
- }
- }
- if allLanded {
- return workspaceLifecycleAssessment{
- Phase: workspaceLanded, Source: "git-after-publication", Published: true, Landed: true,
- Reason: "Every published delivery branch is contained in the base branch.",
- }, true
- }
- if resolveDeliveryTerminal(owner, feature) == TerminalMerged {
- return workspaceLifecycleAssessment{
- Phase: workspaceActive, Source: "delivery", Published: true,
- Reason: "Every delivery slice is published, but the configured merged terminal is unfinished.",
- }, true
- }
- return workspaceLifecycleAssessment{
- Phase: workspacePublished, Source: "delivery", Published: true,
- Reason: "Every delivery slice is published, but landing is not verified.",
- }, true
-}
-
-func assessWorkspaceLifecycle(repo, branch, base string, abandoned bool) workspaceLifecycleAssessment {
- if abandoned {
- return workspaceLifecycleAssessment{
- Phase: workspaceAbandoned, Source: "operator", Reason: "The operator explicitly abandoned this delivery.",
- }
- }
- if managed, ok := managedWorkspaceLifecycle(repo, branch, base); ok {
- return managed
- }
- if out, err := workspaceGh(repo, "pr", "view", branch, "--json", "state", "-q", ".state"); err == nil {
- switch strings.ToUpper(strings.TrimSpace(out)) {
- case "MERGED":
- return workspaceLifecycleAssessment{
- Phase: workspaceLanded, Source: "gh", Published: true, Landed: true,
- Reason: "GitHub reports the pull request merged.",
- }
- case "OPEN":
- return workspaceLifecycleAssessment{
- Phase: workspacePublished, Source: "gh", Published: true,
- Reason: "GitHub reports an open pull request.",
- }
- case "CLOSED":
- return workspaceLifecycleAssessment{
- Phase: workspaceAttentionRequired, Source: "gh", Published: true,
- Reason: "GitHub reports the pull request closed without a merge.",
- }
- default:
- return workspaceLifecycleAssessment{
- Phase: workspaceAttentionRequired, Source: "gh",
- Reason: "GitHub returned an unsupported pull request state.",
- }
- }
- }
- return workspaceLifecycleAssessment{
- Phase: workspaceActive, Source: "unpublished",
- Reason: "No durable publication evidence exists; preserving the active workspace.",
- }
-}
-
-func (assessment workspaceLifecycleAssessment) cleanupEligible(cleanupAfter string) bool {
- if assessment.Phase == workspaceAbandoned {
- return true
- }
- if cleanupAfter == "ship" {
- return assessment.Phase == workspacePublished || assessment.Phase == workspaceLanded
- }
- return assessment.Phase == workspaceLanded
-}
-
-// workspaceMergeStatus is retained as the narrow merge projection used by
-// existing callers and tests. It can no longer promote bare ancestry into merge
-// authority: the full lifecycle assessment owns that decision.
-func workspaceMergeStatus(repo, branch, base string) (bool, string) {
- assessment := assessWorkspaceLifecycle(repo, branch, base, false)
- return assessment.Landed, assessment.Source
-}
-
-// worktreePathForBranch returns the linked worktree path checked out on branch,
-// or "" when the branch is not checked out in a separate worktree.
-func worktreePathForBranch(repo, branch string) string {
- out, err := workspaceGit(repo, "worktree", "list", "--porcelain")
- if err != nil {
- return ""
- }
- current := ""
- for _, line := range strings.Split(out, "\n") {
- switch {
- case strings.HasPrefix(line, "worktree "):
- current = strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
- case strings.HasPrefix(line, "branch "):
- if strings.TrimSpace(strings.TrimPrefix(line, "branch ")) == "refs/heads/"+branch {
- return current
- }
- }
- }
- return ""
-}
-
-func branchExists(repo, branch string) bool {
- _, err := workspaceGit(repo, "rev-parse", "--verify", "refs/heads/"+branch+"^{commit}")
- return err == nil
-}
-
-// WorkspaceCleanupOptions requests removal of a finished per-feature workspace.
-type WorkspaceCleanupOptions struct {
- Repo string
- Branch string
- Confirm bool // the human supplied the cleanup confirmation
- Force bool // override the merge gate and discard uncommitted/unmerged work
-}
-
-// WorkspaceCleanup is the deterministic result of a cleanup request.
-type WorkspaceCleanup struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED | NEEDS_CONFIRMATION
- Branch string `json:"branch,omitempty"`
- Mode string `json:"mode,omitempty"`
- Merged bool `json:"merged"`
- MergeSource string `json:"merge_source,omitempty"`
- WorktreeRemoved bool `json:"worktree_removed"`
- BranchDeleted bool `json:"branch_deleted"`
- Reason string `json:"reason"`
-}
-
-func blockedCleanup(branch, reason string) WorkspaceCleanup {
- return WorkspaceCleanup{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Branch: branch, Reason: reason}
-}
-
-// workspaceRemovalPlan is the outcome of the safety gates shared by cleanup and
-// reap: whether the named workspace may be removed, and the merge state resolved
-// while deciding (which selects force-deletion of a squash/rebase-merged branch).
-type workspaceRemovalPlan struct {
- Removable bool
- Status string // BLOCKED when !Removable
- Reason string
- Merged bool
-}
-
-// planWorkspaceRemoval applies the base-branch, current-branch, merge, dirty, and
-// unmerged-commit gates that govern removing a single feature workspace. It never
-// consults cleanup/reap mode or human confirmation and never mutates the
-// repository; callers own policy and confirmation. cleanupAfter="ship" permits
-// removing an unmerged branch after the lifecycle boundary has already proved
-// publication (used for published and explicitly abandoned workspaces).
-func planWorkspaceRemoval(repo, base, branch, worktreePath, cleanupAfter string, merged, force bool) workspaceRemovalPlan {
- if branch == base {
- return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Refusing to clean up the base branch %q.", base)}
- }
- if current, _ := workspaceGit(repo, "branch", "--show-current"); strings.TrimSpace(current) == branch && worktreePath == "" {
- return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Branch %q is the current branch; switch away before cleaning it up.", branch)}
- }
- if cleanupAfter == "merge" && !merged && !force {
- return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("PR for %q is not merged yet; keeping the workspace. Re-run with force to clean up early.", branch)}
- }
- // Refuse to discard work the user has not landed unless explicitly forced.
- if !force {
- if worktreePath != "" {
- if dirty, _ := workspaceGit(worktreePath, "status", "--porcelain"); strings.TrimSpace(dirty) != "" {
- return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Workspace %q has uncommitted changes; commit or discard them, or force cleanup.", branch)}
- }
- }
- if !merged && cleanupAfter != "ship" {
- return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Branch %q has commits not merged into %s; force cleanup to discard them.", branch, base)}
- }
- }
- return workspaceRemovalPlan{Removable: true, Status: "VERIFIED", Merged: merged}
-}
-
-// performWorkspaceRemoval removes the linked worktree (if any) and deletes the
-// local branch in-process. It assumes planWorkspaceRemoval already cleared the
-// safety gates; merged/force select force-deletion so a squash- or rebase-merged
-// branch (whose local ref is not a local ancestor of the base) is still removable.
-// The git it runs is the helper's own subprocess, never an agent shell command, so
-// it is the sanctioned actuator the guard allows.
-func performWorkspaceRemoval(repo, branch, worktreePath string, merged, force bool) (worktreeRemoved, branchDeleted bool, reason string, err error) {
- if worktreePath != "" {
- removeArgs := []string{"worktree", "remove", worktreePath}
- if force {
- removeArgs = append(removeArgs, "--force")
- }
- if _, e := workspaceGit(repo, removeArgs...); e != nil {
- return false, false, "Boatstack could not remove the worktree: " + e.Error(), e
- }
- worktreeRemoved = true
- }
- if branchExists(repo, branch) {
- deleteFlag := "-d"
- if force || merged {
- deleteFlag = "-D"
- }
- if _, e := workspaceGit(repo, "branch", deleteFlag, branch); e != nil {
- return worktreeRemoved, false, "Boatstack could not delete the branch: " + e.Error(), e
- }
- branchDeleted = true
- }
- return worktreeRemoved, branchDeleted, "", nil
-}
-
-// CleanupFeatureWorkspace removes a finished workspace only when it is safe: the
-// PR must be merged (unless cleanup_after is "ship" or Force overrides), there
-// must be no uncommitted or unmerged work (unless Force), and confirm-mode must
-// receive the human confirmation before anything is deleted.
-func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, error) {
- branch := strings.TrimSpace(options.Branch)
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return blockedCleanup(branch, err.Error()), nil
- }
- if branch == "" {
- return blockedCleanup(branch, "A branch is required to clean up a workspace."), nil
- }
- if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) {
- return blockedCleanup(branch, "This repository has no Boatstack project installation."), nil
- }
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- return blockedCleanup(branch, "Boatstack could not read the workspace policy: "+err.Error()), nil
- }
- policy := resolveWorkspace(config.Workspace)
- if policy.Cleanup == "off" && !options.Force {
- return blockedCleanup(branch, "Workspace cleanup is disabled (workspace.cleanup=off)."), nil
- }
-
- worktreePath := worktreePathForBranch(repo, branch)
- if !branchExists(repo, branch) && worktreePath == "" {
- return WorkspaceCleanup{
- SchemaVersion: workspaceSchemaVersion, VerificationStatus: "VERIFIED", Branch: branch,
- Mode: policy.Mode, Reason: fmt.Sprintf("No workspace for branch %q; nothing to clean up.", branch),
- }, nil
- }
-
- base := defaultPRBase(repo)
- abandoned := false
- for _, feature := range config.Workflow.IgnoredDeliveries {
- if branchForFeature(feature) == branch {
- abandoned = true
- break
- }
- }
- lifecycle := assessWorkspaceLifecycle(repo, branch, base, abandoned)
- result := WorkspaceCleanup{
- SchemaVersion: workspaceSchemaVersion, Branch: branch, Mode: policy.Mode,
- Merged: lifecycle.Landed, MergeSource: lifecycle.Source,
- }
- if !options.Force && !lifecycle.cleanupEligible(policy.CleanupAfter) {
- result.VerificationStatus = "BLOCKED"
- result.Reason = lifecycle.Reason
- return result, nil
- }
-
- cleanupAfter := policy.CleanupAfter
- if lifecycle.Phase == workspaceAbandoned {
- cleanupAfter = "ship"
- }
- plan := planWorkspaceRemoval(repo, base, branch, worktreePath, cleanupAfter, lifecycle.Landed, options.Force)
- if !plan.Removable {
- result.VerificationStatus = plan.Status
- result.Reason = plan.Reason
- return result, nil
- }
- result.Merged = plan.Merged
-
- if policy.Cleanup == "confirm" && !options.Confirm && !options.Force {
- result.VerificationStatus = "NEEDS_CONFIRMATION"
- result.Reason = fmt.Sprintf("Ready to remove the workspace for %q. Confirm cleanup to proceed.", branch)
- return result, nil
- }
-
- worktreeRemoved, branchDeleted, failReason, removeErr := performWorkspaceRemoval(repo, branch, worktreePath, plan.Merged, options.Force)
- if removeErr != nil {
- return blockedCleanup(branch, failReason), nil
- }
- result.WorktreeRemoved = worktreeRemoved
- result.BranchDeleted = branchDeleted
- result.VerificationStatus = "VERIFIED"
- result.Reason = fmt.Sprintf("Cleaned up the workspace for %q.", branch)
- return result, nil
-}
-
-// worktreeEntry is one linked worktree and the branch it has checked out.
-type worktreeEntry struct {
- Path string
- Branch string
-}
-
-// boatstackWorktrees lists the linked worktrees Boatstack owns — those created
-// under .product-loop/worktrees/. The main worktree, human-created worktrees, and
-// detached worktrees are excluded so reap can never remove work Boatstack did not
-// create. Paths are absolute (git reports them so), which lets reap run from any
-// worktree.
-func boatstackWorktrees(repo string) []worktreeEntry {
- out, err := workspaceGit(repo, "worktree", "list", "--porcelain")
- if err != nil {
- return nil
- }
- var entries []worktreeEntry
- var current worktreeEntry
- flush := func() {
- if current.Path != "" && current.Branch != "" && strings.Contains(filepath.ToSlash(current.Path), "/.product-loop/worktrees/") {
- entries = append(entries, current)
- }
- current = worktreeEntry{}
- }
- for _, line := range strings.Split(out, "\n") {
- switch {
- case strings.HasPrefix(line, "worktree "):
- flush()
- current.Path = strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
- case strings.HasPrefix(line, "branch "):
- current.Branch = strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(line, "branch ")), "refs/heads/")
- }
- }
- flush()
- return entries
-}
-
-// WorkspaceReapOptions requests a sweep of all terminal Boatstack workspaces.
-type WorkspaceReapOptions struct {
- Repo string
- Confirm bool // the operator approved the aggregate reap prompt
- Force bool // override the merge/dirty/unmerged gates and discard unlanded work
-}
-
-// WorkspaceReapItem is the per-workspace outcome within a reap sweep.
-type WorkspaceReapItem struct {
- Branch string `json:"branch"`
- WorktreePath string `json:"worktree_path,omitempty"`
- Merged bool `json:"merged"`
- MergeSource string `json:"merge_source,omitempty"`
- Abandoned bool `json:"abandoned,omitempty"`
- Action string `json:"action"` // reaped | reclaimable | skipped | blocked
- WorktreeRemoved bool `json:"worktree_removed,omitempty"`
- BranchDeleted bool `json:"branch_deleted,omitempty"`
- Reason string `json:"reason"`
-}
-
-// WorkspaceReap is the deterministic result of a reap sweep.
-type WorkspaceReap struct {
- SchemaVersion int `json:"schema_version"`
- VerificationStatus string `json:"verification_status"` // VERIFIED | NEEDS_CONFIRMATION | BLOCKED
- Mode string `json:"mode,omitempty"`
- Candidates []WorkspaceReapItem `json:"candidates,omitempty"`
- ReclaimableCount int `json:"reclaimable_count"`
- ReapedCount int `json:"reaped_count"`
- Reason string `json:"reason"`
-}
-
-func blockedReap(reason string) WorkspaceReap {
- return WorkspaceReap{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason}
-}
-
-// samePath reports whether two filesystem paths denote the same location,
-// resolving symlinks first (macOS temp dirs live under a /private symlink, so a
-// raw string compare of git's toplevel against a recorded worktree path can
-// spuriously differ) and falling back to a lexical comparison.
-func samePath(a, b string) bool {
- if resolved, err := filepath.EvalSymlinks(a); err == nil {
- a = resolved
- }
- if resolved, err := filepath.EvalSymlinks(b); err == nil {
- b = resolved
- }
- return filepath.Clean(a) == filepath.Clean(b)
-}
-
-// reclaimableScan enumerates the Boatstack worktrees and classifies each as
-// skipped or reclaimable without mutating the repository. It returns the skipped
-// candidates (for reporting) and the reclaimable subset (merged or abandoned,
-// excluding the base branch, the current worktree, and non-Boatstack worktrees).
-func reclaimableScan(repo, base, cleanupAfter string, ignored []string) (skipped, reapable []WorkspaceReapItem) {
- abandonedBranches := map[string]bool{}
- for _, slug := range ignored {
- if branch := branchForFeature(slug); branch != "" {
- abandonedBranches[branch] = true
- }
- }
- currentTop := ""
- if top, topErr := workspaceGit(repo, "rev-parse", "--show-toplevel"); topErr == nil {
- currentTop = strings.TrimSpace(top)
- }
- for _, wt := range boatstackWorktrees(repo) {
- branch := wt.Branch
- if branch == "" || branch == base {
- continue
- }
- item := WorkspaceReapItem{Branch: branch, WorktreePath: wt.Path}
- if currentTop != "" && samePath(wt.Path, currentTop) {
- item.Action = "skipped"
- item.Reason = "This is the current worktree; reap it from another location."
- skipped = append(skipped, item)
- continue
- }
- abandoned := abandonedBranches[branch]
- lifecycle := assessWorkspaceLifecycle(repo, branch, base, abandoned)
- item.Merged = lifecycle.Landed
- item.MergeSource = lifecycle.Source
- item.Abandoned = abandoned
- if !lifecycle.cleanupEligible(cleanupAfter) {
- item.Action = "skipped"
- item.Reason = lifecycle.Reason
- skipped = append(skipped, item)
- continue
- }
- item.Action = "reclaimable"
- reapable = append(reapable, item)
- }
- return skipped, reapable
-}
-
-// CountReclaimableWorkspaces reports how many terminal Boatstack workspaces reap
-// would reclaim right now. It is read-only so boatstack-next can surface the reap
-// prompt with an accurate count. It returns 0 when workspace management is off.
-func CountReclaimableWorkspaces(repoPath string) int {
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return 0
- }
- config, _, cfgErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if cfgErr != nil || !resolveWorkspace(config.Workspace).Enabled {
- return 0
- }
- policy := resolveWorkspace(config.Workspace)
- _, reapable := reclaimableScan(repo, defaultPRBase(repo), policy.CleanupAfter, config.Workflow.IgnoredDeliveries)
- return len(reapable)
-}
-
-// ReapWorkspaces sweeps every terminal Boatstack workspace — one whose branch is
-// confirmed merged (via gh, else local ancestry) or explicitly abandoned (its
-// feature slug is in workflow.ignored_deliveries) — and reclaims the local
-// worktree and branch. It never touches a non-Boatstack worktree, the base branch,
-// the current worktree, or an unmerged/open workspace, and it never discards
-// uncommitted or unmerged work without Force. In confirm mode it returns the
-// reclaimable set as NEEDS_CONFIRMATION without removing anything; in auto mode (or
-// after Confirm/Force) it removes them through the in-process actuator.
-func ReapWorkspaces(options WorkspaceReapOptions) (WorkspaceReap, error) {
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return blockedReap(err.Error()), nil
- }
- if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) {
- return blockedReap("This repository has no Boatstack project installation."), nil
- }
- config, _, cfgErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if cfgErr != nil {
- return blockedReap("Boatstack could not read the workspace policy: " + cfgErr.Error()), nil
- }
- policy := resolveWorkspace(config.Workspace)
- result := WorkspaceReap{SchemaVersion: workspaceSchemaVersion, Mode: policy.Reap}
- if !policy.Enabled {
- result.VerificationStatus = "BLOCKED"
- result.Reason = "Workspace management is disabled (workspace.enabled=false)."
- return result, nil
- }
- if policy.Reap == "off" && !options.Force {
- result.VerificationStatus = "BLOCKED"
- result.Reason = "Workspace reaping is disabled (workspace.reap=off)."
- return result, nil
- }
-
- base := defaultPRBase(repo)
- skipped, reapable := reclaimableScan(repo, base, policy.CleanupAfter, config.Workflow.IgnoredDeliveries)
- result.Candidates = append(result.Candidates, skipped...)
- result.ReclaimableCount = len(reapable)
-
- if len(reapable) == 0 {
- result.VerificationStatus = "VERIFIED"
- result.Reason = "No merged or abandoned Boatstack workspaces to reclaim."
- return result, nil
- }
-
- if policy.Reap == "confirm" && !options.Confirm && !options.Force {
- result.Candidates = append(result.Candidates, reapable...)
- result.VerificationStatus = "NEEDS_CONFIRMATION"
- result.Reason = fmt.Sprintf("%d Boatstack worktree(s)/branch(es) are merged or abandoned and reclaimable. Confirm reap to remove them.", len(reapable))
- return result, nil
- }
-
- for _, item := range reapable {
- // An explicitly abandoned but unmerged branch is operator-authorized
- // disposal, so it is removed like cleanup_after="ship" (unmerged allowed);
- // the dirty gate below still protects uncommitted work unless forced.
- effectiveAfter := policy.CleanupAfter
- if item.Abandoned && !item.Merged {
- effectiveAfter = "ship"
- }
- plan := planWorkspaceRemoval(repo, base, item.Branch, item.WorktreePath, effectiveAfter, item.Merged, options.Force)
- if !plan.Removable {
- item.Action = "blocked"
- item.Reason = plan.Reason
- result.Candidates = append(result.Candidates, item)
- continue
- }
- // A terminal branch — merged or operator-abandoned — is safe to
- // force-delete: an abandoned branch is intentionally not merged into base,
- // so `git branch -d` would refuse it. The gates above already cleared it.
- forceDelete := plan.Merged || item.Abandoned
- worktreeRemoved, branchDeleted, failReason, removeErr := performWorkspaceRemoval(repo, item.Branch, item.WorktreePath, forceDelete, options.Force)
- if removeErr != nil {
- item.Action = "blocked"
- item.Reason = failReason
- result.Candidates = append(result.Candidates, item)
- continue
- }
- item.Merged = plan.Merged
- item.WorktreeRemoved = worktreeRemoved
- item.BranchDeleted = branchDeleted
- item.Action = "reaped"
- item.Reason = fmt.Sprintf("Reclaimed the workspace for %q.", item.Branch)
- result.Candidates = append(result.Candidates, item)
- result.ReapedCount++
- }
-
- // Clear any stale worktree admin entries left by out-of-band directory removal.
- _, _ = workspaceGit(repo, "worktree", "prune")
-
- result.VerificationStatus = "VERIFIED"
- if result.ReapedCount == len(reapable) {
- result.Reason = fmt.Sprintf("Reclaimed %d Boatstack workspace(s).", result.ReapedCount)
- } else {
- result.Reason = fmt.Sprintf("Reclaimed %d of %d reclaimable Boatstack workspace(s); see candidates for the rest.", result.ReapedCount, len(reapable))
- }
- return result, nil
-}
-
-// WorkspaceStatus reports whether a branch's workspace still exists and whether
-// it is safe to clean up, so the flow can surface cleanup without forcing it.
-type WorkspaceStatus struct {
- SchemaVersion int `json:"schema_version"`
- Branch string `json:"branch,omitempty"`
- Exists bool `json:"exists"`
- WorktreePath string `json:"worktree_path,omitempty"`
- Merged bool `json:"merged"`
- MergeSource string `json:"merge_source,omitempty"`
- CleanupDue bool `json:"cleanup_due"`
- Reason string `json:"reason"`
-}
-
-// FeatureWorkspaceStatus inspects one branch's workspace. It is read-only.
-func FeatureWorkspaceStatus(repoPath, branch string) (WorkspaceStatus, error) {
- branch = strings.TrimSpace(branch)
- repo, err := ResolveRepository(repoPath)
- if err != nil {
- return WorkspaceStatus{}, err
- }
- if branch == "" {
- return WorkspaceStatus{}, fmt.Errorf("workspace status requires a branch")
- }
- status := WorkspaceStatus{SchemaVersion: workspaceSchemaVersion, Branch: branch}
- worktreePath := worktreePathForBranch(repo, branch)
- status.WorktreePath = worktreePath
- status.Exists = worktreePath != "" || branchExists(repo, branch)
- if !status.Exists {
- status.Reason = fmt.Sprintf("No workspace exists for branch %q.", branch)
- return status, nil
- }
- config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- abandoned := false
- if configErr == nil {
- for _, feature := range config.Workflow.IgnoredDeliveries {
- if branchForFeature(feature) == branch {
- abandoned = true
- break
- }
- }
- }
- base := defaultPRBase(repo)
- lifecycle := assessWorkspaceLifecycle(repo, branch, base, abandoned)
- status.Merged, status.MergeSource = lifecycle.Landed, lifecycle.Source
- status.CleanupDue = configErr == nil && lifecycle.cleanupEligible(resolveWorkspace(config.Workspace).CleanupAfter)
- if status.CleanupDue {
- status.Reason = fmt.Sprintf("Workspace for %q is ready to clean up.", branch)
- } else {
- status.Reason = lifecycle.Reason
- }
- return status, nil
-}
diff --git a/boatstack/workspace_authority_conformance_test.go b/boatstack/workspace_authority_conformance_test.go
deleted file mode 100644
index 9d8a4b1..0000000
--- a/boatstack/workspace_authority_conformance_test.go
+++ /dev/null
@@ -1,295 +0,0 @@
-package boatstack
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "testing"
-)
-
-// control-law: uncertain-workspace-identity-or-lifecycle-preserves-resource
-func TestDetachedUnpublishedCutRemainsActiveAndActivatesOnlyFromLinkedAlias(t *testing.T) {
- repo := detachedTestRepo(t, "")
- embeddedFeatureForDetach(t, repo, "feature-one", "")
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Workflow.HumanPlanApproval = false
- config.Workspace = defaultWorkspace()
- config.Adapters = nil
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, sourceConfigName), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "project.json"), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, repo, "add", sourceConfigName, "plans/source.md")
- workspaceGitDo(t, repo, "commit", "-m", "record detached workspace inputs")
- addWorkspaceOrigin(t, repo)
-
- attached, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || attached.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach: %+v (%v)", attached, err)
- }
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if err != nil || cut.VerificationStatus != "VERIFIED" || cut.DestinationRepo == repo {
- t.Fatalf("cut: %+v (%v)", cut, err)
- }
- withWorkspaceGh(t, ghUnavailable())
-
- status, err := FeatureWorkspaceStatus(repo, cut.Branch)
- if err != nil {
- t.Fatal(err)
- }
- if status.Merged || status.CleanupDue || status.MergeSource != "unpublished" {
- t.Fatalf("unpublished cut collapsed into completion: %+v", status)
- }
- cleanup, err := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: cut.Branch, Confirm: true})
- if err != nil || cleanup.VerificationStatus != "BLOCKED" {
- t.Fatalf("unpublished cleanup must block: %+v (%v)", cleanup, err)
- }
- reap, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Confirm: true})
- if err != nil || reap.ReapedCount != 0 || !dirExists(cut.WorktreePath) {
- t.Fatalf("reap removed unpublished workspace: %+v (%v)", reap, err)
- }
-
- directory := WorkspaceFor(cut.DestinationRepo).FeatureDir("feature-one")
- planPath := filepath.Join(directory, "plan.md")
- lockPath := filepath.Join(directory, "plan.lock.json")
- compiled := filepath.Join(directory, "compiled")
- if _, err := ResolveControllerRepository(directory); err == nil || !strings.Contains(err.Error(), "multiple verified repository aliases") {
- t.Fatalf("ambiguous controller inverse selected an alias: %v", err)
- }
- mainOptions := ActivationOptions{Repo: repo, PlanPath: planPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}
- if err := ActivatePlan(mainOptions); err == nil || !strings.Contains(err.Error(), "cut worktree") {
- t.Fatalf("main activation did not refuse with the worktree guard: %v", err)
- }
- if fileExists(lockPath) {
- t.Fatal("refused main activation wrote a plan lock")
- }
- linkedOptions := mainOptions
- linkedOptions.Repo = cut.DestinationRepo
- if err := ActivatePlan(linkedOptions); err != nil {
- t.Fatalf("linked detached alias did not activate: %v", err)
- }
- if !fileExists(lockPath) {
- t.Fatal("linked activation did not write the plan lock")
- }
-}
-
-// control-law: cleanup-policy-never-substitutes-for-lifecycle-evidence
-func TestCleanupAfterShipStillRequiresCompletedPublication(t *testing.T) {
- policy := defaultWorkspace()
- policy.CleanupAfter = "ship"
- repo := workspaceRepo(t, policy)
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "ship-later"})
- if err != nil || cut.VerificationStatus != "VERIFIED" {
- t.Fatalf("cut: %+v (%v)", cut, err)
- }
- withWorkspaceGh(t, ghUnavailable())
- status, err := FeatureWorkspaceStatus(repo, cut.Branch)
- if err != nil || status.CleanupDue || status.Merged {
- t.Fatalf("ship policy treated an unpublished cut as terminal: %+v (%v)", status, err)
- }
- cleanup, err := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: cut.Branch, Confirm: true})
- if err != nil || cleanup.VerificationStatus != "BLOCKED" || !dirExists(cut.WorktreePath) {
- t.Fatalf("ship cleanup removed unpublished work: %+v (%v)", cleanup, err)
- }
- if count := CountReclaimableWorkspaces(repo); count != 0 {
- t.Fatalf("ship reap exposed %d unpublished workspace(s)", count)
- }
-}
-
-// control-law: active-managed-delivery-outranks-branch-pr-projections
-func TestPartialManagedDeliveryRemainsActiveEvenWhenGitHubReportsMerged(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "partial"})
- if err != nil || cut.VerificationStatus != "VERIFIED" {
- t.Fatalf("cut: %+v (%v)", cut, err)
- }
- directory := WorkspaceFor(cut.WorktreePath).FeatureDir("partial")
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(directory, "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- lockHash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := saveDeliveryState(cut.WorktreePath, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "partial", PlanLockHash: lockHash, ActiveIndex: 1,
- Slices: []DeliverySlice{
- {ID: "first", Title: "First", Status: StatusPublished, HeadBranch: cut.Branch, PRURL: "https://example.invalid/pr/1", PRState: "MERGED"},
- {ID: "second", Title: "Second", Status: StatusBuild, HeadBranch: cut.Branch},
- },
- }); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("MERGED"))
- status, err := FeatureWorkspaceStatus(repo, cut.Branch)
- if err != nil || status.Merged || status.CleanupDue || status.MergeSource != "delivery" {
- t.Fatalf("partial delivery collapsed into completion: %+v (%v)", status, err)
- }
-}
-
-// control-law: closed-unmerged-publication-is-preservation-only
-func TestClosedUnmergedWorkspaceRequiresAttention(t *testing.T) {
- policy := defaultWorkspace()
- policy.CleanupAfter = "ship"
- repo := workspaceRepo(t, policy)
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "closed"})
- if err != nil || cut.VerificationStatus != "VERIFIED" {
- t.Fatalf("cut: %+v (%v)", cut, err)
- }
- withWorkspaceGh(t, ghState("CLOSED"))
- status, err := FeatureWorkspaceStatus(repo, cut.Branch)
- if err != nil || status.Merged || status.CleanupDue || status.MergeSource != "gh" {
- t.Fatalf("closed-unmerged workspace became cleanup-ready: %+v (%v)", status, err)
- }
-}
-
-// control-law: managed-terminal-goal-controls-lifecycle-completion
-func TestMergedTerminalKeepsPublishedManagedDeliveryActive(t *testing.T) {
- policy := defaultWorkspace()
- policy.CleanupAfter = "ship"
- repo := workspaceRepo(t, policy)
- config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
- if err != nil {
- t.Fatal(err)
- }
- config.Delivery = &DeliveryPolicy{Terminal: string(TerminalMerged)}
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(WorkspaceFor(repo).ProjectConfigPath(), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "merged-goal"})
- if err != nil || cut.VerificationStatus != "VERIFIED" {
- t.Fatalf("cut: %+v (%v)", cut, err)
- }
- if err := os.WriteFile(filepath.Join(cut.WorktreePath, "managed-change.txt"), []byte("work\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, cut.WorktreePath, "add", "managed-change.txt")
- workspaceGitDo(t, cut.WorktreePath, "commit", "-m", "managed change")
- writeCompletedDelivery(t, cut.WorktreePath, "merged-goal", cut.Branch)
- state, err := CurrentDeliveryState(cut.WorktreePath, "merged-goal")
- if err != nil {
- t.Fatal(err)
- }
- state.Goal = string(TerminalMerged)
- state.Slices[0].PRState = "OPEN"
- if err := saveDeliveryState(cut.WorktreePath, state); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("MERGED"))
- status, err := FeatureWorkspaceStatus(repo, cut.Branch)
- if err != nil || status.Merged || status.CleanupDue || status.MergeSource != "delivery" {
- t.Fatalf("published slice escaped the configured merged terminal: %+v (%v)", status, err)
- }
-}
-
-func directCallers(t *testing.T, filename, callee string) []string {
- t.Helper()
- file, err := parser.ParseFile(token.NewFileSet(), filename, nil, 0)
- if err != nil {
- t.Fatal(err)
- }
- callers := []string{}
- for _, declaration := range file.Decls {
- function, ok := declaration.(*ast.FuncDecl)
- if !ok || function.Body == nil {
- continue
- }
- found := false
- ast.Inspect(function.Body, func(node ast.Node) bool {
- call, ok := node.(*ast.CallExpr)
- if !ok {
- return true
- }
- identifier, ok := call.Fun.(*ast.Ident)
- if ok && identifier.Name == callee {
- found = true
- }
- return true
- })
- if found {
- callers = append(callers, function.Name.Name)
- }
- }
- sort.Strings(callers)
- return callers
-}
-
-func requireCallerInventory(t *testing.T, filename, callee string, expected []string) {
- t.Helper()
- actual := directCallers(t, filename, callee)
- sort.Strings(expected)
- if strings.Join(actual, "\x00") != strings.Join(expected, "\x00") {
- t.Fatalf("%s callers changed without authority review: got %v want %v", callee, actual, expected)
- }
-}
-
-func repositoryCallers(t *testing.T, callee string) []string {
- t.Helper()
- files, err := filepath.Glob("*.go")
- if err != nil {
- t.Fatal(err)
- }
- callers := []string{}
- for _, filename := range files {
- if strings.HasSuffix(filename, "_test.go") {
- continue
- }
- for _, caller := range directCallers(t, filename, callee) {
- callers = append(callers, filename+":"+caller)
- }
- }
- sort.Strings(callers)
- return callers
-}
-
-func requireRepositoryCallerInventory(t *testing.T, callee string, expected []string) {
- t.Helper()
- actual := repositoryCallers(t, callee)
- sort.Strings(expected)
- if strings.Join(actual, "\x00") != strings.Join(expected, "\x00") {
- t.Fatalf("%s repository callers changed without authority review: got %v want %v", callee, actual, expected)
- }
-}
-
-// control-law: workspace-authority-boundaries-cover-every-static-consumer
-func TestWorkspaceAuthoritySurfaceInventory(t *testing.T) {
- requireCallerInventory(t, "workspace.go", "assessWorkspaceLifecycle", []string{
- "CleanupFeatureWorkspace", "FeatureWorkspaceStatus", "reclaimableScan", "workspaceMergeStatus",
- })
- requireCallerInventory(t, "workspace.go", "workspaceBranchLanded", []string{"managedWorkspaceLifecycle"})
- requireCallerInventory(t, "plan.go", "ResolveControllerRepositoryFor", []string{
- "ActivatePlan", "CheckPlanForRepository", "CheckApprovalLock", "checkApprovalSourcePlan",
- })
- requireCallerInventory(t, "planning.go", "ResolveControllerRepositoryFor", []string{
- "PlanningBaselineForRepository", "RecordApproval",
- })
- requireCallerInventory(t, "readiness.go", "ResolveControllerRepositoryFor", []string{"CheckPlanReadinessForRepository"})
- requireRepositoryCallerInventory(t, "ResolveControllerRepositoryFor", []string{
- "plan.go:ActivatePlan", "plan.go:CheckApprovalLock", "plan.go:CheckPlanForRepository", "plan.go:checkApprovalSourcePlan",
- "planning.go:PlanningBaselineForRepository", "planning.go:RecordApproval",
- "readiness.go:CheckPlanReadinessForRepository",
- })
- requireRepositoryCallerInventory(t, "ResolveControllerRepository", []string{
- "plan.go:CheckApprovalLock", "plan.go:CheckPlan", "plan.go:checkApprovalReceipt", "plan.go:compilePlanFiles", "plan.go:sourcePlanForStructuredPlan",
- "planning.go:PlanningBaselineForPlan", "readiness.go:CheckPlanReadiness",
- })
-}
diff --git a/boatstack/workspace_reap_test.go b/boatstack/workspace_reap_test.go
deleted file mode 100644
index 5c70739..0000000
--- a/boatstack/workspace_reap_test.go
+++ /dev/null
@@ -1,330 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-// These are boundary-conformance tests for the post-merge reap sweep. Each test
-// names the control law it proves (see AGENTS.md "Map tests to control laws"):
-//
-// control-law: reap-removes-only-terminal-boatstack-workspaces
-// Reap removes a worktree/branch only when it is Boatstack-owned AND confirmed
-// merged or explicitly abandoned; never unmerged/open, never a non-Boatstack
-// worktree, never the base or current worktree.
-// control-law: reap-never-discards-unlanded-work
-// Reap never removes a worktree with uncommitted or unmerged work without Force.
-// control-law: reap-prompts-before-destroying-in-confirm-mode
-// confirm returns NEEDS_CONFIRMATION without removing; auto removes; off blocks.
-// control-law: reap-preserves-per-worktree-delivery-isolation
-// Reap enumerates via git worktree list and the merge oracle; reaping one
-// worktree never disturbs an unrelated worktree or its per-worktree state.
-
-// ghStateByBranch mocks `gh pr view --json state -q .state`, returning a
-// per-branch state and defaulting unknown branches to OPEN so the merge oracle is
-// deterministic (it never falls through to local ancestry, which would treat a
-// commitless freshly-cut branch as merged).
-func ghStateByBranch(states map[string]string) func(string, ...string) (string, error) {
- return func(_ string, arguments ...string) (string, error) {
- branch := ""
- for i := 0; i+1 < len(arguments); i++ {
- if arguments[i] == "view" {
- branch = arguments[i+1]
- break
- }
- }
- if state, ok := states[branch]; ok {
- return state, nil
- }
- return "OPEN", nil
- }
-}
-
-func cutWorktree(t *testing.T, repo, feature string) (branch, path string) {
- t.Helper()
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: feature})
- if err != nil || cut.VerificationStatus != "VERIFIED" {
- t.Fatalf("cut %q failed: %+v (%v)", feature, cut, err)
- }
- return cut.Branch, cut.WorktreePath
-}
-
-// commitInWorktree gives a worktree a real, unmerged commit so it is genuinely not
-// an ancestor of the base branch.
-func commitInWorktree(t *testing.T, path, name string) {
- t.Helper()
- if err := os.WriteFile(filepath.Join(path, name), []byte("work\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, path, "add", name)
- workspaceGitDo(t, path, "commit", "-m", "work in "+name)
-}
-
-// writeWorkspaceProjectConfig rewrites project.json with a workspace policy and an
-// optional set of abandoned (ignored) feature slugs. It mirrors the config that
-// workspaceRepo writes.
-func writeWorkspaceProjectConfig(t *testing.T, repo string, ws Workspace, ignored ...string) {
- t.Helper()
- config := ProjectConfig{
- SchemaVersion: 1,
- Project: Project{Name: "test", DefaultBranch: "main", Commands: map[string]string{"test": "go test ./..."}},
- Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true, IgnoredDeliveries: ignored},
- Workspace: ws,
- Adapters: []string{"cursor"},
- }
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), raw, 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func autoReapWorkspace() Workspace {
- ws := defaultWorkspace()
- ws.Reap = "auto"
- return ws
-}
-
-// control-law: reap-removes-only-terminal-boatstack-workspaces
-// Positive + relation conformance: a sweep reclaims every merged worktree at once
-// and keeps the still-open one.
-func TestReapSweepsAllMergedWorktrees(t *testing.T) {
- repo := workspaceRepo(t, autoReapWorkspace())
- alphaBranch, alphaPath := cutWorktree(t, repo, "alpha")
- betaBranch, betaPath := cutWorktree(t, repo, "beta")
- gammaBranch, gammaPath := cutWorktree(t, repo, "gamma")
- commitInWorktree(t, gammaPath, "gamma.txt") // genuinely unmerged
- withWorkspaceGh(t, ghStateByBranch(map[string]string{
- alphaBranch: "MERGED", betaBranch: "MERGED", gammaBranch: "OPEN",
- }))
-
- result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || result.ReapedCount != 2 {
- t.Fatalf("expected 2 merged worktrees reaped: %+v", result)
- }
- for _, path := range []string{alphaPath, betaPath} {
- if dirExists(path) {
- t.Fatalf("merged worktree was not removed: %s", path)
- }
- }
- if branchExists(repo, alphaBranch) || branchExists(repo, betaBranch) {
- t.Fatal("merged branches were not deleted")
- }
- if !dirExists(gammaPath) || !branchExists(repo, gammaBranch) {
- t.Fatal("open worktree must be kept")
- }
-}
-
-// control-law: reap-removes-only-terminal-boatstack-workspaces
-// Positive conformance: an explicitly abandoned (ignored) delivery is reclaimed
-// even though its branch is unmerged, because the operator authorized disposal.
-func TestReapReclaimsAbandonedIgnoredDelivery(t *testing.T) {
- ws := autoReapWorkspace()
- repo := workspaceRepo(t, ws)
- branch, path := cutWorktree(t, repo, "delta")
- commitInWorktree(t, path, "delta.txt") // unmerged commit
- writeWorkspaceProjectConfig(t, repo, ws, "delta")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "OPEN"}))
-
- result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if result.ReapedCount != 1 || dirExists(path) || branchExists(repo, branch) {
- t.Fatalf("abandoned delivery was not reclaimed: %+v", result)
- }
-}
-
-// control-law: reap-removes-only-terminal-boatstack-workspaces
-// Negative + bypass conformance: a human-created worktree outside
-// .product-loop/worktrees is never reaped, even when it is merged; a real
-// Boatstack worktree alongside it still is, proving the sweep ran.
-func TestReapSkipsNonBoatstackWorktree(t *testing.T) {
- repo := workspaceRepo(t, autoReapWorkspace())
- manualBranch := "feat/manual"
- manualPath := filepath.Join(repo, "manual-wt")
- workspaceGitDo(t, repo, "worktree", "add", "-b", manualBranch, manualPath)
- realBranch, realPath := cutWorktree(t, repo, "real")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{
- manualBranch: "MERGED", realBranch: "MERGED",
- }))
-
- result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if !dirExists(manualPath) || !branchExists(repo, manualBranch) {
- t.Fatal("non-Boatstack worktree must never be reaped")
- }
- if dirExists(realPath) || branchExists(repo, realBranch) {
- t.Fatal("Boatstack worktree should have been reaped")
- }
- if result.ReapedCount != 1 {
- t.Fatalf("expected exactly one reaped worktree: %+v", result)
- }
-}
-
-// control-law: reap-removes-only-terminal-boatstack-workspaces
-// Negative conformance: an open, unmerged, non-abandoned worktree is never in the
-// reclaimable set — Force overrides discard gates, not terminality.
-func TestReapNeverReapsOpenUnmergedEvenWithForce(t *testing.T) {
- repo := workspaceRepo(t, autoReapWorkspace())
- branch, path := cutWorktree(t, repo, "openpr")
- commitInWorktree(t, path, "openpr.txt")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "OPEN"}))
-
- result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Confirm: true, Force: true})
- if err != nil {
- t.Fatal(err)
- }
- if result.ReapedCount != 0 || !dirExists(path) || !branchExists(repo, branch) {
- t.Fatalf("open unmerged branch must never be reaped, even with force: %+v", result)
- }
-}
-
-// control-law: reap-never-discards-unlanded-work
-// Negative + override conformance: a merged worktree with uncommitted changes is
-// blocked without Force and reclaimed with it.
-func TestReapRefusesDirtyMergedWorktreeWithoutForce(t *testing.T) {
- repo := workspaceRepo(t, autoReapWorkspace())
- branch, path := cutWorktree(t, repo, "dirtymerged")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"}))
- if err := os.WriteFile(filepath.Join(path, "scratch.txt"), []byte("uncommitted\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- blocked, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if blocked.ReapedCount != 0 || !dirExists(path) {
- t.Fatalf("dirty worktree must not be reaped without force: %+v", blocked)
- }
- sawBlocked := false
- for _, candidate := range blocked.Candidates {
- if candidate.Branch == branch && candidate.Action == "blocked" {
- sawBlocked = true
- }
- }
- if !sawBlocked {
- t.Fatalf("dirty worktree should surface as a blocked candidate: %+v", blocked)
- }
-
- forced, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Force: true})
- if err != nil {
- t.Fatal(err)
- }
- if forced.ReapedCount != 1 || dirExists(path) {
- t.Fatalf("force must reclaim the dirty merged worktree: %+v", forced)
- }
-}
-
-// control-law: reap-prompts-before-destroying-in-confirm-mode
-// Positive conformance: confirm mode reports the reclaimable set without removing
-// anything, then reclaims after the confirmation.
-func TestReapNeedsConfirmationInConfirmMode(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace()) // reap defaults to "confirm"
- branch, path := cutWorktree(t, repo, "confirmme")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"}))
-
- pending, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if pending.VerificationStatus != "NEEDS_CONFIRMATION" || pending.ReclaimableCount != 1 || !dirExists(path) {
- t.Fatalf("confirm mode must prompt without removing: %+v", pending)
- }
-
- done, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Confirm: true})
- if err != nil {
- t.Fatal(err)
- }
- if done.VerificationStatus != "VERIFIED" || done.ReapedCount != 1 || dirExists(path) {
- t.Fatalf("confirmed reap must remove the workspace: %+v", done)
- }
-}
-
-// control-law: reap-prompts-before-destroying-in-confirm-mode
-// Negative conformance: reap=off blocks the sweep and removes nothing.
-func TestReapDisabledWhenReapOff(t *testing.T) {
- ws := defaultWorkspace()
- ws.Reap = "off"
- repo := workspaceRepo(t, ws)
- branch, path := cutWorktree(t, repo, "keepme")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"}))
-
- result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "BLOCKED" || !dirExists(path) || !branchExists(repo, branch) {
- t.Fatalf("reap=off must block and remove nothing: %+v", result)
- }
-}
-
-// control-law: reap-removes-only-terminal-boatstack-workspaces
-// Failure-state conformance: reaping from inside a worktree never removes the
-// worktree it is standing in; it is reported as skipped instead.
-func TestReapSkipsCurrentWorktree(t *testing.T) {
- ws := autoReapWorkspace()
- repo := workspaceRepo(t, ws)
- // Commit the installation so the linked worktree carries project.json.
- workspaceGitDo(t, repo, "add", ".product-loop")
- workspaceGitDo(t, repo, "commit", "-m", "install boatstack")
- branch, path := cutWorktree(t, repo, "current")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"}))
-
- result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: path})
- if err != nil {
- t.Fatal(err)
- }
- if !dirExists(path) {
- t.Fatal("reap must never remove the current worktree")
- }
- if result.ReapedCount != 0 {
- t.Fatalf("current worktree must not be reaped: %+v", result)
- }
- skipped := false
- for _, candidate := range result.Candidates {
- if candidate.Branch == branch && candidate.Action == "skipped" {
- skipped = true
- }
- }
- if !skipped {
- t.Fatalf("current worktree should be a skipped candidate: %+v", result)
- }
-}
-
-// control-law: reap-preserves-per-worktree-delivery-isolation
-// Relation conformance: reaping one merged worktree leaves an unrelated open
-// worktree — and thus its per-worktree runtime/delivery state under its own git
-// dir — fully intact and still registered.
-func TestReapPreservesUnrelatedWorktree(t *testing.T) {
- repo := workspaceRepo(t, autoReapWorkspace())
- mergedBranch, mergedPath := cutWorktree(t, repo, "landed")
- openBranch, openPath := cutWorktree(t, repo, "inflight")
- commitInWorktree(t, openPath, "inflight.txt")
- withWorkspaceGh(t, ghStateByBranch(map[string]string{
- mergedBranch: "MERGED", openBranch: "OPEN",
- }))
-
- result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo})
- if err != nil {
- t.Fatal(err)
- }
- if result.ReapedCount != 1 || dirExists(mergedPath) {
- t.Fatalf("the merged worktree should have been reaped: %+v", result)
- }
- if !dirExists(openPath) || !branchExists(repo, openBranch) {
- t.Fatal("reap must not disturb an unrelated worktree")
- }
- if worktreePathForBranch(repo, openBranch) == "" {
- t.Fatal("the unrelated worktree must remain a registered linked worktree")
- }
-}
diff --git a/boatstack/workspace_sync.go b/boatstack/workspace_sync.go
deleted file mode 100644
index 01c70e3..0000000
--- a/boatstack/workspace_sync.go
+++ /dev/null
@@ -1,249 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "path/filepath"
- "strings"
- "time"
-)
-
-const workspaceSyncSchemaVersion = 1
-
-var workspaceSyncNow = time.Now
-
-// WorkspaceSyncOptions identifies one local branch and one remote branch that
-// should become identical. Empty Branch means the branch checked out in Repo.
-type WorkspaceSyncOptions struct {
- Repo string
- Branch string
- Source string
-}
-
-// WorkspaceSync reports the verified result and the Git refs that retain the
-// pre-sync branch and worktree state.
-type WorkspaceSync struct {
- SchemaVersion int `json:"schema_version"`
- Status string `json:"status"` // NO_CHANGE | SYNCED | BLOCKED
- Branch string `json:"branch,omitempty"`
- Source string `json:"source,omitempty"`
- WorktreePath string `json:"worktree_path,omitempty"`
- OldCommit string `json:"old_commit,omitempty"`
- NewCommit string `json:"new_commit,omitempty"`
- RecoveryRef string `json:"recovery_ref,omitempty"`
- CheckpointRef string `json:"checkpoint_ref,omitempty"`
- Reason string `json:"reason"`
-}
-
-func blockedWorkspaceSync(result WorkspaceSync, reason string) WorkspaceSync {
- result.SchemaVersion = workspaceSyncSchemaVersion
- result.Status = "BLOCKED"
- result.Reason = reason
- return result
-}
-
-func normalizeRemoteSource(repo, source string) (string, string, string, error) {
- source = strings.TrimSpace(source)
- source = strings.TrimPrefix(source, "refs/remotes/")
- parts := strings.SplitN(source, "/", 2)
- if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
- return "", "", "", fmt.Errorf("source must name a remote branch such as origin/main")
- }
- remote, branch := parts[0], parts[1]
- ref := "refs/remotes/" + remote + "/" + branch
- if _, err := workspaceGit(repo, "check-ref-format", ref); err != nil {
- return "", "", "", fmt.Errorf("source %q is not a valid remote branch", source)
- }
- if _, err := workspaceGit(repo, "remote", "get-url", remote); err != nil {
- return "", "", "", fmt.Errorf("remote %q does not exist", remote)
- }
- return remote, branch, ref, nil
-}
-
-func activeDeliveryOwningBranch(repo, branch string) (string, error) {
- paths := []string{repo}
- if output, err := workspaceGit(repo, "worktree", "list", "--porcelain"); err == nil {
- paths = paths[:0]
- for _, line := range strings.Split(output, "\n") {
- if strings.HasPrefix(line, "worktree ") {
- paths = append(paths, strings.TrimSpace(strings.TrimPrefix(line, "worktree ")))
- }
- }
- }
- seen := map[string]bool{}
- for _, path := range paths {
- active, err := ActiveManagedDeliveries(path)
- if err != nil {
- return "", err
- }
- for _, feature := range active {
- key := path + "\x00" + feature
- if seen[key] {
- continue
- }
- seen[key] = true
- state, loadErr := LoadDeliveryState(path, feature)
- if loadErr != nil {
- return "", loadErr
- }
- if stateMatchesBranch(state, branch) {
- return feature, nil
- }
- }
- }
- return "", nil
-}
-
-func syncRecoveryRefs(branch, oldCommit string) (string, string) {
- now := workspaceSyncNow().UTC()
- fingerprint := SHA256Bytes([]byte(branch + "\x00" + oldCommit + "\x00" + now.Format(time.RFC3339Nano)))
- base := "refs/boatstack/recovery/workspace-sync/" + now.Format("20060102T150405Z") + "-" + fingerprint[:12]
- return base + "/head", base + "/worktree"
-}
-
-func rollbackWorkspaceCheckpoint(worktreePath, recoveryRef string, checkpointCreated bool) {
- if checkpointCreated {
- _, _ = workspaceGit(worktreePath, "stash", "pop", "--index")
- }
- if recoveryRef != "" {
- _, _ = workspaceGit(worktreePath, "update-ref", "-d", recoveryRef)
- }
-}
-
-// SyncWorkspace aligns one local branch with a freshly fetched remote branch.
-// It creates recovery refs before changing the branch or its owning worktree.
-func SyncWorkspace(options WorkspaceSyncOptions) (WorkspaceSync, error) {
- result := WorkspaceSync{SchemaVersion: workspaceSyncSchemaVersion}
- repo, err := ResolveRepository(options.Repo)
- if err != nil {
- return blockedWorkspaceSync(result, err.Error()), nil
- }
-
- branch := strings.TrimSpace(options.Branch)
- if branch == "" {
- branch, _ = workspaceGit(repo, "branch", "--show-current")
- branch = strings.TrimSpace(branch)
- }
- if branch == "" {
- return blockedWorkspaceSync(result, "A named branch is required when the current worktree is detached."), nil
- }
- result.Branch = branch
- if _, err := workspaceGit(repo, "check-ref-format", "refs/heads/"+branch); err != nil {
- return blockedWorkspaceSync(result, fmt.Sprintf("Branch %q is not a valid local branch.", branch)), nil
- }
-
- remote, remoteBranch, sourceRef, err := normalizeRemoteSource(repo, options.Source)
- if err != nil {
- return blockedWorkspaceSync(result, err.Error()), nil
- }
- result.Source = strings.TrimPrefix(sourceRef, "refs/remotes/")
-
- if owner, ownerErr := activeDeliveryOwningBranch(repo, branch); ownerErr != nil {
- return blockedWorkspaceSync(result, "Boatstack could not verify managed delivery ownership: "+ownerErr.Error()), nil
- } else if owner != "" {
- return blockedWorkspaceSync(result, fmt.Sprintf("Branch %q belongs to active managed delivery %q; use repair instead.", branch, owner)), nil
- }
-
- localRef := "refs/heads/" + branch
- oldCommit, err := workspaceGit(repo, "rev-parse", "--verify", localRef+"^{commit}")
- if err != nil {
- return blockedWorkspaceSync(result, fmt.Sprintf("Local branch %q does not exist.", branch)), nil
- }
- result.OldCommit = strings.TrimSpace(oldCommit)
-
- if _, err := workspaceGit(repo, "fetch", remote, "+refs/heads/"+remoteBranch+":"+sourceRef); err != nil {
- return blockedWorkspaceSync(result, "Could not fetch the requested source: "+err.Error()), nil
- }
- newCommit, err := workspaceGit(repo, "rev-parse", "--verify", sourceRef+"^{commit}")
- if err != nil {
- return blockedWorkspaceSync(result, fmt.Sprintf("Fetched source %q does not resolve to a commit.", result.Source)), nil
- }
- result.NewCommit = strings.TrimSpace(newCommit)
-
- worktreePath := worktreePathForBranch(repo, branch)
- if worktreePath != "" {
- absolute, absErr := filepath.Abs(worktreePath)
- if absErr != nil {
- return blockedWorkspaceSync(result, "Could not resolve the branch's owning worktree."), nil
- }
- result.WorktreePath = filepath.Clean(absolute)
- }
- dirty := ""
- if result.WorktreePath != "" {
- dirty, _ = workspaceGit(result.WorktreePath, "status", "--porcelain=v1", "--untracked-files=all")
- }
- if result.OldCommit == result.NewCommit && strings.TrimSpace(dirty) == "" {
- result.Status = "NO_CHANGE"
- result.Reason = fmt.Sprintf("Branch %q already matches %s and its worktree is clean.", branch, result.Source)
- return result, nil
- }
-
- recoveryRef, checkpointRef := syncRecoveryRefs(branch, result.OldCommit)
- if _, err := workspaceGit(repo, "update-ref", recoveryRef, result.OldCommit); err != nil {
- return blockedWorkspaceSync(result, "Could not create the branch recovery reference: "+err.Error()), nil
- }
- result.RecoveryRef = recoveryRef
- if verified, verifyErr := workspaceGit(repo, "rev-parse", "--verify", recoveryRef+"^{commit}"); verifyErr != nil || strings.TrimSpace(verified) != result.OldCommit {
- _, _ = workspaceGit(repo, "update-ref", "-d", recoveryRef)
- result.RecoveryRef = ""
- return blockedWorkspaceSync(result, "Could not verify the branch recovery reference."), nil
- }
-
- checkpointCreated := false
- if strings.TrimSpace(dirty) != "" {
- beforeStash, _ := workspaceGit(result.WorktreePath, "rev-parse", "--verify", "refs/stash^{commit}")
- label := fmt.Sprintf("Boatstack workspace-sync %s from %s", branch, result.OldCommit)
- if _, err := workspaceGit(result.WorktreePath, "stash", "push", "--include-untracked", "--message", label); err != nil {
- rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, false)
- result.RecoveryRef = ""
- return blockedWorkspaceSync(result, "Could not checkpoint the dirty worktree: "+err.Error()), nil
- }
- stashCommit, stashErr := workspaceGit(result.WorktreePath, "rev-parse", "--verify", "refs/stash^{commit}")
- checkpointCreated = stashErr == nil && strings.TrimSpace(stashCommit) != "" && strings.TrimSpace(stashCommit) != strings.TrimSpace(beforeStash)
- if !checkpointCreated {
- rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, false)
- result.RecoveryRef = ""
- return blockedWorkspaceSync(result, "Could not verify the dirty-worktree checkpoint."), nil
- }
- if _, err := workspaceGit(repo, "update-ref", checkpointRef, strings.TrimSpace(stashCommit)); err != nil {
- rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, true)
- result.RecoveryRef = ""
- return blockedWorkspaceSync(result, "Could not preserve the dirty-worktree checkpoint: "+err.Error()), nil
- }
- result.CheckpointRef = checkpointRef
- if verified, verifyErr := workspaceGit(repo, "rev-parse", "--verify", checkpointRef+"^{commit}"); verifyErr != nil || strings.TrimSpace(verified) != strings.TrimSpace(stashCommit) {
- _, _ = workspaceGit(repo, "update-ref", "-d", checkpointRef)
- result.CheckpointRef = ""
- rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, true)
- result.RecoveryRef = ""
- return blockedWorkspaceSync(result, "Could not verify the dirty-worktree recovery reference."), nil
- }
- }
-
- if result.WorktreePath != "" {
- if _, err := workspaceGit(result.WorktreePath, "reset", "--hard", result.NewCommit); err != nil {
- return blockedWorkspaceSync(result, "The recovery checkpoint was preserved, but branch alignment failed: "+err.Error()), nil
- }
- } else if _, err := workspaceGit(repo, "update-ref", localRef, result.NewCommit, result.OldCommit); err != nil {
- return blockedWorkspaceSync(result, "The recovery reference was preserved, but branch alignment failed: "+err.Error()), nil
- }
-
- actual, actualErr := workspaceGit(repo, "rev-parse", "--verify", localRef+"^{commit}")
- if actualErr != nil || strings.TrimSpace(actual) != result.NewCommit {
- return blockedWorkspaceSync(result, "Branch alignment could not be verified; recovery references were preserved."), nil
- }
- if result.WorktreePath != "" {
- status, statusErr := workspaceGit(result.WorktreePath, "status", "--porcelain=v1", "--untracked-files=all")
- if statusErr != nil || strings.TrimSpace(status) != "" {
- return blockedWorkspaceSync(result, "The target worktree is not clean after alignment; recovery references were preserved."), nil
- }
- }
- if checkpointCreated {
- if _, err := workspaceGit(repo, "rev-parse", "--verify", result.CheckpointRef+"^{commit}"); err != nil {
- return blockedWorkspaceSync(result, "The worktree checkpoint became unreadable after alignment."), nil
- }
- }
-
- result.Status = "SYNCED"
- result.Reason = fmt.Sprintf("Branch %q now matches %s; prior state is retained under %s.", branch, result.Source, result.RecoveryRef)
- return result, nil
-}
diff --git a/boatstack/workspace_sync_test.go b/boatstack/workspace_sync_test.go
deleted file mode 100644
index 09567bf..0000000
--- a/boatstack/workspace_sync_test.go
+++ /dev/null
@@ -1,265 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func workspaceSyncRepo(t *testing.T) (string, string) {
- t.Helper()
- repo := workspaceRepo(t, defaultWorkspace())
- workspaceGitDo(t, repo, "add", ".product-loop/project.json")
- workspaceGitDo(t, repo, "commit", "-m", "configure workspace")
- remote := filepath.Join(t.TempDir(), "remote.git")
- if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
- t.Fatalf("init remote: %v: %s", err, output)
- }
- workspaceGitDo(t, repo, "remote", "add", "origin", remote)
- workspaceGitDo(t, repo, "push", "-u", "origin", "main")
- return repo, remote
-}
-
-func advanceWorkspaceRemote(t *testing.T, repo string) (string, string) {
- t.Helper()
- oldCommit := runGit(t, repo, "rev-parse", "HEAD")
- if err := os.WriteFile(filepath.Join(repo, "remote.txt"), []byte("remote\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, repo, "add", "remote.txt")
- workspaceGitDo(t, repo, "commit", "-m", "advance remote")
- newCommit := runGit(t, repo, "rev-parse", "HEAD")
- workspaceGitDo(t, repo, "push", "origin", "main")
- workspaceGitDo(t, repo, "reset", "--hard", oldCommit)
- return oldCommit, newCommit
-}
-
-func TestSyncWorkspaceNoChangeCreatesNoRecoveryState(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "NO_CHANGE" || result.RecoveryRef != "" || result.CheckpointRef != "" {
- t.Fatalf("unexpected no-change result: %+v", result)
- }
- if refs := runGit(t, repo, "for-each-ref", "--format=%(refname)", "refs/boatstack/recovery/workspace-sync"); refs != "" {
- t.Fatalf("no-change sync created recovery refs: %s", refs)
- }
-}
-
-func TestSyncWorkspaceCheckpointsDirtyWorktreeAndAlignsBranch(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- _, newCommit := advanceWorkspaceRemote(t, repo)
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty tracked\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, "untracked.txt"), []byte("dirty untracked\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, repo, "add", "README.md")
-
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "SYNCED" || result.RecoveryRef == "" || result.CheckpointRef == "" {
- t.Fatalf("unexpected sync result: %+v", result)
- }
- if got := runGit(t, repo, "rev-parse", "main"); got != newCommit {
- t.Fatalf("main=%s, want %s", got, newCommit)
- }
- if status := runGit(t, repo, "status", "--porcelain=v1", "--untracked-files=all"); status != "" {
- t.Fatalf("target worktree is dirty after sync: %s", status)
- }
- workspaceGitDo(t, repo, "stash", "apply", "--index", result.CheckpointRef)
- if value, err := os.ReadFile(filepath.Join(repo, "README.md")); err != nil || strings.ReplaceAll(string(value), "\r\n", "\n") != "dirty tracked\n" {
- t.Fatalf("tracked checkpoint was not restorable: %q %v", value, err)
- }
- if value, err := os.ReadFile(filepath.Join(repo, "untracked.txt")); err != nil || strings.ReplaceAll(string(value), "\r\n", "\n") != "dirty untracked\n" {
- t.Fatalf("untracked checkpoint was not restorable: %q %v", value, err)
- }
-}
-
-func TestSyncWorkspaceReplacesDivergedBranchAndRetainsOldHead(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- _, newCommit := advanceWorkspaceRemote(t, repo)
- if err := os.WriteFile(filepath.Join(repo, "local.txt"), []byte("local\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, repo, "add", "local.txt")
- workspaceGitDo(t, repo, "commit", "-m", "local divergence")
- localCommit := runGit(t, repo, "rev-parse", "HEAD")
-
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "SYNCED" || runGit(t, repo, "rev-parse", "main") != newCommit {
- t.Fatalf("diverged branch was not synchronized: %+v", result)
- }
- if got := runGit(t, repo, "rev-parse", result.RecoveryRef); got != localCommit {
- t.Fatalf("recovery ref=%s, want old head %s", got, localCommit)
- }
-}
-
-func TestSyncWorkspaceUsesNamedBranchOwningWorktree(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- caller := filepath.Join(t.TempDir(), "caller")
- workspaceGitDo(t, repo, "worktree", "add", "-b", "caller", caller, "main")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty main\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(caller, "caller.txt"), []byte("caller remains dirty\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: caller, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- ownerInfo, ownerErr := os.Stat(result.WorktreePath)
- repoInfo, repoErr := os.Stat(repo)
- if result.Status != "SYNCED" || ownerErr != nil || repoErr != nil || !os.SameFile(ownerInfo, repoInfo) {
- t.Fatalf("sync did not target main's owning worktree: %+v", result)
- }
- if _, err := os.Stat(filepath.Join(caller, "caller.txt")); err != nil {
- t.Fatalf("caller worktree was modified: %v", err)
- }
- if status := runGit(t, repo, "status", "--porcelain=v1", "--untracked-files=all"); status != "" {
- t.Fatalf("main worktree is dirty after sync: %s", status)
- }
-}
-
-func TestSyncWorkspaceBlocksActiveManagedDelivery(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- state := DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion,
- Feature: "active-main",
- PlanLockHash: strings.Repeat("a", 64),
- ActiveIndex: 0,
- Mode: "NORMAL",
- Slices: []DeliverySlice{{
- ID: "delivery", Title: "Delivery", Status: "BUILD", HeadBranch: "main",
- }},
- }
- if err := saveDeliveryState(repo, state); err != nil {
- t.Fatal(err)
- }
- before := runGit(t, repo, "rev-parse", "main")
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "BLOCKED" || !strings.Contains(result.Reason, "use repair instead") {
- t.Fatalf("active delivery was not blocked: %+v", result)
- }
- if after := runGit(t, repo, "rev-parse", "main"); after != before {
- t.Fatalf("blocked sync changed main from %s to %s", before, after)
- }
-}
-
-func TestSyncWorkspaceFailuresDoNotChangeTargetState(t *testing.T) {
- t.Run("fetch", func(t *testing.T) {
- repo, remote := workspaceSyncRepo(t)
- before := runGit(t, repo, "rev-parse", "main")
- if err := os.Rename(remote, remote+".missing"); err != nil {
- t.Fatal(err)
- }
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "main") != before {
- t.Fatalf("fetch failure changed target state: %+v", result)
- }
- })
-
- t.Run("checkpoint", func(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- beforeHead := runGit(t, repo, "rev-parse", "main")
- oldGit := workspaceGit
- workspaceGit = func(path string, arguments ...string) (string, error) {
- if len(arguments) >= 2 && arguments[0] == "stash" && arguments[1] == "push" {
- return "", fmt.Errorf("injected checkpoint failure")
- }
- return gitCommand(path, arguments...)
- }
- t.Cleanup(func() { workspaceGit = oldGit })
-
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "main") != beforeHead {
- t.Fatalf("checkpoint failure changed target ref: %+v", result)
- }
- if value, _ := os.ReadFile(filepath.Join(repo, "README.md")); string(value) != "dirty\n" {
- t.Fatalf("checkpoint failure changed worktree: %q", value)
- }
- if refs := runGit(t, repo, "for-each-ref", "--format=%(refname)", "refs/boatstack/recovery/workspace-sync"); refs != "" {
- t.Fatalf("checkpoint failure retained recovery refs: %s", refs)
- }
- })
-
- t.Run("checkpoint verification preserves prior stash", func(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- if err := os.WriteFile(filepath.Join(repo, "prior.txt"), []byte("prior\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, repo, "stash", "push", "--include-untracked", "--message", "prior user stash")
- priorStash := runGit(t, repo, "rev-parse", "refs/stash")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("current dirty\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- oldGit := workspaceGit
- workspaceGit = func(path string, arguments ...string) (string, error) {
- if len(arguments) >= 2 && arguments[0] == "stash" && arguments[1] == "push" {
- return "injected success without checkpoint", nil
- }
- return gitCommand(path, arguments...)
- }
- t.Cleanup(func() { workspaceGit = oldGit })
-
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "refs/stash") != priorStash {
- t.Fatalf("checkpoint verification disturbed the prior stash: %+v", result)
- }
- if value, _ := os.ReadFile(filepath.Join(repo, "README.md")); string(value) != "current dirty\n" {
- t.Fatalf("checkpoint verification changed current worktree: %q", value)
- }
- })
-
- t.Run("detached", func(t *testing.T) {
- repo, _ := workspaceSyncRepo(t)
- workspaceGitDo(t, repo, "checkout", "--detach")
- before := runGit(t, repo, "rev-parse", "HEAD")
- result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Source: "origin/main"})
- if err != nil {
- t.Fatal(err)
- }
- if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "HEAD") != before {
- t.Fatalf("detached target was not safely blocked: %+v", result)
- }
- })
-}
-
-func TestSyncRecoveryRefsAreUnique(t *testing.T) {
- oldNow := workspaceSyncNow
- workspaceSyncNow = func() time.Time { return time.Date(2026, 7, 23, 12, 0, 0, 1, time.UTC) }
- t.Cleanup(func() { workspaceSyncNow = oldNow })
- head, worktree := syncRecoveryRefs("main", strings.Repeat("a", 40))
- if head == worktree || !strings.HasSuffix(head, "/head") || !strings.HasSuffix(worktree, "/worktree") {
- t.Fatalf("unexpected recovery refs: %s %s", head, worktree)
- }
-}
diff --git a/boatstack/workspace_test.go b/boatstack/workspace_test.go
deleted file mode 100644
index 73c97f0..0000000
--- a/boatstack/workspace_test.go
+++ /dev/null
@@ -1,497 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func workspaceGitDo(t *testing.T, dir string, arguments ...string) {
- t.Helper()
- out, err := exec.Command("git", append([]string{"-C", dir}, arguments...)...).CombinedOutput()
- if err != nil {
- t.Fatalf("git %s: %v: %s", strings.Join(arguments, " "), err, out)
- }
-}
-
-// workspaceRepo builds a real git repository with one commit on main and a
-// Boatstack project.json carrying the given workspace policy.
-func workspaceRepo(t *testing.T, ws Workspace) string {
- t.Helper()
- repo := t.TempDir()
- workspaceGitDo(t, repo, "init", "-b", "main")
- workspaceGitDo(t, repo, "config", "user.name", "Boatstack Test")
- workspaceGitDo(t, repo, "config", "user.email", "boatstack@example.test")
- if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("test\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, repo, "add", "README.md")
- workspaceGitDo(t, repo, "commit", "-m", "initial")
- config := ProjectConfig{
- SchemaVersion: 1,
- Project: Project{Name: "test", DefaultBranch: "main", Commands: map[string]string{"test": "go test ./..."}},
- Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true},
- Workspace: ws,
- Adapters: []string{"cursor"},
- }
- raw, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), raw, 0o644); err != nil {
- t.Fatal(err)
- }
- return repo
-}
-
-func withWorkspaceGh(t *testing.T, fn func(string, ...string) (string, error)) {
- t.Helper()
- old := workspaceGh
- workspaceGh = fn
- t.Cleanup(func() { workspaceGh = old })
-}
-
-func ghState(state string) func(string, ...string) (string, error) {
- return func(string, ...string) (string, error) { return state, nil }
-}
-
-func ghUnavailable() func(string, ...string) (string, error) {
- return func(string, ...string) (string, error) { return "", fmt.Errorf("gh: not found") }
-}
-
-func defaultWorkspace() Workspace {
- return Workspace{Enabled: true, Mode: "worktree", Cleanup: "confirm", CleanupAfter: "merge"}
-}
-
-func TestResolveWorkspaceAppliesDefaults(t *testing.T) {
- got := resolveWorkspace(Workspace{Enabled: true})
- if got.Mode != "worktree" || got.Cleanup != "confirm" || got.CleanupAfter != "merge" {
- t.Fatalf("unexpected resolved defaults: %+v", got)
- }
- if resolveWorkspace(Workspace{}).Enabled {
- t.Fatal("empty workspace must resolve to disabled")
- }
- explicit := resolveWorkspace(Workspace{Enabled: true, Mode: "branch", Cleanup: "auto", CleanupAfter: "ship"})
- if explicit.Mode != "branch" || explicit.Cleanup != "auto" || explicit.CleanupAfter != "ship" {
- t.Fatalf("explicit values overwritten: %+v", explicit)
- }
-}
-
-func TestValidateWorkspaceConfig(t *testing.T) {
- valid := []Workspace{
- {},
- {Enabled: true},
- {Mode: "worktree", Cleanup: "confirm", CleanupAfter: "merge"},
- {Mode: "branch", Cleanup: "off", CleanupAfter: "ship"},
- {Cleanup: "auto"},
- }
- for _, ws := range valid {
- if err := validateWorkspaceConfig(ws); err != nil {
- t.Fatalf("expected %+v valid: %v", ws, err)
- }
- }
- invalid := []Workspace{
- {Mode: "detached"},
- {Cleanup: "prompt"},
- {CleanupAfter: "review"},
- }
- for _, ws := range invalid {
- if err := validateWorkspaceConfig(ws); err == nil {
- t.Fatalf("expected %+v invalid", ws)
- }
- }
-}
-
-func TestCutFeatureWorkspaceWorktreeMode(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "add-widget"})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || !result.Created || result.Branch != "feat/add-widget" || result.Mode != "worktree" {
- t.Fatalf("unexpected cut: %+v", result)
- }
- if result.WorktreePath == "" {
- t.Fatal("worktree mode must report a worktree path")
- }
- if _, err := os.Stat(result.WorktreePath); err != nil {
- t.Fatalf("worktree directory missing: %v", err)
- }
- if !branchExists(repo, "feat/add-widget") {
- t.Fatal("branch was not created")
- }
-}
-
-func TestCutFeatureWorkspaceBranchMode(t *testing.T) {
- ws := defaultWorkspace()
- ws.Mode = "branch"
- repo := workspaceRepo(t, ws)
- commitWorkspaceController(t, repo)
- result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Branch: "feat/inline"})
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || result.Mode != "branch" || result.WorktreePath != "" {
- t.Fatalf("unexpected branch-mode cut: %+v", result)
- }
- current, _ := gitCommand(repo, "branch", "--show-current")
- if strings.TrimSpace(current) != "feat/inline" {
- t.Fatalf("branch mode did not switch to feature branch, on %q", current)
- }
-}
-
-func TestCutFeatureWorkspaceAdoptsExactBaseBranch(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- workspaceGitDo(t, repo, "branch", "feat/dupe")
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "dupe"})
- if result.VerificationStatus != "VERIFIED" || result.Outcome != "adopted" || result.WorktreePath == "" {
- t.Fatalf("expected exact-base branch adoption: %+v", result)
- }
-}
-
-func TestCutFeatureWorkspaceRefusesBaseBranch(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Branch: "main"})
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "base branch") {
- t.Fatalf("expected base-branch block: %+v", result)
- }
-}
-
-func TestCutFeatureWorkspaceDisabled(t *testing.T) {
- repo := workspaceRepo(t, Workspace{Enabled: false})
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "x"})
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "disabled") {
- t.Fatalf("expected disabled block: %+v", result)
- }
-}
-
-func TestWorkspaceMergeStatusPrefersGh(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- withWorkspaceGh(t, ghState("MERGED"))
- if merged, source := workspaceMergeStatus(repo, "feat/x", "main"); !merged || source != "gh" {
- t.Fatalf("gh MERGED not honored: merged=%v source=%s", merged, source)
- }
- withWorkspaceGh(t, ghState("OPEN"))
- if merged, source := workspaceMergeStatus(repo, "feat/x", "main"); merged || source != "gh" {
- t.Fatalf("gh OPEN not honored: merged=%v source=%s", merged, source)
- }
-}
-
-func TestWorkspaceMergeStatusFallsBackToGit(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- withWorkspaceGh(t, ghUnavailable())
- // Bare ancestry is not publication evidence: a freshly cut branch at main
- // remains active and cannot become cleanup-ready.
- workspaceGitDo(t, repo, "branch", "feat/landed")
- if merged, source := workspaceMergeStatus(repo, "feat/landed", "main"); merged || source != "unpublished" {
- t.Fatalf("bare ancestry created false completion: merged=%v source=%s", merged, source)
- }
- // Once durable delivery publication exists, ancestry may confirm landing.
- writeCompletedDelivery(t, repo, "landed", "feat/landed")
- if merged, source := workspaceMergeStatus(repo, "feat/landed", "main"); !merged || source != "git-after-publication" {
- t.Fatalf("published ancestry did not confirm landing: merged=%v source=%s", merged, source)
- }
- // Not merged: branch has a commit main does not contain.
- workspaceGitDo(t, repo, "switch", "-c", "feat/ahead")
- if err := os.WriteFile(filepath.Join(repo, "ahead.txt"), []byte("x\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, repo, "add", "ahead.txt")
- workspaceGitDo(t, repo, "commit", "-m", "ahead")
- workspaceGitDo(t, repo, "switch", "main")
- if merged, _ := workspaceMergeStatus(repo, "feat/ahead", "main"); merged {
- t.Fatal("branch with unmerged commit reported as merged")
- }
-}
-
-func TestCleanupBlocksWhenNotMerged(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "open-feature"}); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("OPEN"))
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/open-feature", Confirm: true})
- if result.VerificationStatus != "BLOCKED" || result.Merged || !strings.Contains(result.Reason, "open pull request") {
- t.Fatalf("expected not-merged block: %+v", result)
- }
- if !branchExists(repo, "feat/open-feature") {
- t.Fatal("blocked cleanup must not delete the branch")
- }
-}
-
-func TestCleanupNeedsConfirmation(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "ready"}); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("MERGED"))
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/ready", Confirm: false})
- if result.VerificationStatus != "NEEDS_CONFIRMATION" {
- t.Fatalf("expected confirmation gate: %+v", result)
- }
- if !branchExists(repo, "feat/ready") {
- t.Fatal("confirmation gate must not delete anything")
- }
-}
-
-func TestCleanupRemovesMergedWorktree(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "done"})
- if err != nil {
- t.Fatal(err)
- }
- // A committed change in the worktree keeps it clean but non-empty.
- if err := os.WriteFile(filepath.Join(cut.WorktreePath, "done.txt"), []byte("done\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, cut.WorktreePath, "add", "done.txt")
- workspaceGitDo(t, cut.WorktreePath, "commit", "-m", "done")
- withWorkspaceGh(t, ghState("MERGED"))
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/done", Confirm: true})
- if result.VerificationStatus != "VERIFIED" || !result.WorktreeRemoved || !result.BranchDeleted {
- t.Fatalf("expected full cleanup: %+v", result)
- }
- if _, err := os.Stat(cut.WorktreePath); !os.IsNotExist(err) {
- t.Fatal("worktree directory was not removed")
- }
- if branchExists(repo, "feat/done") {
- t.Fatal("branch was not deleted")
- }
-}
-
-func TestCleanupDirtyWorktreeBlocked(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "dirty"})
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(cut.WorktreePath, "wip.txt"), []byte("wip\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("MERGED"))
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/dirty", Confirm: true})
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "uncommitted") {
- t.Fatalf("expected dirty block: %+v", result)
- }
- if _, err := os.Stat(cut.WorktreePath); err != nil {
- t.Fatal("blocked cleanup must not remove a dirty worktree")
- }
-}
-
-func TestCleanupForceDiscardsDirtyUnmerged(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "abandon"})
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(cut.WorktreePath, "wip.txt"), []byte("wip\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("OPEN"))
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/abandon", Force: true})
- if result.VerificationStatus != "VERIFIED" || !result.WorktreeRemoved || !result.BranchDeleted {
- t.Fatalf("force cleanup should discard everything: %+v", result)
- }
-}
-
-func TestCleanupDisabled(t *testing.T) {
- ws := defaultWorkspace()
- ws.Cleanup = "off"
- repo := workspaceRepo(t, ws)
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "keep"}); err != nil {
- t.Fatal(err)
- }
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/keep", Confirm: true})
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "disabled") {
- t.Fatalf("expected cleanup-off block: %+v", result)
- }
-}
-
-func TestCleanupNothingToClean(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/ghost", Confirm: true})
- if result.VerificationStatus != "VERIFIED" || !strings.Contains(result.Reason, "nothing to clean") {
- t.Fatalf("expected idempotent no-op: %+v", result)
- }
-}
-
-func TestCleanupAutoModeSkipsConfirmation(t *testing.T) {
- ws := defaultWorkspace()
- ws.Cleanup = "auto"
- repo := workspaceRepo(t, ws)
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "auto-clean"}); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("MERGED"))
- result, _ := CleanupFeatureWorkspace(WorkspaceCleanupOptions{Repo: repo, Branch: "feat/auto-clean", Confirm: false})
- if result.VerificationStatus != "VERIFIED" || !result.BranchDeleted {
- t.Fatalf("auto cleanup should not require confirmation: %+v", result)
- }
-}
-
-func writeApprovedFeature(t *testing.T, repo, feature string) {
- t.Helper()
- dir := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "plan.md"), []byte("# Plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "approval.md"), []byte("# Approval\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestResolveNextRoutesToWorkspaceCutWhenApprovedOnBase(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- writeApprovedFeature(t, repo, "newthing")
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "APPROVED" || status.NextOperation != "workspace-cut" {
- t.Fatalf("expected workspace-cut routing: %+v", status)
- }
-}
-
-func TestResolveNextRoutesSourceCheckoutToExistingWorkspace(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- writeApprovedFeature(t, repo, "cutdone")
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "cutdone"})
- if err != nil || cut.VerificationStatus != "BLOCKED" {
- // The dummy historical plan is intentionally invalid. The branch/worktree
- // was rolled back, proving invalid packages never cross the boundary.
- t.Fatalf("invalid package should block and roll back: %+v (%v)", cut, err)
- }
- workspaceGitDo(t, repo, "branch", "feat/cutdone")
- if _, err := workspaceGit(repo, "worktree", "add", filepath.Join(repo, ".product-loop", "worktrees", "cutdone"), "feat/cutdone"); err != nil {
- t.Fatal(err)
- }
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.NextOperation != "workspace-cut" {
- t.Fatalf("source checkout must route into the existing workspace: %+v", status)
- }
-}
-
-func TestResolveNextApprovedBuildsWhenWorkspaceDisabled(t *testing.T) {
- repo := workspaceRepo(t, Workspace{Enabled: false})
- writeApprovedFeature(t, repo, "plainfeat")
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.NextOperation != "build" {
- t.Fatalf("disabled workspace must go straight to build: %+v", status)
- }
-}
-
-func writeCompletedDelivery(t *testing.T, repo, feature, headBranch string) {
- t.Helper()
- dir := filepath.Join(repo, ".product-loop", "features", feature)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- lockPath := filepath.Join(dir, "plan.lock.json")
- if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- hash, err := SHA256File(lockPath)
- if err != nil {
- t.Fatal(err)
- }
- if err := saveDeliveryState(repo, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: hash,
- ActiveIndex: 1,
- Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "PUBLISHED", HeadBranch: headBranch, PRURL: "https://example.invalid/pr/1"}},
- }); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestResolveNextRoutesToWorkspaceCleanupAfterPublication(t *testing.T) {
- // With reap disabled, the merge checkpoint falls back to the single-feature
- // workspace-cleanup surface for the just-merged worktree.
- ws := defaultWorkspace()
- ws.Reap = "off"
- repo := workspaceRepo(t, ws)
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "shipped"}); err != nil {
- t.Fatal(err)
- }
- writeCompletedDelivery(t, repo, "shipped", "feat/shipped")
- withRecoveryGh(t, recoveryPR("MERGED", "feat/shipped", "head"))
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "FEATURE_COMPLETE" || status.NextOperation != "workspace-cleanup" {
- t.Fatalf("expected cleanup routing: %+v", status)
- }
-}
-
-func TestResolveNextFeatureCompleteStaysNoneWithoutWorktree(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- writeCompletedDelivery(t, repo, "shipped", "feat/no-worktree")
- withRecoveryGh(t, recoveryPR("MERGED", "feat/no-worktree", "head"))
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.ObservedStage != "FEATURE_COMPLETE" || status.NextOperation != "none" {
- t.Fatalf("expected none without a live worktree: %+v", status)
- }
-}
-
-func TestResolveNextFeatureCompleteStaysNoneWhenWorkspaceDisabled(t *testing.T) {
- repo := workspaceRepo(t, Workspace{Enabled: false})
- // A worktree exists on disk, but management is off, so cleanup is not surfaced.
- workspaceGitDo(t, repo, "worktree", "add", "-b", "feat/manual", filepath.Join(repo, "wt-manual"))
- writeCompletedDelivery(t, repo, "shipped", "feat/manual")
- withRecoveryGh(t, recoveryPR("MERGED", "feat/manual", "head"))
- status, err := ResolveNext(repo, "")
- if err != nil {
- t.Fatal(err)
- }
- if status.NextOperation != "none" {
- t.Fatalf("disabled workspace must not route to cleanup: %+v", status)
- }
-}
-
-func TestFeatureWorkspaceStatus(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "reportable"}); err != nil {
- t.Fatal(err)
- }
- withWorkspaceGh(t, ghState("OPEN"))
- open, err := FeatureWorkspaceStatus(repo, "feat/reportable")
- if err != nil {
- t.Fatal(err)
- }
- if !open.Exists || open.Merged || open.CleanupDue {
- t.Fatalf("open workspace status wrong: %+v", open)
- }
- withWorkspaceGh(t, ghState("MERGED"))
- merged, err := FeatureWorkspaceStatus(repo, "feat/reportable")
- if err != nil {
- t.Fatal(err)
- }
- if !merged.Exists || !merged.Merged || !merged.CleanupDue {
- t.Fatalf("merged workspace status wrong: %+v", merged)
- }
- missing, err := FeatureWorkspaceStatus(repo, "feat/never")
- if err != nil {
- t.Fatal(err)
- }
- if missing.Exists {
- t.Fatalf("missing workspace reported as existing: %+v", missing)
- }
-}
diff --git a/boatstack/workspace_transition_conformance_test.go b/boatstack/workspace_transition_conformance_test.go
deleted file mode 100644
index d17d3b2..0000000
--- a/boatstack/workspace_transition_conformance_test.go
+++ /dev/null
@@ -1,469 +0,0 @@
-package boatstack
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// Boundary: validated plan package -> feature workspace.
-// Control law: the transition preserves the exact plan fingerprint, repository
-// identity, and fresh base branch or leaves the source authoritative unchanged.
-
-func addWorkspaceOrigin(t *testing.T, repo string) string {
- t.Helper()
- remote := filepath.Join(t.TempDir(), "origin.git")
- if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
- t.Fatalf("git init --bare: %v: %s", err, output)
- }
- workspaceGitDo(t, repo, "remote", "add", "origin", remote)
- workspaceGitDo(t, repo, "push", "-u", "origin", "main")
- return remote
-}
-
-func commitWorkspaceController(t *testing.T, repo string) {
- t.Helper()
- workspaceGitDo(t, repo, "add", ".product-loop/project.json")
- workspaceGitDo(t, repo, "commit", "-m", "install controller fixture")
-}
-
-func writeWorkspacePlanPackage(t *testing.T, repo, feature string) (string, string) {
- t.Helper()
- directory := WorkspaceFor(repo).FeatureDir(feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Source plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Feature spec\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = feature
- planPath := filepath.Join(directory, "plan.md")
- writeMarkdownPlan(t, planPath, plan, true)
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- return planPath, check.Fingerprint
-}
-
-func writeWorkspaceSchema3Package(t *testing.T, repo, feature string) (string, string) {
- t.Helper()
- directory := WorkspaceFor(repo).FeatureDir(feature)
- if err := os.MkdirAll(directory, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Source plan\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Feature spec\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["schema_version"] = float64(3)
- plan["feature_id"] = feature
- plan["architecture_facts"] = []any{}
- plan["architecture_unknowns"] = []any{}
- task := plan["tasks"].([]any)[0].(map[string]any)
- task["requires_facts"] = []any{}
- task["affected_paths"] = []any{"README.md"}
- task["side_effects"] = []any{}
- task["rollback_boundary"] = "revert the workspace fixture"
- plan["journey_evidence"] = map[string]any{
- "relevance": "not_relevant", "reason": "workspace control-only fixture", "oracles": []any{},
- }
- planPath := filepath.Join(directory, "plan.md")
- writeMarkdownPlan(t, planPath, plan, true)
- check, err := CheckPlan(planPath)
- if err != nil {
- t.Fatal(err)
- }
- return planPath, check.Fingerprint
-}
-
-func withWorkspaceTransitionSeams(t *testing.T) {
- t.Helper()
- copyFn := workspacePackageCopy
- removeFn := workspaceSourcePackageRemove
- aliasFn := workspaceDetachedAlias
- afterDestinationFn := workspaceAfterDestination
- afterAliasFn := workspaceAfterDetachedAlias
- t.Cleanup(func() {
- workspacePackageCopy = copyFn
- workspaceSourcePackageRemove = removeFn
- workspaceDetachedAlias = aliasFn
- workspaceAfterDestination = afterDestinationFn
- workspaceAfterDetachedAlias = afterAliasFn
- })
-}
-
-// control-law: workspace-transition-preserves-plan-authority
-func TestWorkspaceTransitionMovesValidatedPackageFromBase(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- sourcePlan, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one")
-
- status, err := ResolveNext(repo, "")
- if err != nil || status.NextOperation != "workspace-cut" || status.ObservedStage != "DRAFT_PLAN" {
- t.Fatalf("valid draft did not route through workspace transition: %+v (%v)", status, err)
- }
- result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if err != nil || result.VerificationStatus != "VERIFIED" {
- t.Fatalf("transition failed: %+v (%v)", result, err)
- }
- if result.Outcome != "created" || result.PlanFingerprint != fingerprint || result.DestinationRepo == "" || result.BaseCommit == "" {
- t.Fatalf("transition result lacks verified destination facts: %+v", result)
- }
- if fileExists(sourcePlan) {
- t.Fatal("source package remained authoritative after verified relocation")
- }
- destinationPlan := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md")
- check, err := CheckPlan(destinationPlan)
- if err != nil || check.Fingerprint != fingerprint {
- t.Fatalf("destination plan fingerprint changed: %v %#v", err, check)
- }
- next, err := ResolveNext(result.DestinationRepo, "")
- if err != nil || next.NextOperation != "plan-gate" || next.ObservedStage != "DRAFT_PLAN" {
- t.Fatalf("destination did not continue at approval boundary: %+v (%v)", next, err)
- }
-}
-
-// control-law: workspace-transition-preserves-plan-authority
-func TestWorkspaceTransitionAcceptsDetachedHEAD(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- workspaceGitDo(t, repo, "checkout", "--detach")
- _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one")
-
- result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if err != nil || result.VerificationStatus != "VERIFIED" || result.PlanFingerprint != fingerprint {
- t.Fatalf("detached HEAD did not reach a verified workspace: %+v (%v)", result, err)
- }
- branch, _ := workspaceGit(result.DestinationRepo, "branch", "--show-current")
- if strings.TrimSpace(branch) != "feat/feature-one" {
- t.Fatalf("destination branch = %q", branch)
- }
-}
-
-// control-law: activation-requires-current-readiness-bound-to-exact-authority
-func TestWorkspaceTransitionPrecedesSchema3ApprovalAndActivation(t *testing.T) {
- previousHealth := runInstallationHealth
- runInstallationHealth = func(string) error { return nil }
- t.Cleanup(func() { runInstallationHealth = previousHealth })
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- planPath, fingerprint := writeWorkspaceSchema3Package(t, repo, "feature-one")
- if _, err := CheckPlanReadiness(planPath); err == nil || !strings.Contains(err.Error(), "workspace-cut") {
- t.Fatalf("pre-transition readiness did not name workspace-cut: %v", err)
- }
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "VERIFIED" || result.PlanFingerprint != fingerprint {
- t.Fatalf("workspace transition failed: %+v", result)
- }
- destinationPlan := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md")
- check, err := CheckPlan(destinationPlan)
- if err != nil {
- t.Fatal(err)
- }
- readiness, err := CheckPlanReadiness(destinationPlan)
- if err != nil {
- t.Fatal(err)
- }
- approvalPath := filepath.Join(filepath.Dir(destinationPlan), "approval.md")
- if err := RecordApproval(ApprovalRecordOptions{
- Repo: result.DestinationRepo,
- PlanPath: destinationPlan, OutputPath: approvalPath, ApprovedBy: "Test Human",
- ApprovedAt: "2026-08-09T12:00:00Z", Fingerprint: check.Fingerprint,
- }); err != nil {
- t.Fatal(err)
- }
- receipt, err := LoadApprovalReceipt(approvalPath)
- if err != nil || receipt.Readiness.Fingerprint != readiness.Fingerprint || receipt.Readiness.HeadBranch != "feat/feature-one" {
- t.Fatalf("approval did not bind destination readiness: %+v (%v)", receipt, err)
- }
- if err := ActivatePlan(ActivationOptions{
- Repo: result.DestinationRepo,
- PlanPath: destinationPlan, ApprovalPath: approvalPath,
- OutDir: filepath.Join(filepath.Dir(destinationPlan), "compiled"),
- OutputPath: filepath.Join(filepath.Dir(destinationPlan), "plan.lock.json"),
- }); err != nil {
- t.Fatalf("destination approval did not activate: %v", err)
- }
-}
-
-// control-law: autonomy-receipt-binds-policy-activation-to-plan-repository-and-branch
-func TestWorkspaceTransitionPrecedesAutonomyTargets(t *testing.T) {
- for _, target := range []RunTarget{RunTargetVerified, RunTargetPR} {
- t.Run(string(target), func(t *testing.T) {
- previousAction := autonomyRecommendedPRAction
- autonomyRecommendedPRAction = func(string) (string, string, error) { return "open", "", nil }
- t.Cleanup(func() { autonomyRecommendedPRAction = previousAction })
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- planPath, _ := writeWorkspacePlanPackage(t, repo, "feature-one")
- if _, err := RecordAutonomy(AutonomyRecordOptions{Repo: repo, PlanPath: planPath, Target: target}); err == nil || !strings.Contains(err.Error(), "workspace-cut") {
- t.Fatalf("pre-transition autonomy did not name workspace-cut: %v", err)
- }
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "VERIFIED" {
- t.Fatalf("workspace transition failed: %+v", result)
- }
- destinationPlan := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md")
- receipt, err := RecordAutonomy(AutonomyRecordOptions{Repo: result.DestinationRepo, PlanPath: destinationPlan, Target: target})
- if err != nil {
- t.Fatal(err)
- }
- if receipt.Branch != "feat/feature-one" || receipt.IssuingBranch != "feat/feature-one" || receipt.PlanFingerprint != result.PlanFingerprint {
- t.Fatalf("autonomy did not bind destination: %+v", receipt)
- }
- })
- }
-}
-
-// control-law: workspace-transition-adopts-only-pristine-base
-func TestWorkspaceTransitionAdoptsOnlyExactBaseBranch(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- workspaceGitDo(t, repo, "branch", "feat/feature-one")
- _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one")
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "VERIFIED" || result.Outcome != "adopted" || result.PlanFingerprint != fingerprint {
- t.Fatalf("exact-base branch was not adopted: %+v", result)
- }
-
- other := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, other)
- addWorkspaceOrigin(t, other)
- workspaceGitDo(t, other, "switch", "-c", "feat/feature-one")
- if err := os.WriteFile(filepath.Join(other, "diverged.txt"), []byte("change\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- workspaceGitDo(t, other, "add", "diverged.txt")
- workspaceGitDo(t, other, "commit", "-m", "diverge feature branch")
- workspaceGitDo(t, other, "switch", "main")
- sourcePlan, _ := writeWorkspacePlanPackage(t, other, "feature-one")
- blocked, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: other, Feature: "feature-one"})
- if blocked.VerificationStatus != "BLOCKED" || !strings.Contains(blocked.Reason, "diverges") || !fileExists(sourcePlan) {
- t.Fatalf("divergent branch changed source authority: %+v", blocked)
- }
-}
-
-// control-law: workspace-transition-adopts-only-pristine-base
-func TestWorkspaceTransitionRejectsDirtyOwnedAndConflictingDestinations(t *testing.T) {
- setup := func(t *testing.T) (string, string) {
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- workspaceGitDo(t, repo, "branch", "feat/feature-one")
- path := filepath.Join(repo, ".product-loop", "worktrees", "existing-feature-one")
- workspaceGitDo(t, repo, "worktree", "add", path, "feat/feature-one")
- return repo, path
- }
-
- t.Run("dirty", func(t *testing.T) {
- repo, destination := setup(t)
- sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one")
- if err := os.WriteFile(filepath.Join(destination, "unrelated.txt"), []byte("dirty\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "changes outside") || !fileExists(sourcePlan) {
- t.Fatalf("dirty destination changed source authority: %+v", result)
- }
- })
-
- t.Run("owned", func(t *testing.T) {
- repo, destination := setup(t)
- sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one")
- if err := saveDeliveryState(destination, DeliveryState{
- SchemaVersion: deliveryStateSchemaVersion, Feature: "other-delivery", ActiveIndex: 0,
- Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: StatusBuild, HeadBranch: "feat/feature-one"}},
- }); err != nil {
- t.Fatal(err)
- }
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "owned") || !fileExists(sourcePlan) {
- t.Fatalf("owned destination changed source authority: %+v", result)
- }
- })
-
- t.Run("conflicting_plan", func(t *testing.T) {
- repo, destination := setup(t)
- sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one")
- destinationDir := WorkspaceFor(destination).FeatureDir("feature-one")
- if err := os.MkdirAll(destinationDir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(destinationDir, "source-plan.md"), []byte("# Other source\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(destinationDir, "spec.md"), []byte("# Other spec\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- plan := validPlan()
- plan["feature_id"] = "feature-one"
- plan["acceptance_criteria"].([]any)[0].(map[string]any)["text"] = "different result"
- writeMarkdownPlan(t, filepath.Join(destinationDir, "plan.md"), plan, true)
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "conflicting") || !fileExists(sourcePlan) {
- t.Fatalf("conflicting destination changed source authority: %+v", result)
- }
- })
-}
-
-// control-law: workspace-transition-preserves-plan-authority
-func TestWorkspaceTransitionReusesMatchingWorktree(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- sourcePlan, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one")
- workspaceGitDo(t, repo, "branch", "feat/feature-one")
- destination := filepath.Join(repo, ".product-loop", "worktrees", "existing-feature-one")
- workspaceGitDo(t, repo, "worktree", "add", destination, "feat/feature-one")
- destinationDir := WorkspaceFor(destination).FeatureDir("feature-one")
- if err := copyFeaturePackage(filepath.Dir(sourcePlan), destinationDir); err != nil {
- t.Fatal(err)
- }
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- expectedDestination, err := filepath.EvalSymlinks(destination)
- if err != nil {
- t.Fatal(err)
- }
- if result.VerificationStatus != "VERIFIED" || result.Outcome != "current" || !samePath(result.DestinationRepo, expectedDestination) || result.PlanFingerprint != fingerprint {
- t.Fatalf("matching worktree was not reused: %+v", result)
- }
- if fileExists(sourcePlan) {
- t.Fatal("matching current worktree left the embedded source package authoritative")
- }
-}
-
-// control-law: workspace-transition-preserves-plan-authority
-func TestWorkspaceTransitionRollbackPreservesSource(t *testing.T) {
- tests := []struct {
- name string
- fail func()
- }{
- {"copy", func() {
- workspacePackageCopy = func(string, string) error { return fmt.Errorf("injected copy failure") }
- }},
- {"copied_package_drift", func() {
- workspacePackageCopy = func(source, destination string) error {
- if err := copyFeaturePackage(source, destination); err != nil {
- return err
- }
- return os.WriteFile(filepath.Join(destination, "spec.md"), []byte("# Drifted spec\n"), 0o644)
- }
- }},
- {"source_cleanup", func() {
- workspaceSourcePackageRemove = func(string) error { return fmt.Errorf("injected cleanup failure") }
- }},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- withWorkspaceTransitionSeams(t)
- repo := workspaceRepo(t, defaultWorkspace())
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one")
- test.fail()
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "BLOCKED" || !fileExists(sourcePlan) {
- t.Fatalf("failure did not preserve source package: %+v", result)
- }
- if branchExists(repo, "feat/feature-one") || worktreePathForBranch(repo, "feat/feature-one") != "" {
- t.Fatal("failed transition left branch or worktree authority")
- }
- })
- }
-}
-
-// control-law: workspace-transition-preserves-plan-authority
-func TestBranchWorkspaceTransitionRollbackRestoresOriginalHead(t *testing.T) {
- withWorkspaceTransitionSeams(t)
- repo := workspaceRepo(t, Workspace{Enabled: true, Mode: "branch"})
- commitWorkspaceController(t, repo)
- addWorkspaceOrigin(t, repo)
- sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one")
- originalHead, err := workspaceGit(repo, "rev-parse", "HEAD")
- if err != nil {
- t.Fatal(err)
- }
- workspaceAfterDestination = func(string) error { return fmt.Errorf("injected post-branch failure") }
-
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "BLOCKED" || !fileExists(sourcePlan) {
- t.Fatalf("branch failure did not preserve source authority: %+v", result)
- }
- current, err := workspaceGit(repo, "branch", "--show-current")
- if err != nil {
- t.Fatal(err)
- }
- if current = strings.TrimSpace(current); current != "main" {
- t.Fatalf("rollback left caller on %q", current)
- }
- head, err := workspaceGit(repo, "rev-parse", "HEAD")
- if err != nil {
- t.Fatal(err)
- }
- if head = strings.TrimSpace(head); head != strings.TrimSpace(originalHead) {
- t.Fatalf("rollback changed original head: got %s want %s", head, originalHead)
- }
- if branchExists(repo, "feat/feature-one") {
- t.Fatal("branch rollback left partial feature authority")
- }
-}
-
-// control-law: detached-state-controls-only-its-bound-repository
-func TestDetachedWorkspaceTransitionReusesControllerIdentity(t *testing.T) {
- repo := detachedTestRepo(t, "")
- remote := addWorkspaceOrigin(t, repo)
- _ = remote
- attached, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || attached.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach failed: %+v (%v)", attached, err)
- }
- _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one")
- result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if err != nil || result.VerificationStatus != "VERIFIED" || result.ControllerMode != "detached" || result.PlanFingerprint != fingerprint {
- t.Fatalf("detached transition failed: %+v (%v)", result, err)
- }
- destination := WorkspaceFor(result.DestinationRepo)
- if destination.Mode != SupervisionDetached || destination.RepoID != attached.RepoID || destination.WorktreeID == WorkspaceFor(repo).WorktreeID {
- t.Fatalf("destination controller identity drifted: source=%+v destination=%+v", WorkspaceFor(repo), destination)
- }
-}
-
-// control-law: detached-state-controls-only-its-bound-repository
-func TestDetachedWorkspaceRegistrationRollsBack(t *testing.T) {
- withWorkspaceTransitionSeams(t)
- repo := detachedTestRepo(t, "")
- addWorkspaceOrigin(t, repo)
- attached, err := AttachDetached(AttachOptions{Repo: repo})
- if err != nil || attached.VerificationStatus != "VERIFIED" {
- t.Fatalf("attach failed: %+v (%v)", attached, err)
- }
- writeWorkspacePlanPackage(t, repo, "feature-one")
- workspaceAfterDetachedAlias = func(string) error { return fmt.Errorf("injected post-registration failure") }
- result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"})
- if result.VerificationStatus != "BLOCKED" {
- t.Fatalf("post-registration failure was accepted: %+v", result)
- }
- if branchExists(repo, "feat/feature-one") || worktreePathForBranch(repo, "feat/feature-one") != "" {
- t.Fatal("detached registration failure left branch or worktree authority")
- }
- registry, err := loadRegistry(filepath.Join(os.Getenv(stateRootEnv), "boatstack"))
- if err != nil || len(registry.Repositories) != 1 {
- t.Fatalf("detached alias rollback changed source binding: %+v (%v)", registry, err)
- }
-}
diff --git a/boatstack/worktree_activation_guard_test.go b/boatstack/worktree_activation_guard_test.go
deleted file mode 100644
index 6ebc7a5..0000000
--- a/boatstack/worktree_activation_guard_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package boatstack
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// Conformance for the worktree-discipline guard.
-//
-// control-law: managed-delivery-activates-in-its-cut-worktree
-// Once a feature's workspace worktree is cut, activation must happen inside it,
-// never from the main worktree on the base branch (which would strand compiled
-// artifacts + a competing per-worktree delivery ledger on the base branch).
-// Activation inside the worktree, or when no workspace is cut, is unaffected.
-
-func workspaceActivationConfig() ProjectConfig {
- config := testConfig()
- config.Project.DefaultBranch = "main"
- config.Workspace = Workspace{Enabled: true, Mode: "worktree", Cleanup: "confirm", CleanupAfter: "merge"}
- return config
-}
-
-// control-law: managed-delivery-activates-in-its-cut-worktree
-func TestActivationGuardBlocksMainWorktreeWhenWorkspaceCut(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
- cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "sample-feature"})
- if err != nil || cut.VerificationStatus != "VERIFIED" || cut.WorktreePath == "" {
- t.Fatalf("failed to cut workspace: %+v (%v)", cut, err)
- }
- config := workspaceActivationConfig()
-
- // From the main worktree on the base branch → blocked, naming the worktree.
- err = guardManagedActivationWorktree(repo, config, "sample-feature")
- if err == nil {
- t.Fatal("activation from the main worktree after a cut must be blocked")
- }
- // The guard's path comes from `git worktree list` (always forward slashes);
- // cut.WorktreePath comes from filepath.Join (OS separator). Normalize before
- // comparing so this holds on Windows too.
- if !strings.Contains(filepath.ToSlash(err.Error()), filepath.ToSlash(cut.WorktreePath)) || !strings.Contains(err.Error(), "cut worktree") {
- t.Fatalf("block message should name the worktree and the rule: %v", err)
- }
-
- // From inside the cut worktree → allowed.
- if err := guardManagedActivationWorktree(cut.WorktreePath, config, "sample-feature"); err != nil {
- t.Fatalf("activation inside the cut worktree must be allowed: %v", err)
- }
-}
-
-// control-law: managed-delivery-activates-in-its-cut-worktree
-func TestActivationGuardIsInertWithoutWorktreeMode(t *testing.T) {
- repo := workspaceRepo(t, defaultWorkspace())
-
- // No workspace cut yet → the normal pre-cut path is allowed.
- if err := guardManagedActivationWorktree(repo, workspaceActivationConfig(), "sample-feature"); err != nil {
- t.Fatalf("pre-cut activation must be allowed: %v", err)
- }
-
- // Cut a workspace, then confirm disabled/branch-mode policies do not guard.
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "sample-feature"}); err != nil {
- t.Fatal(err)
- }
- if err := guardManagedActivationWorktree(repo, ProjectConfig{Workspace: Workspace{Enabled: false}}, "sample-feature"); err != nil {
- t.Fatalf("disabled workspace management must not guard: %v", err)
- }
- if err := guardManagedActivationWorktree(repo, ProjectConfig{Workspace: Workspace{Enabled: true, Mode: "branch"}}, "sample-feature"); err != nil {
- t.Fatalf("branch mode must not guard: %v", err)
- }
-}
-
-// control-law: managed-delivery-activates-in-its-cut-worktree
-// End-to-end: ActivatePlan refuses to run in the main worktree after a cut, and
-// writes no lock/delivery state on the base branch.
-func TestActivatePlanBlockedFromMainWorktreeAfterCut(t *testing.T) {
- root := t.TempDir()
- _, _, planPath := writePlanInputs(t, root, false) // policy mode: no approval needed
- runGit(t, root, "init", "-b", "main")
- runGit(t, root, "config", "user.name", "Boatstack Test")
- runGit(t, root, "config", "user.email", "boatstack@example.invalid")
- config := workspaceActivationConfig()
- value, err := MarshalJSON(config)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(root, ".product-loop"), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(root, ".product-loop", "project.json"), value, 0o644); err != nil {
- t.Fatal(err)
- }
- runGit(t, root, "add", ".")
- runGit(t, root, "commit", "-m", "record planning inputs")
-
- if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: root, Feature: "feature-one"}); err != nil {
- t.Fatal(err)
- }
-
- lock := filepath.Join(root, "plan.lock.json")
- options := ActivationOptions{Repo: root, PlanPath: planPath, OutDir: filepath.Join(root, "compiled"), OutputPath: lock, SourceCommit: "test"}
- err = ActivatePlan(options)
- if err == nil || !strings.Contains(err.Error(), "cut worktree") {
- t.Fatalf("expected activation blocked from the main worktree, got %v", err)
- }
- if fileExists(lock) {
- t.Fatal("guard was bypassed: a plan lock was written on the base branch")
- }
- deliveries, _ := deliveryStateDirectory(root)
- if _, statErr := os.Stat(filepath.Join(deliveries, "feature-one")); statErr == nil {
- t.Fatal("guard was bypassed: delivery state was written on the base branch")
- }
-}
diff --git a/docs/account-recovery-walkthrough.md b/docs/account-recovery-walkthrough.md
deleted file mode 100644
index 5345b19..0000000
--- a/docs/account-recovery-walkthrough.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Example: account recovery in a passwordless product
-
-**For:** someone who wants to see why Boatstack asks questions before code.
-**Outcome:** follow a real product conflict through decision, approval, repair, and PR preparation.
-
-This is a sanitized product-repository sequence. It demonstrates observed behavior, not a benchmark claim about Boatstack's overall performance.
-
-## The request conflicts with the product
-
-The request was:
-
-```text
-Add a password reset button on the homepage.
-```
-
-The repository used passwordless email-code authentication, had no password-reset route, and promised that users needed no password. A literal implementation would have created a button for a capability that did not exist.
-
-Boatstack therefore stopped and asked:
-
-```text
-Q1 Clarify email-code recovery or introduce passwords?
-1a Keep email-code recovery
-1b Introduce passwords (Recommended)
-
-Q2 If passwords are introduced, how should they relate to email codes?
-2a Replace email codes
-2b Keep both methods (Recommended)
-```
-
-The human replied `r`. Boatstack echoed `Q1 -> Introduce passwords` and `Q2 -> Keep both methods`, then recorded those recommendations as explicit human answers. Repository facts remained discovered rather than becoming inferred decisions.
-
-## Approval defines the change
-
-The revised plan kept passwordless login, added password login and recovery routes, preserved passwordless signup, updated misleading copy, and required route and authentication tests. It also kept an operational redirect gap visible rather than implying it was solved.
-
-The plan gate displayed the outcome, exclusions, decisions, checks, gaps, and exact fingerprint. The human replied `a`. No product code changed until the host entered its execution mode and build activated that approved plan.
-
-## Review finds what the tests missed
-
-The targeted suite initially passed. Review then found that the reset screen accepted any authenticated session as proof of password recovery. An ordinarily signed-in user could reach a form intended only for a recovery event.
-
-Boatstack blocked progression. The implementation was repaired to unlock the form only for the recovery event, a regression test was added, and review passed with the separate operational gap still visible.
-
-## Shipping keeps unrelated work separate
-
-At ship time, a pre-push type check failed in code unrelated to the feature. The correct choices were to prove it existed on the base branch and then either repair it separately or use a repository-policy bypass with explicit human authorization.
-
-Silently editing unrelated code would have widened the approved feature and made the PR harder to review.
-
-## What the sequence shows
-
-```text
-vague request
- -> discover a product conflict
- -> ask the person responsible
- -> approve one clear change
- -> build
- -> let evidence force a repair
- -> keep unrelated failures outside the feature
-```
-
-The safeguard behavior is covered by planning, approval, review, and PR-projection tests. Whether the complete Boatstack workflow improves product-delivery success remains a separate paired evaluation.
-
-Next: [install and ship a first feature](getting-started.md) or read the [research and design notes](research-and-design.md).
diff --git a/docs/architecture/boatstack-v1-authority-inventory.md b/docs/architecture/boatstack-v1-authority-inventory.md
new file mode 100644
index 0000000..9cd7438
--- /dev/null
+++ b/docs/architecture/boatstack-v1-authority-inventory.md
@@ -0,0 +1,631 @@
+# Boatstack V1 authority inventory
+
+Frozen against `c5b5e10cdcf4d97b645d705cb164e762acf93ff1`. This file is deletion evidence, not a compatibility contract.
+
+The inventory uses conservative syntactic definitions so its counts are reproducible:
+
+- **Direct lifecycle/completion decision declarations:** every function or method declaration in the nine files named by the V2 deletion contract as independent lifecycle/completion owners.
+- **Supporting control-authority declarations:** every declaration in the additional authority-owning files named by that contract.
+- **Direct filesystem mutation sites:** every production call to the listed `os` mutation primitives.
+- **External-effect sites:** the generic command boundaries plus explicit Git mutation intents. Read-only Git observations are excluded; the generic boundaries are included because their arguments could request effects.
+
+Counts:
+
+- direct lifecycle/completion decision declarations: **84**
+- supporting control-authority declarations: **388**
+- direct filesystem mutation sites: **106**
+- external-effect dispatch/intent sites: **14**
+- conservative effect surface: **120**
+
+All paths and line numbers below refer to the frozen base, not the post-rewrite tree.
+
+## Direct lifecycle/completion decision declarations
+
+```text
+boatstack/decision.go:49:func ResolvePlanDecision(input PlanDecisionInput) DecisionResolution {
+boatstack/delivery_terminal.go:23:func normalizeDeliveryTerminal(value string) (DeliveryTerminal, bool) {
+boatstack/delivery_terminal.go:38:func configuredDeliveryTerminal(repo string) DeliveryTerminal {
+boatstack/delivery_terminal.go:51:func resolveDeliveryTerminal(repo, feature string) DeliveryTerminal {
+boatstack/delivery_terminal.go:66:func deliveryGoalSnapshot(repo string) string {
+boatstack/engagement.go:57:func engagementLeasePath(repo string) (string, error) {
+boatstack/engagement.go:65:func dormantEngagement(reason string) EngagementStatus {
+boatstack/engagement.go:73:func ResolveEngagement(repoPath string, request EngagementRequest) EngagementStatus {
+boatstack/engagement.go:135:func engagementLeaseForState(repo string, state DeliveryState) (engagementLease, bool, error) {
+boatstack/engagement.go:164:func syncEngagementLease(repo string, state DeliveryState) error {
+boatstack/engagement.go:186:func clearEngagementLease(repo string) error {
+boatstack/lifecycle.go:41:func lockPlanSHA256(path string) (string, error) {
+boatstack/lifecycle.go:57:func lifecycleStateForSlice(status string) (deliverycontrol.StateID, error) {
+boatstack/lifecycle.go:74:func lifecycleFingerprint(snapshot LifecycleSnapshot) (string, error) {
+boatstack/lifecycle.go:86:func ResolveLifecycleSnapshot(repoPath, feature string) (LifecycleSnapshot, error) {
+boatstack/lifecycle.go:164:func amendmentLifecycleState(state deliverycontrol.StateID) bool {
+boatstack/next.go:53:func decorateAutonomyStatus(repo string, status NextStatus) NextStatus {
+boatstack/next.go:74:func blockedNextStatus(stage, operation, reason string, ambiguity ...string) NextStatus {
+boatstack/next.go:82:func featurePlanCandidates(repo string) ([]string, error) {
+boatstack/next.go:120:func orphanedFeatureArtifacts(repo string) ([]string, error) {
+boatstack/next.go:143:func nextForDelivery(repo, feature string) (NextStatus, error) {
+boatstack/next.go:186:func nextForPublished(repo string, state DeliveryState) NextStatus {
+boatstack/next.go:209:func observeVisualPublication(repo, feature string) string {
+boatstack/next.go:225:func publishedNextStatus(state DeliveryState, pr publishedPRObservation, terminal DeliveryTerminal, visualPublication string) NextStatus {
+boatstack/next.go:285:func completedManagedStates(repo string) ([]DeliveryState, error) {
+boatstack/next.go:325:func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr error) {
+boatstack/next.go:582:func FormatNextStatus(status NextStatus) string {
+boatstack/next.go:648:func RenderNextStatusBanner(status NextStatus) string {
+boatstack/next.go:671:func bannerRule(title string, width int) string {
+boatstack/next.go:681:func bannerSubtitle(status NextStatus) string {
+boatstack/next.go:693:func journeyNodes(status NextStatus) []string {
+boatstack/next.go:722:func stagePosition(stage string) int {
+boatstack/next.go:737:func bannerBlocked(status NextStatus) bool {
+boatstack/next.go:745:func friendlyPhrase(status NextStatus) string {
+boatstack/next.go:777:func friendlyBlockReason(status NextStatus) string {
+boatstack/pr_phase.go:66:func summarizeCheckRollup(entries []prStatusCheck) prCheckSummary {
+boatstack/pr_phase.go:93:func classifyStatusCheck(entry prStatusCheck) string {
+boatstack/pr_phase.go:133:func derivePRPhase(prState string, checks prCheckSummary, reviewDecision, mergeState string) PRPhase {
+boatstack/run.go:31:func blockedRunPreflight(base, head, upstream, relation, reason string) RunPreflight {
+boatstack/run.go:41:func blockedRunPreflightWithAuthority(base, head, upstream, relation, reason, authorityStatus, authorityReason string) RunPreflight {
+boatstack/run.go:48:func runBranches(repo, explicitFeature string) (string, string, error) {
+boatstack/run.go:112:func CheckInstallationPreflight(repoPath string) RunPreflight {
+boatstack/run.go:144:func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight {
+boatstack/workspace.go:41:func resolveWorkspace(workspace Workspace) ResolvedWorkspace {
+boatstack/workspace.go:66:func workspaceEnabled(repo string) bool {
+boatstack/workspace.go:77:func reapEnabled(repo string) bool {
+boatstack/workspace.go:89:func needsFreshCut(repo, feature string) bool {
+boatstack/workspace.go:101:func isMainWorktree(repo string) bool {
+boatstack/workspace.go:122:func guardManagedActivationWorktree(repo string, config ProjectConfig, feature string) error {
+boatstack/workspace.go:148:func loadWorkspacePolicy(repo string) (ResolvedWorkspace, error) {
+boatstack/workspace.go:158:func branchForFeature(feature string) string {
+boatstack/workspace.go:192:func blockedCut(reason string) WorkspaceCut {
+boatstack/workspace.go:205:func rollbackWorkspaceTransition(repo, branch, worktreePath string, transition workspaceTransition) {
+boatstack/workspace.go:224:func featurePackageFingerprint(repo, directory string) (string, error) {
+boatstack/workspace.go:236:func featurePackageDigest(directory string) (string, error) {
+boatstack/workspace.go:275:func copyFeaturePackage(source, destination string) error {
+boatstack/workspace.go:319:func dirtyOutsideFeature(repo, feature string) (bool, error) {
+boatstack/workspace.go:346:func transferFeaturePackage(sourceRepo, destinationRepo, feature string, controllerMode SupervisionMode) (string, error) {
+boatstack/workspace.go:407:func CutFeatureWorkspace(options WorkspaceCutOptions) (WorkspaceCut, error) {
+boatstack/workspace.go:609:func workspaceFeatureForBranch(branch string) string {
+boatstack/workspace.go:617:func workspaceBranchLanded(repo, branch, base string) bool {
+boatstack/workspace.go:629:func managedWorkspaceLifecycle(repo, branch, base string) (workspaceLifecycleAssessment, bool) {
+boatstack/workspace.go:701:func assessWorkspaceLifecycle(repo, branch, base string, abandoned bool) workspaceLifecycleAssessment {
+boatstack/workspace.go:740:func (assessment workspaceLifecycleAssessment) cleanupEligible(cleanupAfter string) bool {
+boatstack/workspace.go:753:func workspaceMergeStatus(repo, branch, base string) (bool, string) {
+boatstack/workspace.go:760:func worktreePathForBranch(repo, branch string) string {
+boatstack/workspace.go:779:func branchExists(repo, branch string) bool {
+boatstack/workspace.go:805:func blockedCleanup(branch, reason string) WorkspaceCleanup {
+boatstack/workspace.go:825:func planWorkspaceRemoval(repo, base, branch, worktreePath, cleanupAfter string, merged, force bool) workspaceRemovalPlan {
+boatstack/workspace.go:855:func performWorkspaceRemoval(repo, branch, worktreePath string, merged, force bool) (worktreeRemoved, branchDeleted bool, reason string, err error) {
+boatstack/workspace.go:883:func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, error) {
+boatstack/workspace.go:971:func boatstackWorktrees(repo string) []worktreeEntry {
+boatstack/workspace.go:1028:func blockedReap(reason string) WorkspaceReap {
+boatstack/workspace.go:1036:func samePath(a, b string) bool {
+boatstack/workspace.go:1050:func reclaimableScan(repo, base, cleanupAfter string, ignored []string) (skipped, reapable []WorkspaceReapItem) {
+boatstack/workspace.go:1093:func CountReclaimableWorkspaces(repoPath string) int {
+boatstack/workspace.go:1115:func ReapWorkspaces(options WorkspaceReapOptions) (WorkspaceReap, error) {
+boatstack/workspace.go:1219:func FeatureWorkspaceStatus(repoPath, branch string) (WorkspaceStatus, error) {
+boatstack/workspace_sync.go:37:func blockedWorkspaceSync(result WorkspaceSync, reason string) WorkspaceSync {
+boatstack/workspace_sync.go:44:func normalizeRemoteSource(repo, source string) (string, string, string, error) {
+boatstack/workspace_sync.go:62:func activeDeliveryOwningBranch(repo, branch string) (string, error) {
+boatstack/workspace_sync.go:96:func syncRecoveryRefs(branch, oldCommit string) (string, string) {
+boatstack/workspace_sync.go:103:func rollbackWorkspaceCheckpoint(worktreePath, recoveryRef string, checkpointCreated bool) {
+boatstack/workspace_sync.go:114:func SyncWorkspace(options WorkspaceSyncOptions) (WorkspaceSync, error) {
+```
+## Supporting control-authority declarations
+
+```text
+boatstack/activation.go:15:func containsEngagementHook(value any) bool {
+boatstack/activation.go:38:func detachedHelperPath(repo string) string {
+boatstack/activation.go:48:func engagementDesiredEntry(host, event, helper string) map[string]any {
+boatstack/activation.go:86:func userHostConfigPath(host string) (string, error) {
+boatstack/activation.go:112:func engagementProbeCommand(host, helper string) string {
+boatstack/activation.go:120:func engagementProbePowerShellCommand(host, helper string) string {
+boatstack/activation.go:131:func engagementHostFragment(host, helper string) ([]byte, error) {
+boatstack/activation.go:145:func overrideHookCommands(entry map[string]any, command, commandWindows string) {
+boatstack/activation.go:163:func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, error) {
+boatstack/activation.go:228:func blockedEngagementActivation(reason string) EngagementActivationResult {
+boatstack/activation.go:232:func defaultActivationHosts(hosts []string) []string {
+boatstack/activation.go:243:func mergeEngagementHooks(config map[string]any, host, helper string) error {
+boatstack/activation.go:278:func removeEngagementHooks(config map[string]any, host string) bool {
+boatstack/activation.go:309:func InstallEngagementProbes(repoPath string, hosts []string) (EngagementActivationResult, error) {
+boatstack/activation.go:355:func RemoveEngagementProbes(repoPath string, hosts []string) (EngagementActivationResult, error) {
+boatstack/authority.go:68:func AuthorityReceiptSigningBytes(receipt AuthorityBoundaryReceipt) ([]byte, error) {
+boatstack/authority.go:73:func ResolveAuthorityContext(repoInput string) (AuthorityContext, error) {
+boatstack/authority.go:97:func normalizedAuthorityMode(policy *ExternalAuthorityPolicy) string {
+boatstack/authority.go:104:func validateExternalAuthorityPolicy(policy *ExternalAuthorityPolicy) error {
+boatstack/authority.go:115:func ownerID(info os.FileInfo) (uint64, bool) {
+boatstack/authority.go:136:func protectedExternalTrustStore(path string) error {
+boatstack/authority.go:161:func loadExternalTrustStore(policy *ExternalAuthorityPolicy) (map[string]string, error) {
+boatstack/authority.go:187:func verifyAuthorityBoundary(repo string, policy *ExternalAuthorityPolicy) (string, string) {
+boatstack/bootstrap.go:61:func normalizedPlanningDocument(document []byte) ([]byte, error) {
+boatstack/bootstrap.go:73:func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, feature string) (string, *LifecycleSnapshot, error) {
+boatstack/bootstrap.go:129:func bootstrapProgram(workspace WorkspaceContext, shell BootstrapShell) string {
+boatstack/bootstrap.go:136:func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string, lifecycle *LifecycleSnapshot) []string {
+boatstack/bootstrap.go:155:func posixPlanningEnvelopeFor(argv []string, document []byte) string {
+boatstack/bootstrap.go:164:func powerShellPlanningWord(value string) string {
+boatstack/bootstrap.go:168:func powerShellPlanningEnvelopeFor(argv []string, document []byte) (string, error) {
+boatstack/bootstrap.go:189:func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription, error) {
+boatstack/config_mutation.go:18:func commitDetachedConfigBinding(topology ConfigurationTopology, raw []byte) error {
+boatstack/config_mutation.go:42:func MigrateManagedConfiguration(repoPath, requestedTarget string, check bool) (ConfigMigrationResult, error) {
+boatstack/config_mutation.go:147:func configFromBytes(path string, raw []byte) (ProjectConfig, error) {
+boatstack/config_rebind.go:58:func previewConfigRebind(opts ConfigRebindOptions) (configRebindPreview, error) {
+boatstack/config_rebind.go:187:func snapshotFiles(paths []string) ([]savedFile, error) {
+boatstack/config_rebind.go:215:func restoreFiles(saved []savedFile) error {
+boatstack/config_rebind.go:231:func ConfigRebind(opts ConfigRebindOptions) (result ConfigRebindResult, returnErr error) {
+boatstack/config_topology.go:43:func repositorySourceConfigPath(repo string) string {
+boatstack/config_topology.go:47:func repositoryPackagePresent(repo string) bool {
+boatstack/config_topology.go:51:func fileSHAIfRegular(path string) (string, error) {
+boatstack/config_topology.go:65:func detachedAliases(stateRoot, repoID string) ([]string, error) {
+boatstack/config_topology.go:80:func ResolveConfigurationTopology(repoPath string) (ConfigurationTopology, error) {
+boatstack/config_topology.go:152:func RequireManagedConfiguration(repo string) (ConfigurationTopology, error) {
+boatstack/config_topology.go:166:func ValidateConfigurationExport(repoPath, configPath string, write bool) error {
+boatstack/config_write.go:35:func withConfigurationMutationLock(repo string, apply func() error) error {
+boatstack/config_write.go:64:func projectionTransactionPaths(projection configMutationProjection) []string {
+boatstack/config_write.go:83:func verifyConfigurationSource(write configSourceWrite) error {
+boatstack/config_write.go:103:func mutateManagedConfiguration(repoPath string, mutate func(*ProjectConfig) (bool, error)) (result configMutationResult, returnErr error) {
+boatstack/config_write.go:306:func equalProjectConfig(left, right ProjectConfig) bool {
+boatstack/context.go:33:func ProjectOperatorContext(repoPath, operation, host string) (OperatorContext, error) {
+boatstack/delivery.go:33:func DeliverySliceStatuses() []string {
+boatstack/delivery.go:119:func validateDeliveryGatePolicy(config ProjectConfig, gate, status string, changed []string, reviewerIdentity, reviewMethod string) error {
+boatstack/delivery.go:180:func deliveryEvidenceGateStatus(value, gate, sliceID string, explicit bool) string {
+boatstack/delivery.go:191:func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) {
+boatstack/delivery.go:302:func deliveryStateDirectory(repo string) (string, error) {
+boatstack/delivery.go:310:func deliveryStatePath(repo, feature string) (string, error) {
+boatstack/delivery.go:321:func deliveryReceiptPath(repo, feature, sliceID, gate string) (string, error) {
+boatstack/delivery.go:332:func saveDeliveryState(repo string, state DeliveryState) error {
+boatstack/delivery.go:362:func LoadDeliveryState(repo, feature string) (DeliveryState, error) {
+boatstack/delivery.go:397:func initializeDeliveryState(repo, feature, planPath, lockPath string) error {
+boatstack/delivery.go:454:func guardReactivationPreservesProgress(repo, feature, planPath string) error {
+boatstack/delivery.go:473:func equalStrings(a, b []string) bool {
+boatstack/delivery.go:489:func deliveryDefinitionMatches(a, b DeliverySlice) bool {
+boatstack/delivery.go:503:func validateAmendmentPreservesProgress(existing DeliveryState, newSlices []DeliverySlice) error {
+boatstack/delivery.go:529:func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliverySlice, lockHash string) DeliveryState {
+boatstack/delivery.go:558:func archiveDeliveryReceipt(repo, feature, sliceID, gate, observationID string) (string, error) {
+boatstack/delivery.go:580:func appendChangeObservation(repo string, observation ChangeObservation) error {
+boatstack/delivery.go:597:func nextChangeObservationID(repo, feature string, fallback int) string {
+boatstack/delivery.go:614:func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservation, DeliveryState, error) {
+boatstack/delivery.go:824:func activeDeliverySlice(state DeliveryState) (DeliverySlice, error) {
+boatstack/delivery.go:834:func isTerminalPRState(prState string) bool {
+boatstack/delivery.go:856:func resolveAddressableSlice(state DeliveryState, sliceID string) (int, DeliverySlice, error) {
+boatstack/delivery.go:906:func resolveAddressableSliceByBranch(state DeliveryState, branch string) (int, DeliverySlice, bool) {
+boatstack/delivery.go:925:func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error {
+boatstack/delivery.go:937:func CurrentDeliveryState(repoPath, feature string) (DeliveryState, error) {
+boatstack/delivery.go:952:func currentDiffIdentity(repo, base, previewPath string) (string, string, string, []string, error) {
+boatstack/delivery.go:979:func pathMatchesDeliveryScope(path string, patterns []string) bool {
+boatstack/delivery.go:1004:func validateDeliveryScope(feature string, slice DeliverySlice, changed []string) error {
+boatstack/delivery.go:1024:func readDeliveryReceipt(repo, feature, sliceID, gate string) (DeliveryGateReceipt, error) {
+boatstack/delivery.go:1043:func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error) {
+boatstack/delivery.go:1196:func CheckDeliveryReadyForShip(repo, feature, sliceID, base, head, diffHash string, changed []string) (DeliveryState, DeliverySlice, []PRSource, error) {
+boatstack/delivery.go:1243:func MarkDeliveryPublished(repo, feature, sliceID, url string) error {
+boatstack/delivery.go:1313:func scanManagedDeliveries(repo string) (active []string, invalid []string, err error) {
+boatstack/delivery.go:1347:func ActiveManagedDeliveries(repo string) ([]string, error) {
+boatstack/delivery.go:1363:func withoutIgnoredDeliveries(features []string, ignored []string) []string {
+boatstack/delivery.go:1382:func withoutIgnoredDeliveryStates(states []DeliveryState, ignored []string) []DeliveryState {
+boatstack/delivery.go:1402:func IgnoreDelivery(repo, feature string) (bool, error) {
+boatstack/delivery.go:1468:func discardOrphanFeatureArtifacts(repo, feature string) (DiscardDeliveryResult, bool, error) {
+boatstack/delivery.go:1509:func DiscardDelivery(repoPath, feature string, force bool) (DiscardDeliveryResult, error) {
+boatstack/detached.go:39:func detachedStateRoot() (string, error) {
+boatstack/detached.go:82:func normalizeOrigin(url string) string {
+boatstack/detached.go:100:func firstLine(value string) string {
+boatstack/detached.go:113:func repoIdentity(repo string) (RepoIdentity, error) {
+boatstack/detached.go:174:func registryPath(stateRoot string) string { return filepath.Join(stateRoot, "registry.json") }
+boatstack/detached.go:176:func repositoryControlRoot(stateRoot, repoID string) string {
+boatstack/detached.go:180:func bindingPath(stateRoot, repoID string) string {
+boatstack/detached.go:184:func loadRegistry(stateRoot string) (detachedRegistry, error) {
+boatstack/detached.go:202:func saveRegistry(stateRoot string, registry detachedRegistry) error {
+boatstack/detached.go:218:func registerDetachedWorkspaceAlias(sourceRepo, destinationRepo string) (bool, error) {
+boatstack/detached.go:269:func unregisterDetachedWorkspaceAlias(repo string) error {
+boatstack/detached.go:293:func loadBinding(stateRoot, repoID string) (DetachedBinding, error) {
+boatstack/detached.go:309:func bindingMatchesIdentity(binding DetachedBinding, identity RepoIdentity) bool {
+boatstack/detached.go:331:func verifyDetachedConfiguration(ctx WorkspaceContext, binding DetachedBinding) error {
+boatstack/detached.go:383:func normalizedConfigAuthority(binding DetachedBinding) string {
+boatstack/detached.go:399:func detachedContextFor(repo string) (ctx WorkspaceContext, ok bool, err error) {
+boatstack/detached.go:446:func detachedContextFromIdentity(stateRoot string, identity RepoIdentity) WorkspaceContext {
+boatstack/detached.go:459:func nowRFC3339() string { return operationNow().UTC().Truncate(time.Second).Format(time.RFC3339) }
+boatstack/detached.go:465:func RepositoryIsManaged(repo string) bool {
+boatstack/flow_control.go:19:func FlowCheck() deliverycontrol.CheckResult {
+boatstack/flow_control.go:26:func FormatFlowCheck(result deliverycontrol.CheckResult) string {
+boatstack/flow_control.go:53:func flowStateFromStage(stage string) (deliverycontrol.StateID, bool) {
+boatstack/flow_control.go:81:func CurrentFlowState(repo, feature string) (deliverycontrol.StateID, bool) {
+boatstack/flow_control.go:168:func classifyNextActor(status NextStatus, next FlowNext) NextActor {
+boatstack/flow_control.go:300:func posixPlanningWord(value string) string {
+boatstack/flow_control.go:316:func powerShellCommandWord(value string) string {
+boatstack/flow_control.go:330:func (p PrescribedCommand) commandLineForOS(goos string) string {
+boatstack/flow_control.go:386:func (p PrescribedCommand) CommandLine() string {
+boatstack/flow_control.go:417:func prescribeCommand(repo, feature string, status NextStatus, transition deliverycontrol.TransitionID) (*PrescribedCommand, bool) {
+boatstack/flow_control.go:504:func planningFeatureDir(repo, feature string) string {
+boatstack/flow_control.go:508:func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, string) {
+boatstack/flow_control.go:595:func prescribeVisualAttach(repo string, status NextStatus) (*PrescribedCommand, string) {
+boatstack/flow_control.go:633:func prescribePostPublish(repo string, status NextStatus, terminal DeliveryTerminal) (*PrescribedCommand, string) {
+boatstack/flow_control.go:701:func buildWorkspaceCut(repoArgs []string, feature string) *PrescribedCommand {
+boatstack/flow_control.go:709:func buildActivatePlan(featureDir, stage string) *PrescribedCommand {
+boatstack/flow_control.go:724:func NextControl(repo, feature string) (FlowNext, error) {
+boatstack/flow_control.go:732:func bindFlowCommandPrograms(repo string, next *FlowNext) {
+boatstack/flow_control.go:753:func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) {
+boatstack/flow_control.go:828:func FormatFlowNext(next FlowNext) string {
+boatstack/flow_control.go:872:func writeAlternatives(b *strings.Builder, alternatives []PrescribedCommand) {
+boatstack/flow_control.go:892:func writePrescribed(b *strings.Builder, p *PrescribedCommand) {
+boatstack/flow_frontier.go:55:func ResolveFrontier(repoPath string) (FlowFrontier, error) {
+boatstack/flow_frontier.go:112:func activeDeliveryRows(repo string, state DeliveryState) []FrontierRow {
+boatstack/flow_frontier.go:155:func frontierRowFromStatus(repo string, status NextStatus) FrontierRow {
+boatstack/flow_frontier.go:181:func frontierPosition(row FrontierRow) string {
+boatstack/flow_frontier.go:189:func FormatFlowFrontier(frontier FlowFrontier) string {
+boatstack/flow_frontier.go:223:func frontierLabel(row FrontierRow) string {
+boatstack/flow_guard.go:66:func GuardFlowMove(repo, feature string, transition deliverycontrol.TransitionID) FlowGuard {
+boatstack/flow_guard.go:93:func GateTransition(gate string) deliverycontrol.TransitionID {
+boatstack/operation.go:108:func operationTimestamp() string {
+boatstack/operation.go:119:func operationDirectory(repo string) (string, error) {
+boatstack/operation.go:132:func pruneLegacyOperationLedger(repo string) {
+boatstack/operation.go:141:func operationOwnedPath(repo, operationID string) (controllerPath, error) {
+boatstack/operation.go:154:func operationPath(repo, operationID string) (string, error) {
+boatstack/operation.go:159:func operationID(kind, target, fingerprint string) string {
+boatstack/operation.go:163:func validOperationState(state OperationState) bool {
+boatstack/operation.go:172:func validRetryClass(value string) bool {
+boatstack/operation.go:181:func validateOperation(receipt OperationReceipt) error {
+boatstack/operation.go:194:func loadOperation(repo, id string) (OperationReceipt, error) {
+boatstack/operation.go:213:func saveOperation(repo string, receipt OperationReceipt) error {
+boatstack/operation.go:228:func withOperationLock(repo, id string, apply func() error) error {
+boatstack/operation.go:264:func isLockContention(openErr error, lock string) bool {
+boatstack/operation.go:268:func isLockContentionForOS(openErr error, lock, goos string) bool {
+boatstack/operation.go:287:func PrepareOperation(options OperationPrepareOptions) (OperationReceipt, error) {
+boatstack/operation.go:358:func AuthorizeOperation(repoPath, id, packageFingerprint, authorizationFingerprint string) (OperationReceipt, error) {
+boatstack/operation.go:390:func randomLeaseToken() (string, error) {
+boatstack/operation.go:398:func BeginOperation(repoPath, id, attemptKey, tool string) (OperationBeginResult, error) {
+boatstack/operation.go:476:func reconcileSucceededInstallUpdate(repoPath, id, detail, evidence string) (OperationReceipt, error) {
+boatstack/operation.go:510:func completeOperation(repoPath, id, leaseToken, attemptKey, outcome, detail, evidence string, trustedAttempt bool) (OperationReceipt, error) {
+boatstack/operation.go:567:func boundedObservation(value string) string {
+boatstack/operation.go:579:func CompleteOperation(repo, id, leaseToken, outcome, detail, evidence string) (OperationReceipt, error) {
+boatstack/operation.go:583:func CompleteOperationAttempt(repo, id, attemptKey, outcome, detail, evidence string) (OperationReceipt, error) {
+boatstack/operation.go:587:func RecordOperationReconciliation(repoPath, id, result, detail, evidence string) (OperationReceipt, error) {
+boatstack/operation.go:626:func operationReceipts(repo string) ([]OperationReceipt, error) {
+boatstack/operation.go:653:func refreshExpiredOperation(repo, id string) (OperationReceipt, error) {
+boatstack/operation.go:678:func ResolveOperationStatus(repoPath, id string) (OperationStatusResult, error) {
+boatstack/operation.go:721:func operationStatusFor(receipt OperationReceipt) OperationStatusResult {
+boatstack/operation.go:746:func compactOperations(repo string) error {
+boatstack/paths.go:75:func newControllerPath(root, target string) (controllerPath, error) {
+boatstack/paths.go:86:func (p controllerPath) Validate() error {
+boatstack/paths.go:91:func (p controllerPath) Sibling(name string) (controllerPath, error) {
+boatstack/paths.go:98:func (w WorkspaceContext) worktreeOwnedPath(target string) (controllerPath, error) {
+boatstack/paths.go:109:func (w WorkspaceContext) sharedOwnedPath(target string) (controllerPath, error) {
+boatstack/paths.go:126:func WorkspaceFor(repo string) WorkspaceContext {
+boatstack/paths.go:149:func ResolveWorkspaceContext(repo string) (WorkspaceContext, error) {
+boatstack/paths.go:160:func embeddedWorkspace(repo string) WorkspaceContext {
+boatstack/paths.go:164:func pathWithin(root, target string) bool {
+boatstack/paths.go:175:func ResolveControllerRepository(path string) (string, error) {
+boatstack/paths.go:212:func ResolveControllerRepositoryFor(repoPath, path string) (string, error) {
+boatstack/paths.go:234:func invalidateWorkspaceCache() {
+boatstack/paths.go:244:func (w WorkspaceContext) configBase() string {
+boatstack/paths.go:253:func (w WorkspaceContext) GeneratedRoot() string {
+boatstack/paths.go:258:func (w WorkspaceContext) HelperPath() string {
+boatstack/paths.go:265:func (w WorkspaceContext) LauncherPath(powerShell bool) string {
+boatstack/paths.go:276:func projectLocalLauncherCommand() string {
+boatstack/paths.go:283:func (w WorkspaceContext) ExportRoot() string {
+boatstack/paths.go:290:func (w WorkspaceContext) FeatureRoot() string {
+boatstack/paths.go:296:func (w WorkspaceContext) FeatureDir(feature string) string {
+boatstack/paths.go:305:func (w WorkspaceContext) ProjectConfigPath() string {
+boatstack/paths.go:311:func (w WorkspaceContext) SourceConfigPath() string {
+boatstack/paths.go:318:func (w WorkspaceContext) HostActivationRoot() string {
+boatstack/paths.go:325:func (w WorkspaceContext) worktreeControlDir() (string, error) {
+boatstack/paths.go:338:func (w WorkspaceContext) sharedControlDir() (string, error) {
+boatstack/paths.go:350:func (w WorkspaceContext) DeliveryDir() (string, error) {
+boatstack/paths.go:360:func (w WorkspaceContext) OperationDir() (string, error) {
+boatstack/paths.go:369:func (w WorkspaceContext) FlowDir() (string, error) {
+boatstack/paths.go:381:func (w WorkspaceContext) InsightDir() (string, error) {
+boatstack/paths.go:389:func (w WorkspaceContext) GuardDir() (string, error) {
+boatstack/paths.go:399:func (w WorkspaceContext) RuntimeDir(version, sourceCommit string) (string, error) {
+boatstack/paths.go:419:func (w WorkspaceContext) BootstrapRuntimeDir(version, sourceCommit string) (string, error) {
+boatstack/plan.go:22:func stringValue(value any) string {
+boatstack/plan.go:27:func stringSlice(value any) ([]string, bool) {
+boatstack/plan.go:43:func objectSlice(value any) ([]map[string]any, bool) {
+boatstack/plan.go:59:func validationSlice(value any) ([]map[string]any, bool) {
+boatstack/plan.go:84:func validateJourneyEvidence(plan map[string]any, version float64) error {
+boatstack/plan.go:149:func fencedJSONBlocks(value string) ([]string, error) {
+boatstack/plan.go:177:func markedJSON(value, label, startMarker, endMarker string, allowLegacy bool) ([]byte, error) {
+boatstack/plan.go:211:func loadJSONObject(path, label, startMarker, endMarker string, allowLegacyMarkdown bool) (map[string]any, error) {
+boatstack/plan.go:230:func LoadPlan(path string) (map[string]any, error) {
+boatstack/plan.go:237:func CheckSourcePlan(path string) error {
+boatstack/plan.go:267:func DiscoverSourcePlan(repo, explicit string) (string, error) {
+boatstack/plan.go:290:func sourcePlanForStructuredPlan(planPath, repo string) (string, error) {
+boatstack/plan.go:326:func SourcePlanForStructuredPlan(planPath string) (string, error) {
+boatstack/plan.go:330:func SpecForStructuredPlan(planPath string) (string, error) {
+boatstack/plan.go:345:func checkNonEmptyFile(path, label string) error {
+boatstack/plan.go:371:func checkPlanForRepository(repoRoot, planPath string) (PlanCheck, error) {
+boatstack/plan.go:428:func CheckPlan(planPath string) (PlanCheck, error) {
+boatstack/plan.go:441:func CheckPlanForRepository(repoPath, planPath string) (PlanCheck, error) {
+boatstack/plan.go:449:func checkApprovalSourcePlan(options ApprovalOptions) error {
+boatstack/plan.go:476:func ValidatePlan(plan map[string]any, opts *ValidatePlanOptions) error {
+boatstack/plan.go:645:func taskSafetyText(task map[string]any) string {
+boatstack/plan.go:658:func taskHasExternalWrite(task map[string]any) bool {
+boatstack/plan.go:671:func destructiveRollback(value string) bool {
+boatstack/plan.go:689:func validateTaskSafety(task map[string]any) error {
+boatstack/plan.go:735:func CompilePlan(plan map[string]any, opts *ValidatePlanOptions) (map[string]any, map[string]any, string, error) {
+boatstack/plan.go:822:func CompilePlanFiles(planPath, outDir string) error {
+boatstack/plan.go:831:func canonicalizeExistingAncestor(path string) string {
+boatstack/plan.go:850:func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error {
+boatstack/plan.go:894:func compileArtifacts(repoRoot, planPath, outDir, structuredPlanStatus string) (compiledArtifacts, error) {
+boatstack/plan.go:1033:func LoadApprovalReceipt(path string) (ApprovalReceipt, error) {
+boatstack/plan.go:1095:func intValue(value any) int {
+boatstack/plan.go:1103:func checkApprovalReceipt(path string, planCheck PlanCheck, repo string) (ApprovalReceipt, error) {
+boatstack/plan.go:1144:func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, error) {
+boatstack/plan.go:1158:func ActivatePlan(options ActivationOptions) error {
+boatstack/plan.go:1342:func activationMutation(repoRoot string, options ActivationOptions, structuredPlanStatus string, approval ApprovalOptions) (MutationSet, error) {
+boatstack/plan.go:1392:func gitCommit(directory string) string {
+boatstack/plan.go:1405:func buildApprovalLock(options ApprovalOptions, tasksSHA256 string) ([]byte, error) {
+boatstack/plan.go:1490:func CreateApprovalLock(options ApprovalOptions) error {
+boatstack/plan.go:1505:func CheckApprovalLock(options ApprovalOptions) error {
+boatstack/planning.go:34:func planningArtifactNames() []string {
+boatstack/planning.go:77:func relativeBaselineExclusions(repo string, paths ...string) map[string]bool {
+boatstack/planning.go:97:func productBaseline(repo string, artifactPaths ...string) (PlanningBaseline, error) {
+boatstack/planning.go:176:func PlanningBaselineForPlan(planPath string) (PlanningBaseline, error) {
+boatstack/planning.go:184:func PlanningBaselineForRepository(repoPath, planPath string) (PlanningBaseline, error) {
+boatstack/planning.go:196:func rejectSymlinkComponents(root, target string) error {
+boatstack/planning.go:218:func atomicWrite(path string, content []byte) error {
+boatstack/planning.go:247:func WritePlanningArtifact(options PlanningWriteOptions) (string, error) {
+boatstack/planning.go:352:func normalizePlanningTransportBytes(content []byte) []byte {
+boatstack/planning.go:357:func RecordApproval(options ApprovalRecordOptions) error {
+boatstack/planning.go:488:func CheckInstallationHealth(repoPath string) error {
+boatstack/planning.go:579:func Doctor(repoPath string) error {
+boatstack/planning.go:593:func DoctorHookHosts(repoPath string) ([]string, error) {
+boatstack/planning.go:611:func DoctorRepairHint(err error) error {
+boatstack/pr.go:101:func planVisualDecision(repo, feature string) (string, string, []PRVisualScenario, error) {
+boatstack/pr.go:136:func ensureCurrentPRVisualEvidence(repo string, config ProjectConfig, mode, feature, base, diffHash string, runner CaptureRunner) (string, error) {
+boatstack/pr.go:189:func currentVisualEvidenceIdentity(scenarios []PRVisualScenario, config ProjectConfig) (string, string, error) {
+boatstack/pr.go:205:func visualScenarioDefinitionHash(scenarios []PRVisualScenario) (string, error) {
+boatstack/pr.go:215:func boundedCaptureDetail(detail string) string {
+boatstack/pr.go:223:func resolvePRVisualEvidence(repo string, config ProjectConfig, mode, feature, head, diffHash string) (string, string, int, string, string, string, string, *PRVisualEvidenceManifest, error) {
+boatstack/pr.go:313:func publishPRVisualEvidence(repo, prURL string, context PRContext, publisher PRVisualEvidencePublisher) error {
+boatstack/pr.go:332:func attachVisualEvidence(repo, prURL string, manifest PRVisualEvidenceManifest, publisher PRVisualEvidencePublisher, policy string) error {
+boatstack/pr.go:369:func RetryVisualAttachment(repo, feature string, publisher PRVisualEvidencePublisher) (PRVisualEvidenceManifest, error) {
+boatstack/pr.go:402:func gitCommand(repo string, arguments ...string) (string, error) {
+boatstack/pr.go:406:func defaultPRBase(repo string) string {
+boatstack/pr.go:423:func canonicalPRBaseName(value string) (string, error) {
+boatstack/pr.go:437:func canonicalPRBase(repo, value string) (string, error) {
+boatstack/pr.go:448:func resolveBaseCommit(repo, base string) (string, error) {
+boatstack/pr.go:461:func resolveFetchedOriginBaseCommit(repo, base string) (string, error) {
+boatstack/pr.go:473:func previewSlug(branch string) string {
+boatstack/pr.go:489:func expectedPRPreviewPath(mode, feature, head string) (string, error) {
+boatstack/pr.go:507:func dirtyPaths(repo string) ([]string, error) {
+boatstack/pr.go:533:func productDiff(repo, baseCommit, previewPath string) ([]byte, []string, error) {
+boatstack/pr.go:566:func productDiffStat(repo, baseCommit string) (string, error) {
+boatstack/pr.go:572:func highRiskChangedFiles(changed, patterns []string) []string {
+boatstack/pr.go:596:func evidenceGateStatus(value, gate string) string {
+boatstack/pr.go:610:func relativeSource(repo, path, kind string) (PRSource, error) {
+boatstack/pr.go:629:func featureArtifactPath(directory string, candidates ...string) string {
+boatstack/pr.go:648:func featureEvidencePath(featureDir string) string {
+boatstack/pr.go:652:func managedPRSources(repo, feature string) ([]PRSource, map[string]string, error) {
+boatstack/pr.go:760:func PreparePRContext(options PRContextOptions) (PRContext, error) {
+boatstack/pr.go:951:func parsePRFrontmatter(value string) (map[string]string, string, error) {
+boatstack/pr.go:1000:func validateVisualEvidenceSection(body, status string, count int) error {
+boatstack/pr.go:1029:func section(value, heading string) string {
+boatstack/pr.go:1041:func validateEvidenceTable(body string, mode string) error {
+boatstack/pr.go:1075:func validateManagedEvidenceSources(body string, sources []PRSource) error {
+boatstack/pr.go:1110:func ParsePRPreview(path string) (PRPreview, error) {
+boatstack/pr.go:1198:func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) {
+boatstack/pr.go:1262:func ghAvailable(repo string) error {
+boatstack/pr.go:1272:func existingPRURL(repo string) (string, bool, error) {
+boatstack/pr.go:1294:func RecommendedPRAction(repo string) (string, string, error) {
+boatstack/pr.go:1309:func revalidatePRVisualPrivacy(repo string, context PRContext) error {
+boatstack/pr.go:1330:func PublishPR(options PRPublishOptions) (string, error) {
+boatstack/pr.go:1511:func extractSystemicBoundaries(repo, feature string) error {
+boatstack/pr.go:1544:func PRPreviewTemplate(context PRContext) string {
+boatstack/pr.go:1597:func PRContextJSON(context PRContext) ([]byte, error) {
+boatstack/pr.go:1601:func PRBody(preview PRPreview) []byte {
+boatstack/readiness.go:23:func readinessFingerprint(receipt ReadinessReceipt) (string, error) {
+boatstack/readiness.go:37:func checkPlanReadiness(repo, planPath string) (ReadinessReceipt, error) {
+boatstack/readiness.go:90:func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) {
+boatstack/readiness.go:98:func CheckPlanReadinessForRepository(repoPath, planPath string) (ReadinessReceipt, error) {
+boatstack/readiness.go:106:func checkJourneyCapabilities(repo string, plan map[string]any) error {
+boatstack/recovery.go:82:func blockedRecovery(reason string, blockers ...string) RecoveryStatus {
+boatstack/recovery.go:97:func allManagedDeliveryStates(repo string) (states []DeliveryState, invalid []string, err error) {
+boatstack/recovery.go:126:func deliveryBranchAndSlice(state DeliveryState) (string, string, string) {
+boatstack/recovery.go:138:func stateMatchesBranch(state DeliveryState, branch string) bool {
+boatstack/recovery.go:156:func selectRecoveryDelivery(states []DeliveryState, explicitFeature, currentBranch string) (DeliveryState, []string, error) {
+boatstack/recovery.go:198:func observePublishedPR(repo string, state DeliveryState) publishedPRObservation {
+boatstack/recovery.go:208:func observePRTarget(repo, prURL, branch string) publishedPRObservation {
+boatstack/recovery.go:277:func persistObservedTerminalPRState(repo string, state DeliveryState, observation publishedPRObservation) {
+boatstack/recovery.go:303:func suggestedCorrectionFeature(states []DeliveryState, parent string) string {
+boatstack/recovery.go:322:func existingRecoveryDiff(repo string, state DeliveryState) (string, []string) {
+boatstack/recovery.go:387:func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) {
+boatstack/recovery.go:532:func refusedRepairState(feature, reason string, blockers ...string) RepairStateResult {
+boatstack/recovery.go:545:func RepairState(repoPath, feature string) (RepairStateResult, error) {
+boatstack/recovery.go:677:func copyTree(source, destination string) error {
+boatstack/runtime_cache.go:30:func helperName() string {
+boatstack/runtime_cache.go:38:func platformKey() string { return runtime.GOOS + "-" + runtime.GOARCH }
+boatstack/runtime_cache.go:40:func safeCacheSegment(value, label string) (string, error) {
+boatstack/runtime_cache.go:49:func gitCommonDir(repo string) (string, error) {
+boatstack/runtime_cache.go:74:func worktreeGitDir(repo string) (string, error) {
+boatstack/runtime_cache.go:92:func sharedRuntimeDirectory(repo, version, sourceCommit string) (string, error) {
+boatstack/runtime_cache.go:96:func sharedRuntimePaths(repo, version, sourceCommit string) (string, string, error) {
+boatstack/runtime_cache.go:101:func sharedRuntimeOwnedPaths(repo, version, sourceCommit string) (controllerPath, controllerPath, error) {
+boatstack/runtime_cache.go:115:func bootstrapRuntimePaths(repo, version, sourceCommit string) (string, string, error) {
+boatstack/runtime_cache.go:120:func bootstrapRuntimeOwnedPaths(repo, version, sourceCommit string) (controllerPath, controllerPath, error) {
+boatstack/runtime_cache.go:138:func atomicWriteMode(path string, content []byte, mode fs.FileMode) error {
+boatstack/runtime_cache.go:172:func installSharedRuntime(source, repo string, integrations map[string]IntegrationState) (runtimeManifest, error) {
+boatstack/runtime_cache.go:186:func installCommandRuntime(source, repo string, integrations map[string]IntegrationState) (runtimeManifest, error) {
+boatstack/runtime_cache.go:211:func installDetachedRuntime(repo, source string) (runtimeManifest, error) {
+boatstack/runtime_cache.go:228:func writeRuntimeSlot(source string, binaryPath, manifestPath controllerPath, integrations map[string]IntegrationState) (runtimeManifest, error) {
+boatstack/runtime_cache.go:272:func loadSharedRuntime(repo string) (runtimeManifest, string, error) {
+boatstack/runtime_cache.go:308:func verifyGeneratedRuntime(repo string) error {
+boatstack/runtime_cache.go:325:func acquireHydrationLock(repo string) (func(), error) {
+boatstack/runtime_cache.go:364:func HydrateWorktree(repoPath string) error {
+boatstack/runtime_cache.go:422:func RunHydrateRuntime(repoPath string) error {
+boatstack/runtime_cache.go:444:func verifyLocalRuntime(repo string) error {
+boatstack/safety.go:69:func (err hookDecodeError) Error() string { return err.code }
+boatstack/safety.go:71:func malformedHookInput(code string) error {
+boatstack/safety.go:247:func controlledPhaseTransition(command, stage string) bool {
+boatstack/safety.go:278:func commandFlagValue(words []string, name string) (string, bool) {
+boatstack/safety.go:306:func mergeCommandFeature(current, candidate string) (string, bool) {
+boatstack/safety.go:316:func ownedCommandFeature(workspace WorkspaceContext, words []string) (string, bool) {
+boatstack/safety.go:346:func ownedReadOnlyHelperCommand(words []string) bool {
+boatstack/safety.go:373:func knownOwnedMutationVerb(verb string) bool {
+boatstack/safety.go:392:func commandMatchesSolutionVerb(next FlowNext, verb string) bool {
+boatstack/safety.go:408:func ownedFlowExecuteCoordinator(words []string, feature string) bool {
+boatstack/safety.go:438:func ownedBoatstackCommand(repo, command string) ownedCommandAdmission {
+boatstack/safety.go:521:func isPureReadOnlyCommandForRepo(repo, command string) bool {
+boatstack/safety.go:532:func controlledWorkspaceSync(repo, command string) bool {
+boatstack/safety.go:589:func attemptedRepositoryPath(repo string, input any) string {
+boatstack/safety.go:647:func redactContentFields(input any) any {
+boatstack/safety.go:674:func fileWriterTool(nameLower, attemptedPath string) bool {
+boatstack/safety.go:682:func featureScopedPath(path string) bool {
+boatstack/safety.go:690:func featuresPathInCommand(command string) string {
+boatstack/safety.go:699:func planningMarkdownPath(path string) bool {
+boatstack/safety.go:719:func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) {
+boatstack/safety.go:738:func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) {
+boatstack/safety.go:804:func classifySafetyText(value, source string, scanSQL bool) []SafetyFinding {
+boatstack/safety.go:835:func isPureReadOnlyCommand(value string) bool {
+boatstack/safety.go:853:func shellPipelineStages(value string) ([]string, bool) {
+boatstack/safety.go:892:func shellSegments(value string) []string {
+boatstack/safety.go:927:func segmentExecutor(segment string) string {
+boatstack/safety.go:951:func shellDashCScript(executor, segment string) (string, bool) {
+boatstack/safety.go:970:func commandExecutesLiveSQL(command string) bool {
+boatstack/safety.go:988:func executedRepositoryFiles(repo, command string) (content []string, symlinks []string) {
+boatstack/safety.go:1041:func toolExecutesLiveSQL(name string) bool {
+boatstack/safety.go:1045:func ClassifyCommand(repo, command string) []SafetyFinding {
+boatstack/safety.go:1135:func ClassifyTool(repo, name string, input any) []SafetyFinding {
+boatstack/safety.go:1195:func mutationCapableTool(repo, name string, input any) bool {
+boatstack/safety.go:1214:func supervisedToolIdentity(name string, input any) (string, string) {
+boatstack/safety.go:1220:func activeManagedOperationScope(repo string) (OperationScope, string, bool) {
+boatstack/safety.go:1228:func operationRetryClassForTool(name string) string {
+boatstack/safety.go:1239:func hookAttemptKey(host, fingerprint string, eventValue []byte) string {
+boatstack/safety.go:1251:func superviseToolAttempt(repo, host, name string, input any, eventValue []byte) *SafetyFinding {
+boatstack/safety.go:1302:func postToolEvent(host string, value []byte) (string, any, string, bool, bool) {
+boatstack/safety.go:1358:func completeSupervisedToolEvent(repo, host string, value []byte) (bool, bool) {
+boatstack/safety.go:1386:func dedupeFindings(values []SafetyFinding) []SafetyFinding {
+boatstack/safety.go:1414:func decodeJSONObject(host string, value []byte) (map[string]any, error) {
+boatstack/safety.go:1425:func cursorMCPInput(value any) (any, error) {
+boatstack/safety.go:1442:func decodeCursorHook(value []byte) (string, any, error) {
+boatstack/safety.go:1507:func decodePreToolUseHook(host string, value []byte) (string, any, error) {
+boatstack/safety.go:1530:func decodeGeminiHook(value []byte) (string, any, error) {
+boatstack/safety.go:1549:func structuredHookDeny(repo, host string, finding SafetyFinding) ([]byte, error) {
+boatstack/safety.go:1623:func denialMessage(repo, host string, finding SafetyFinding) string {
+boatstack/safety.go:1630:func EngagementProbeDecision(options SafetyHookOptions) ([]byte, bool) {
+boatstack/safety.go:1634:func HookDecision(options SafetyHookOptions) ([]byte, bool) {
+boatstack/safety.go:1694:func operationalChangedFiles(repo string, highRisk []string, defaultBranch string) ([]string, error) {
+boatstack/safety.go:1741:func CheckRepositorySafety(repoPath string) (SafetyReport, error) {
+```
+
+## Direct filesystem mutation sites
+
+```text
+boatstack/atomic_unix.go:8: return os.Rename(source, destination)
+boatstack/attach.go:139: if err := os.MkdirAll(ctx.controlRoot, 0o755); err != nil {
+boatstack/attach.go:175: if err := os.MkdirAll(filepath.Dir(bindingPath(stateRoot, identity.RepoID)), 0o755); err != nil {
+boatstack/attach.go:263: if err := os.RemoveAll(repositoryControlRoot(stateRoot, repoID)); err != nil {
+boatstack/capture.go:267: if err := os.MkdirAll(staging, 0o700); err != nil {
+boatstack/capture.go:323: if err := os.Remove(receiptPath); err != nil && !os.IsNotExist(err) {
+boatstack/config_rebind.go:219: if err := os.Remove(item.path); err != nil && !os.IsNotExist(err) {
+boatstack/config_write.go:45: file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+boatstack/config_write.go:49: defer os.Remove(lock)
+boatstack/config_write.go:56: _ = os.Remove(lock)
+boatstack/delivery.go:574: if err := os.Remove(path); err != nil {
+boatstack/delivery.go:1182: _ = os.Remove(reviewPath)
+boatstack/delivery.go:1493: if err := os.MkdirAll(archiveDir, 0o755); err != nil {
+boatstack/delivery.go:1496: if err := os.Rename(dir, destination); err != nil {
+boatstack/delivery.go:1577: if err := os.MkdirAll(archiveDir, 0o755); err != nil {
+boatstack/delivery.go:1581: if err := os.Rename(featureDir, destination); err != nil {
+boatstack/denial_ledger.go:96: if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+boatstack/denial_ledger.go:99: _ = os.WriteFile(path, value, 0o644)
+boatstack/denial_ledger.go:128: _ = os.Remove(path)
+boatstack/detached.go:208: if err := os.MkdirAll(stateRoot, 0o755); err != nil {
+boatstack/detached.go:211: return os.WriteFile(registryPath(stateRoot), raw, 0o644)
+boatstack/detached_migration.go:214: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+boatstack/detached_migration.go:217: temporary, err := os.MkdirTemp(filepath.Dir(target), ".boatstack-feature-import-*")
+boatstack/detached_migration.go:221: defer os.RemoveAll(temporary)
+boatstack/detached_migration.go:232: return os.MkdirAll(destination, 0o755)
+boatstack/detached_migration.go:256: return os.Rename(temporary, target)
+boatstack/engagement.go:174: if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+boatstack/engagement.go:191: if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+boatstack/export.go:750: if err := os.Remove(target); err != nil {
+boatstack/init.go:657: if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil {
+boatstack/init.go:875: return os.WriteFile(path, []byte(strings.TrimSpace(text)+"\n"), 0o644)
+boatstack/init_transaction.go:17: backup, err := os.MkdirTemp("", "boatstack-init-rollback-*")
+boatstack/init_transaction.go:23: _ = os.RemoveAll(backup)
+boatstack/init_transaction.go:57: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+boatstack/init_transaction.go:63: return os.MkdirAll(target, info.Mode().Perm())
+boatstack/init_transaction.go:72: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+boatstack/init_transaction.go:75: return os.WriteFile(target, value, info.Mode().Perm())
+boatstack/init_transaction.go:91: if err := os.RemoveAll(filepath.Join(snapshot.repo, entry.Name())); err != nil {
+boatstack/init_transaction.go:99: return os.RemoveAll(snapshot.backup)
+boatstack/init_transaction.go:106: if err := os.RemoveAll(snapshot.backup); err != nil {
+boatstack/insight.go:532: if err := os.MkdirAll(root, 0o755); err != nil {
+boatstack/insight.go:535: temporary, err := os.MkdirTemp(root, ".insight-*")
+boatstack/insight.go:539: defer os.RemoveAll(temporary)
+boatstack/insight.go:552: if err := os.Rename(temporary, directory); err != nil {
+boatstack/insight.go:640: if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil {
+boatstack/insight.go:644: file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+boatstack/insight.go:648: defer os.Remove(lock)
+boatstack/insight.go:655: _ = os.Remove(lock)
+boatstack/installation_repair.go:455: if err := os.MkdirAll(directory, 0o700); err != nil {
+boatstack/integrations.go:85: if err := os.MkdirAll(filepath.Dir(installRoot), 0o755); err != nil {
+boatstack/internal/deliverycontrol/codinglog.go:25: if err := os.MkdirAll(dir, 0o755); err != nil {
+boatstack/internal/deliverycontrol/codinglog.go:32: file, err := os.OpenFile(filepath.Join(dir, codingLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+boatstack/internal/deliverycontrol/commandlog.go:68: if err := os.MkdirAll(dir, 0o755); err != nil {
+boatstack/internal/deliverycontrol/commandlog.go:75: file, err := os.OpenFile(filepath.Join(dir, commandLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+boatstack/internal/deliverycontrol/trajectorylog.go:25: if err := os.MkdirAll(dir, 0o755); err != nil {
+boatstack/internal/deliverycontrol/trajectorylog.go:32: file, err := os.OpenFile(filepath.Join(dir, trajectoryLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+boatstack/mutation.go:163: if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil {
+boatstack/mutation.go:167: file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+boatstack/mutation.go:171: defer os.Remove(lock)
+boatstack/mutation.go:178: _ = os.Remove(lock)
+boatstack/mutation.go:443: if rmErr := os.Remove(op.native); rmErr != nil && !os.IsNotExist(rmErr) {
+boatstack/mutation.go:527: _ = os.Remove(native)
+boatstack/operation.go:138: _ = os.RemoveAll(legacy)
+boatstack/operation.go:238: if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil {
+boatstack/operation.go:242: file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+boatstack/operation.go:246: defer os.Remove(lock)
+boatstack/operation.go:253: _ = os.Remove(lock)
+boatstack/planning.go:220: if err := os.MkdirAll(directory, 0o755); err != nil {
+boatstack/planning.go:223: temporary, err := os.CreateTemp(directory, ".boatstack-planning-*")
+boatstack/planning.go:228: defer os.Remove(temporaryPath)
+boatstack/pr.go:1450: temporary, err := os.CreateTemp("", "boatstack-pr-body-*.md")
+boatstack/pr.go:1455: defer os.Remove(temporaryPath)
+boatstack/pr.go:1526: f, err := os.OpenFile(outPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+boatstack/recovery.go:650: if err := os.MkdirAll(destParent, 0o700); err != nil {
+boatstack/recovery.go:653: if err := os.Rename(directory, dest); err != nil {
+boatstack/recovery.go:657: if rmErr := os.RemoveAll(directory); rmErr != nil {
+boatstack/recovery.go:688: return os.MkdirAll(target, 0o755)
+boatstack/recovery.go:701: if mkErr := os.MkdirAll(filepath.Dir(target), 0o755); mkErr != nil {
+boatstack/recovery.go:704: return os.WriteFile(target, data, info.Mode().Perm())
+boatstack/runtime.go:265: if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+boatstack/runtime.go:268: return os.WriteFile(path, value, mode)
+boatstack/runtime_cache.go:140: if err := os.MkdirAll(directory, 0o755); err != nil {
+boatstack/runtime_cache.go:148: temporary, err := os.CreateTemp(directory, ".boatstack-runtime-*")
+boatstack/runtime_cache.go:153: defer os.Remove(temporaryPath)
+boatstack/runtime_cache.go:265: _ = os.Remove(binaryPath.path)
+boatstack/runtime_cache.go:266: _ = os.Remove(manifestPath.path)
+boatstack/runtime_cache.go:330: if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
+boatstack/runtime_cache.go:334: file, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+boatstack/runtime_cache.go:338: os.Remove(lockPath)
+boatstack/runtime_cache.go:342: os.Remove(lockPath)
+boatstack/runtime_cache.go:345: return func() { _ = os.Remove(lockPath) }, nil
+boatstack/runtime_cache.go:356: _ = os.Remove(lockPath)
+boatstack/update_publication.go:364: temporary, err := os.CreateTemp("", "boatstack-update-pr-*.md")
+boatstack/update_publication.go:369: defer os.Remove(temporaryPath)
+boatstack/visual_publisher.go:90: bodyFile, err := os.CreateTemp("", "boatstack-evidence-comment-*.md")
+boatstack/visual_publisher.go:95: defer os.Remove(bodyPath)
+boatstack/workspace.go:277: if err := os.MkdirAll(parent, 0o755); err != nil {
+boatstack/workspace.go:280: temporary, err := os.MkdirTemp(parent, ".boatstack-workspace-transfer-")
+boatstack/workspace.go:284: defer os.RemoveAll(temporary)
+boatstack/workspace.go:302: return os.MkdirAll(target, info.Mode().Perm())
+boatstack/workspace.go:308: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+boatstack/workspace.go:311: return os.WriteFile(target, value, info.Mode().Perm())
+boatstack/workspace.go:316: return os.Rename(temporary, destination)
+boatstack/workspace.go:392: _ = os.RemoveAll(destination)
+boatstack/workspace.go:397: _ = os.RemoveAll(destination)
+boatstack/workspace.go:523: if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+```
+
+## External-effect dispatch/intent sites
+
+```text
+boatstack/capture.go:43: command := exec.Command("sh", "-c", request.Command)
+boatstack/command.go:43: command := exec.Command(name, arguments...)
+boatstack/command.go:56: command := exec.Command(name, arguments...)
+boatstack/hooks.go:64: command = exec.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path, "-HostName", host)
+boatstack/hooks.go:67: command = exec.CommandContext(ctx, "bash", path, host)
+boatstack/init.go:33: command := exec.Command("git", append([]string{"-C", repo}, arguments...)...)
+boatstack/integrations.go:16: command := exec.Command(name, arguments...)
+boatstack/integrations.go:24: command := exec.Command(name, arguments...)
+boatstack/migrate_effect_grade.go:69: cmd := exec.Command("sh", "-c", command)
+boatstack/pr.go:1447: if _, err := gitCommand(repo, "push", "--set-upstream", "origin", context.HeadBranch); err != nil {
+boatstack/run.go:161: if _, err := runGitCommand(repo, "fetch", "origin"); err != nil {
+boatstack/update_publication.go:354: if _, err := gitCommand(repo, arguments...); err != nil {
+boatstack/update_publication.go:357: if _, err := gitCommand(repo, "commit", "-m", "chore: update Boatstack to "+preview.Version); err != nil {
+boatstack/update_publication.go:361: if _, err := gitCommand(repo, "push", "--set-upstream", "origin", preview.HeadBranch); err != nil {
+```
diff --git a/docs/architecture/boatstack-v2-closure-report.md b/docs/architecture/boatstack-v2-closure-report.md
new file mode 100644
index 0000000..851d8b0
--- /dev/null
+++ b/docs/architecture/boatstack-v2-closure-report.md
@@ -0,0 +1,86 @@
+# Boatstack V2 replacement closure
+
+Base revision: `c5b5e10cdcf4d97b645d705cb164e762acf93ff1`
+Replacement mode: flag day; no V1 compatibility or state migration
+
+This report binds the V2 implementation to the frozen V1 inventory. It is not a
+claim that old APIs remain available.
+
+## ZCA translation and value
+
+The rewrite ships two logical slices together. Slice 1 is one authoritative
+kernel over one canonical snapshot and transition catalog. Slice 2 is the CLI,
+hook, SDK/MCP, shell, and host projection of that same kernel. The immediate
+value is the removal of independently reconstructed lifecycle and effect
+authority while keeping the product workflows available through V2 semantics.
+
+## Deleted authority
+
+The [frozen V1 inventory](boatstack-v1-authority-inventory.md) identified and the
+rewrite deletes:
+
+- 84 direct lifecycle/completion decision declarations across nine independent
+ owner files;
+- 388 supporting control-authority declarations across 22 additional files;
+- 106 direct filesystem mutation sites;
+- 14 external-effect dispatch or mutation-intent sites;
+- the entire `internal/deliverycontrol` shadow graph and all V1 migration,
+ coexistence, state-repair, host-state-machine, and fallback code.
+
+The conservative removed V1 managed-effect surface is therefore 120 sites.
+V2's static source inventory fails if an `os` writer exists outside
+`internal/effects`, if a command boundary exists outside the exact plant/effect
+allowlist, if a production file is unclassified, or if the deleted shadow
+controller is imported or recreated.
+
+## Executable replacement
+
+The runtime has 17 controlling facets and 61 semantic transitions:
+
+| Class | Count |
+| --- | ---: |
+| authority | 9 |
+| owned-local | 30 |
+| owned-external | 2 |
+| recovery | 7 |
+| observed-external | 13 |
+
+The [generated table](boatstack-v2-transition-catalog.md),
+[generated Mermaid graph](boatstack-v2-transition-catalog.mmd),
+[Locus safety model](boatstack-v2-locus-safety.json), and
+[Locus liveness model](boatstack-v2-locus-liveness.json) come from the same
+registry used by the supervisor and engine. Golden tests reject byte drift and
+require both formal alphabets to equal all 61 executable transitions.
+
+Every effect follows observe, resolve, admit, lock, journal, execute,
+re-observe, verify, and receipt. Runtime identity includes the exact executing
+binary path and fingerprint. Workspace transfer stages both source and
+destination controller states. Clone-family journals and receipts use repository
+plus Git-common identity, while worktree state remains separately partitioned.
+Repository policy is part of the canonical control projection: high-risk changes
+are derived from the default-branch diff and live working tree, require human
+review when configured, refuse visual attachment when disabled, and prevent a
+required-visual terminal until revision-bound evidence exists.
+
+## Historical and live evidence
+
+The historical corpus contains 22 typed fixtures. It covers every PR from #172
+through #185 and the additional ambiguity, interruption, stale-runtime,
+publication, workspace, configuration, and goal-terminal failure classes named
+in the V2 specification.
+
+Live integration tests exercise embedded and detached installation, attach and
+detach, two-clone identity separation, exact runtime update, linked-worktree
+authority transfer and cleanup, strict configuration, shared guard behavior,
+journal restart recovery, receipt/event generation, idempotency, and
+postcondition failure. Repository tests execute an offline checksum-bound
+install and update through the shipped binary.
+
+The generated Locus phase graph is a conservative source-phase by target-phase
+expansion. Formal checks found the forbidden effect state unreachable, proved
+the exact-admission guard essential, found all eight reachable stable phases
+coreachable, and accepted the event-completeness discharge. The claim remains
+advisory: the 17-facet predicates, reducer branches, operating-system behavior,
+and external-provider truth remain bound to executable source, fault,
+integration, repository, and platform tests. The exact result IDs and blocked
+verified frontier are recorded in the technical specification.
diff --git a/docs/architecture/boatstack-v2-kernel.md b/docs/architecture/boatstack-v2-kernel.md
new file mode 100644
index 0000000..e1b776d
--- /dev/null
+++ b/docs/architecture/boatstack-v2-kernel.md
@@ -0,0 +1,779 @@
+# Boatstack V2 authoritative delivery kernel
+
+Status: normative implementation specification
+Base revision: `c5b5e10cdcf4d97b645d705cb164e762acf93ff1` (`origin/main`, including PR #185)
+Rewrite branch: `rewrite/v2-delivery-kernel`
+Scope: one flag-day rewrite and one final pull request; no merge is authorized by this document
+
+> Boatstack V2 is a flag-day replacement. Existing machine-local state may be
+> discarded and regenerated. No V1 runtime remains after cutover.
+
+This document is the source of truth for the V2 implementation. If code and this
+document disagree, the discrepancy is a release blocker: either the code must be
+corrected or this document must be deliberately amended with matching tests.
+The [replacement closure report](boatstack-v2-closure-report.md) binds its frozen
+V1 counts to the implemented V2 evidence.
+
+## ZCA projection and decisions
+
+The existing implementation is projected into two minimal, jointly shipped
+slices. They are logical ownership boundaries, not rollout phases.
+
+| Slice | Domain | Structure | Goal | Operator | Immediate value |
+| --- | --- | --- | --- | --- | --- |
+| 1. Authoritative kernel | Repository-local delivery control | One evidence-backed composite snapshot and one transition catalog | Every managed state has a safe path to progress, recovery, authority frontier, or terminal | Observe, resolve, admit, execute, verify, record, recover | One executable delivery law replaces distributed state and authority reconstruction |
+| 2. Product surfaces | Shipped CLI, hooks, SDK/MCP, hosts, and renderers | One adapter protocol projected from kernel decisions and prescriptions | Every consumer observes and requests the same semantics | Decode, invoke, render | Hosts stop acting as independent controllers while useful workflows remain available |
+
+Canonical form for slice 1: one domain, the `Snapshot` schema, the configured
+`Goal`, and the `Engine.Apply` operator. Canonical form for slice 2: one domain,
+the `SurfaceRequest`/`SurfaceResponse` schema, the same goal, and the adapter
+projection operator.
+
+Known constraints are the flag-day cutover, explicit effectful identity,
+repository-owned policy and durable evidence, fail-closed ambiguity, inertness
+outside managed scope, two logical slices in one PR, and no V1 authority after
+cutover. Unknown constraints to close with code and fixtures are the complete
+reader/writer/surface inventory, provider settlement behavior after an uncertain
+external request, platform-specific atomic filesystem behavior, and the exact
+set of SDK/MCP hosts present at cutover. Optimizer weights, an Observatory
+product integration, and UI presentation are non-critical to this rewrite.
+
+The implementation must answer these technical questions without asking for a
+product decision: which sites control state, which resources each effect owns,
+which external outcomes can be proved, and which platform primitive provides
+atomic replacement. A user decision is required only if a new transition would
+change who may authorize an effect or what counts as a delivery terminal.
+
+## 1. Product contract
+
+Boatstack is a repository-local supervisory controller for software delivery by
+humans and coding agents. The agent writes software. Boatstack deterministically
+observes the delivery plant, retains explicit identity, establishes engagement,
+resolves legal managed events, binds authority, owns transactional effects,
+verifies postconditions, records receipts, recovers from interruption, and
+establishes whether the configured goal is terminal.
+
+The repository owns policy and committed evidence. The kernel owns delivery
+decisions. CLI, hooks, Cursor, Codex, Claude Code, Gemini CLI, SDK, MCP, and future
+hosts are adapters. They never infer lifecycle, identity, authority, effect
+permission, recovery, or completion independently.
+
+Observable behavior is classified only as follows:
+
+- **PRESERVE:** installation, initialization, update, doctor, embedded/detached/
+ hybrid operation, deterministic runtime hydration, explicit repository and
+ worktree identity, planning and approval, autonomy, workspaces, build/test/
+ review/change/journey gates, goal-driven run, interruption and resume,
+ amendments, invalid-plan recovery, publication and correction, merged
+ terminals, visual evidence, safety hooks, configuration, cleanup/reap,
+ abandonment, portable host guidance, evidence, receipts, and passive
+ retrospectives.
+- **NORMALIZE:** every preserved behavior crosses the V2 observation, resolution,
+ admission, effect, verification, and receipt contracts. Commands and output
+ text may change. Machine state, schemas, file layouts, Go APIs, and adapter
+ protocols may change without compatibility shims. Visual capture is the
+ `evidence.visual.attach` transition. Historical insight extraction is the
+ read-only retrospective projection.
+- **REMOVE:** ambient engagement, path-only effect identity, first-match alias
+ selection, inferred authority, duplicated host logic, unverified success,
+ state-repairing reads, V1 state migration, runtime fallback, independent
+ insight/capture writers, and every other accidental or unsafe V1 behavior.
+
+There is deliberately no backward-compatibility promise. Historical behavior is
+evidence about product value and failure classes, not a language or API that V2
+must refine. Existing repositories may be reinstalled or reattached. Committed
+plans, specifications, approvals, evidence, PR briefs, configuration, and policy
+are read as product inputs when they satisfy V2 schemas; accidental V1 machine
+state is discarded.
+
+## 2. Historical failure synthesis
+
+The history through PR #185 converges on one structural diagnosis:
+
+> Boatstack V1 distributed transition authority across independently reconstructed,
+> control-insufficient projections of lifecycle, engagement, workspace,
+> publication, configuration, runtime, and host state.
+
+Local repairs repeatedly added a distinction or precedence rule to one resolver
+while another resolver, renderer, writer, or host retained a different model.
+The V2 class-eliminating change is not another precedence rule. It is one runtime
+snapshot, one transition registry, one supervisor, one admission path, one effect
+boundary, and one independently verified receipt protocol.
+
+The detailed episode inventory and fixture mapping are in Appendix A. The
+structural classes carried into V2 are:
+
+- control-insufficient state projection;
+- split transition, identity, configuration, and completion authority;
+- non-injective repository/worktree reverse lookup;
+- ambient engagement and saved-plan leakage;
+- workspace, publication, Git ancestry, and goal-terminal conflation;
+- stale or self-invalidating runtime/configuration mutation;
+- non-atomic multi-resource and externally uncertain effects;
+- surface, shell, and host prescription divergence;
+- missing recovery coreachability and incomplete event/writer inventories.
+
+The old implementation is permitted only as a fixture source and historical
+oracle while developing this branch. It is not linked into the final runtime.
+
+## 3. Formal discrete-event system model
+
+Let the plant state be:
+
+```text
+x_t = (
+ invocation identity,
+ engagement,
+ repository and worktree state,
+ delivery state,
+ plan and approval state,
+ configuration authority,
+ runtime state,
+ verification state,
+ publication and CI state,
+ recovery state,
+ active transaction state
+)
+```
+
+The read-only observer produces `o_t = H(x_t, evidence_t)`. Canonicalization
+produces the control-sufficient `z_t = P(o_t)`. Events are partitioned into
+controllable Boatstack events `Sigma_c` and uncontrollable observed plant events
+`Sigma_u`. For goal `g` and authority set `a`, the supervisor returns the
+admissible set `S(z_t, g, a) subseteq Sigma_c`; deterministic policy selects at
+most one prescribed event. Execution is accepted only as:
+
+```text
+z_t -- prescribe(e) --> admission
+ -- execute(e) --> unverified plant
+ -- observe --> o_t+1
+ -- verify(target(e)) --> z_t+1 + immutable receipt
+```
+
+The protocol phases are `DORMANT`, `OBSERVED`, `PRESCRIBED`, `ADMITTED`,
+`EXECUTING_LOCAL`, `EXECUTING_EXTERNAL`, `VERIFYING`, `ACTIVE`, `RECOVERY`,
+`UNRESOLVED`, `FRONTIER`, `TERMINAL`, and `ABANDONED`. These phases describe the
+kernel protocol; orthogonal state facets below describe the plant.
+
+Marked outcomes are `FRONTIER`, `TERMINAL`, and `ABANDONED`. `RECOVERY` and
+`UNRESOLVED` must have bounded registered paths to a marked outcome or back to
+`ACTIVE`. Forbidden counterfactual states are `UNADMITTED_EFFECT` and
+`ACCEPTED_MIXED_EPOCH`.
+
+Normative properties within declared managed scope:
+
+1. Safety: forbidden states and events are unreachable.
+2. Inertness: ordinary repository work outside active managed scope is not
+ blocked or mutated.
+3. Coreachability: every reachable nonterminal managed state can reach the goal,
+ a typed recovery path, an authority frontier, or safe abandonment/refusal.
+4. Projection fidelity: `P(x1) = P(x2)` implies equal admissible controllable
+ event sets. A distinguishing legal action requires a distinguishing facet.
+5. Determinism: identical snapshot, goal, authority, and request yield identical
+ decisions and typed prescriptions.
+6. Resource preservation: missing, stale, ambiguous, conflicting, or unknown
+ evidence never grants delete, publish, overwrite, or advance authority.
+7. Event completeness: every controlling reader, writer, resolver, renderer,
+ surface, and effect is classified by the registry or a proved noncontrolling
+ exclusion.
+
+The runtime transition catalog is the model used for reachability. Tests derive
+the graph from executable registry entries; no manually mirrored graph exists.
+
+## 4. State and identity model
+
+`Snapshot` is an immutable typed composite. It is never represented by one flat
+enum and controlling multi-state facts are never booleans.
+
+The executable catalog declares exactly 17 controlling facets:
+
+| Facet | Required distinctions |
+| --- | --- |
+| Phase | dormant, observed, prescribed, admitted, local/external execution, verifying, active, recovery, unresolved, frontier, terminal, abandoned |
+| Topology | embedded, detached, hybrid |
+| Engagement | dormant, command-scoped, active, stale, conflicting, invalid |
+| Delivery | uninitialized, planning, approved, active slice, gates satisfied, published, amendment, invalid, recovery, discarded, terminal |
+| Workspace | absent, cut, active, published, landed, abandoned, attention-required |
+| Plan | absent, draft, valid, approved, locked, stale, invalid, amendment-required |
+| Configuration | verified, stale, divergent, conflicting, unsupported |
+| Configuration policy | plan-approval authority, visual-evidence requirement, independent-review policy plus derived high-risk-change fact, external-effect authority, enabled hosts |
+| Runtime | absent, hydrating, verified, stale, invalid, conflicting, wrong source/version, partially published |
+| Publication | none, candidate, open, closed-unmerged, merged, unavailable, conflicting, published-not-landed |
+| Verification | unverified, current, stale, failed, unresolved |
+| Recovery | none, resumable, rollback, compensation, reconcile, escalated |
+| Transaction | none, staged, local-applied, external-uncertain, verifying, committed, compensating |
+| Recovery info | exact transaction, cause, source phase, permitted exits, budget, resumption target |
+| Transaction info | exact transition, status, resource digests, external possibility |
+| Terminal | nonterminal, established, stale, unknown, conflicting |
+| Goal | target kind, subject delivery, evidence predicate, frontier policy |
+
+Every controlling fact is a `Fact[T]` containing value/status, evidence source,
+revision or fingerprint, observation time when freshness matters, and explicit
+unknown/conflict information. `unknown`, `absent`, `false`, `stale`, `ambiguous`,
+and `conflicting` are distinct values.
+
+Every effectful entry point requires an `InvocationContext` carrying repository,
+Git-common, worktree, branch/ref, controller, topology, invoking path, exact
+executing-runtime path/fingerprint, host identity, and correlation ID. Effectful identity is never reverse-derived
+from a controller path, plan path, generated file, branch name, translated CWD,
+or first registry match. Read-only discovery may return candidates and ambiguity;
+mutation refuses ambiguity before acquiring an effect lock.
+
+## 5. Observation model
+
+`plant.Observer.Observe(ctx, ObservationRequest)` is the only read boundary that
+creates snapshots. The request carries the exact invocation. Only the engine's
+immediate post-effect verification may additionally exclude the current
+admission's pending journal; correlation IDs never hide interrupted work.
+It reads Git, repository/worktree layout, the strict repository configuration,
+the selected runtime bytes, durable delivery state, detached binding, and active
+transaction/recovery journals. Provider observation is an explicit registered
+`publication.observe` or `publication.reconcile` effect; the resulting durable
+publication fact is then read through this observer.
+
+Configuration authority fingerprints the strict decoded schema-2 value in
+canonical JSON form, including canonical defaults and host-set ordering. JSON
+formatting, object-key order, and checkout line endings cannot change authority;
+an actual policy or command change does. Exact file bytes remain transaction and
+rollback material, but they are not semantic configuration identity.
+
+Observation never writes, repairs, hydrates, locks for mutation, or chooses a
+transition. Each provider returns typed known, absent, unknown, stale, and
+conflicting facts with evidence. External provider failure remains `unknown` and
+is not collapsed to false or complete.
+
+`model.Canonicalize(observation)` validates cross-facet reachability constraints
+and fingerprints canonical bytes. Workspace status, next status, cleanup,
+activation, safety, publication, and adapters consume this snapshot rather than
+recomputing lifecycle subsets.
+
+The snapshot fingerprint covers every fact used by source predicates, authority,
+admission, effects, postconditions, and goal termination. Display-only facts are
+explicitly excluded and may not become controlling without a schema change.
+
+## 6. Event vocabulary
+
+Events have stable semantic IDs independent of CLI verbs or Go function names.
+They are one of:
+
+- `owned-local` (`Sigma_c`): Boatstack can perform a local transactional effect;
+- `owned-external` (`Sigma_c`): Boatstack can request an external effect under a
+ preview/authority/idempotency/settlement protocol;
+- `authority` (`Sigma_c`): a human, policy, or autonomy receipt changes the
+ admitted set;
+- `observed-external` (`Sigma_u`): the plant changed outside Boatstack;
+- `recovery` (`Sigma_c`): a bounded resume, rollback, reconcile, escalation, or
+ abandonment event. External effects that have no proven inverse reconcile or
+ escalate; V2 does not register a generic fake compensation;
+- `query`: a read-only surface operation that cannot alter kernel state and is
+ not counted as a managed transition.
+
+Uncontrollable events are incorporated only by re-observation. A host may report
+an observation trigger but may not assert the resulting fact. Queries such as
+status, next-status, doctor, and event streaming return projections and never
+gain event authority merely because they are commands.
+
+## 7. Transition registry
+
+The initial V2 catalog contains **61 semantic events**. This count is generated
+from code and must remain synchronized with this table.
+
+| Family | Count | Required IDs |
+| --- | ---: | --- |
+| Invocation and engagement | 6 | `engagement.begin`, `engagement.renew`, `engagement.release`, `invocation.rebind`, `repository.attach`, `repository.detach` |
+| Installation, runtime, configuration | 8 | `runtime.hydrate`, `runtime.replace`, `runtime.reconcile`, `configuration.initialize`, `configuration.mutate`, `configuration.reconcile`, `installation.initialize`, `installation.update` |
+| Goal and plan | 9 | `goal.configure`, `plan.create`, `plan.validate`, `plan.approve`, `plan.activate`, `plan.amend`, `plan.approve-amendment`, `plan.invalidate`, `plan.abandon` |
+| Workspace | 8 | `workspace.cut`, `workspace.sync`, `workspace.activate`, `workspace.publish`, `workspace.cleanup`, `workspace.reap`, `workspace.abandon`, `workspace.reconcile` |
+| Delivery gates and evidence | 8 | `gate.build.record`, `gate.test.record`, `gate.review.record`, `gate.change.record`, `gate.journey.record`, `evidence.visual.attach`, `evidence.approval.revoke`, `delivery.slice.advance` |
+| Publication | 6 | `publication.preview`, `publication.execute`, `publication.observe`, `publication.reconcile`, `publication.correct`, `publication.abandon` |
+| Recovery | 3 | `recovery.resume`, `recovery.rollback`, `recovery.escalate` |
+| Observed external | 13 | `external.files-changed`, `external.head-changed`, `external.branch-changed`, `external.runtime-disappeared`, `external.configuration-drifted`, `external.lease-expired`, `external.host-interrupted`, `external.ci-completed`, `external.pr-opened`, `external.pr-updated`, `external.pr-closed`, `external.pr-merged`, `external.provider-unavailable` |
+
+Every `Transition` declaration contains: ID and schema version; source predicate;
+event class and controllability; goal relevance; required identity, authority,
+evidence, and fingerprints; admission predicate; owned resources; local/external
+effects; idempotency binding; typed prescription; expected target predicate;
+independent verifier; interruption points; rollback/compensation; reversibility;
+terminal effect; recovery transition; privacy and telemetry classifications; and
+consumer-neutral cost class.
+
+The registry enforces unique IDs, complete effect ownership, valid recovery
+targets, verifier presence, terminal consistency, prescription renderability,
+and reachability. CLI verbs and handlers map to IDs; they are not IDs. POSIX,
+PowerShell, SDK/MCP, and host instructions are renderings of the same typed
+prescription. The registry is executable runtime authority, not a shadow model.
+
+The checked [catalog table](boatstack-v2-transition-catalog.md) and
+[Mermaid graph](boatstack-v2-transition-catalog.mmd) are deterministic
+projections of this registry. Golden tests reject either artifact when it drifts.
+
+## 8. Supervisory control law
+
+`supervisor.Resolve(snapshot, goal, authority, optionalObservedEvent)` is pure and
+deterministic. It evaluates the executable registry and returns exactly one:
+
+- `PRESCRIBED`: one exact next transition and prescription;
+- `TERMINAL`: goal predicate established by current terminal evidence;
+- `FRONTIER`: a genuine human/reasoning authority decision is required;
+- `BLOCKED`: a known recoverable condition plus its registered recovery event;
+- `REFUSED`: the request is outside admissible managed behavior;
+- `UNRESOLVED`: evidence is insufficient or contradictory.
+
+Precedence is invariant, not surface policy: recovery outranks ordinary slice
+position; configured terminal outranks publication convenience; an active
+managed delivery outranks weak ancestry/provider projections; durable
+publication evidence is required before ancestry can establish landing;
+repository presence is not engagement; a saved plan is not active authority.
+
+Resolution never fabricates progress. If several controllable events remain
+equally admissible after declared deterministic priority, the answer is
+`FRONTIER` or `UNRESOLVED`, never map-order selection or first-match behavior.
+
+## 9. Admission and authority model
+
+Knowledge, precondition evidence, authority, and proof of effect are four
+separate objects. `Admission` binds the exact transition ID/version, snapshot
+fingerprint, invocation identity, goal and plan lock, observation/configuration
+fingerprints, source revision, branch/worktree, authority receipt, provider
+preview, idempotency key, and expiry.
+
+`admission.Admit` re-observes or compares current controlling fingerprints before
+any writer runs. A stale prescription fails without mutation. Human approval,
+autonomy, repository policy, and provider authority are typed, scoped, expiring,
+and non-substitutable unless the transition explicitly allows alternatives.
+
+Hooks, CLI, renderers, SDK/MCP, and hosts may carry explicit caller attestations,
+but cannot derive repository authority, weaken admission, cache authority past
+expiry, or reinterpret it. Human and provider receipts are command-scoped audit
+attestations, not operating-system authentication; the external provider still
+must settle the requested operation. Repository-policy authority is derived only
+inside the facade from the exact canonical configuration evidence. Ordinary work
+outside active scope remains inert. Managed work fails closed when identity,
+evidence, or authority is missing, stale, ambiguous, or conflicting.
+
+## 10. Effect and transaction model
+
+Every managed writer implements a registry-owned effect port and is unreachable
+without a valid `Admission`. Status, renderers, hooks, parsing, observation,
+validation, path resolution, and safety classification are read-only.
+
+Local transitions follow one journaled protocol:
+
+1. validate admission against the exact source snapshot;
+2. acquire a partition-scoped lock keyed by repository/worktree/resources;
+3. capture exact prior bytes and external preconditions;
+4. stage all local writes;
+5. verify staged representations;
+6. install effects in declared order;
+7. install the authoritative binding/state last;
+8. re-observe independently;
+9. verify the target predicate;
+10. append the immutable receipt and commit journal;
+11. release the lock.
+
+Failure restores exact prior bytes where reversible. A mixed epoch is never an
+accepted snapshot. An irreversible or unknown external outcome produces a typed
+reconciliation state and preserves local resources.
+
+Clone-family journals, locks, receipts, and process events use a fixed external
+flow root keyed by repository ID and Git-common ID. Worktree state remains
+partitioned by exact worktree ID. `workspace.cut` stages a parked source state
+and an authoritative destination state, then verifies from the destination.
+Cleanup verifies the preserved source checkout, removes the destination from a
+neutral directory, and transfers terminal state back to that source.
+
+External effects use `preview -> authority -> execute -> observe -> reconcile`.
+Request acceptance and effect settlement are distinct. Idempotency binds exact
+request bytes and provider identity. An unknown outcome is not blindly retried;
+the kernel observes by idempotency/correlation key or enters attention.
+
+No successful command may invalidate the evidence needed to verify its own
+target state. Telemetry and ancillary services are never part of commit success.
+
+## 11. Verification and receipt model
+
+The effect implementation cannot certify itself. A transition's verifier reads a
+fresh observation and evaluates the catalog's target predicate. Success requires
+both effect completion and postcondition truth. Otherwise the engine enters the
+declared rollback, compensation, or recovery path and returns non-success.
+
+`TransitionReceipt` is immutable and content-addressed. It binds schema, flow
+and sequence IDs, transition ID/version, admission and goal IDs, source and
+target fingerprints, authority classes, idempotency key, timestamps/duration,
+outcome, postcondition verifier, recovery/terminal classification, and a
+privacy-safe failure class. The admission ID transitively binds invocation,
+authority receipts, parameters, and expiry. A receipt never embeds arbitrary
+output, source, prompts, or secrets.
+
+Receipts are the only accepted evidence that a managed transition occurred.
+Plan approvals, publication settlement, and terminal claims point to exact
+receipts. Idempotency replay validates the stored receipt identity, returns it
+with a fresh current snapshot, and never repeats the effect.
+
+Build, test, review, change, and journey gates copy and independently re-read a
+strict schema-1 passed-evidence document whose bytes, gate, producer, completion
+time, and source revision are bound by admission. The admission also carries the
+observer-derived product worktree fingerprint. Kernel-generated plans,
+approvals, evidence, and publication previews are excluded from that product
+fingerprint so recording proof cannot invalidate itself; configuration remains
+included. Build and test additionally execute the exact configured command
+inside the admitted effect boundary, reject commands classified as destructive
+or managed bypasses, persist no command output, and install no gate evidence on
+a nonzero exit.
+
+## 12. Recovery model
+
+Recovery is a normal registry family. Every transition declares interruption
+points, recovery transition, reversibility, authority, and owned resources. The
+journal records the exact interrupted transaction and resources; observation
+derives its bounded resume, rollback, reconcile, compensation, or escalation
+set and resumption target.
+
+On startup and before a new mutation, observation inspects transaction journals
+and external correlation keys. `RECOVERY` outranks slice and publication status.
+A recovery resolver may prescribe only the transition declared by the interrupted
+effect or a safe escalation/abandonment path.
+
+No damaged artifact grants authority. Unknown or contradictory state preserves
+workspaces, unpublished commits, evidence, and external uncertainty. Recovery
+decisions name the controlling reason and registered recovery or termination
+path. Repair budgets are monotonic and bounded; exhaustion produces `FRONTIER`
+or safe abandonment rather than an infinite retry loop.
+
+## 13. Goal and terminal semantics
+
+`Goal` is configured before managed execution and identifies the subject delivery
+and one terminal predicate: approved plan, verified implementation, open/updated
+PR, merged delivery, or safely abandoned delivery. It also declares required
+evidence freshness and whether a frontier is acceptable as a stopped outcome.
+
+Terminal is evidence, not a local phase label. Examples:
+
+- approved-plan terminal requires the exact plan lock and current approval;
+- verified terminal requires declared gates against the current source revision;
+- PR terminal requires durable provider evidence for the current publication;
+- merged terminal requires durable merged publication evidence plus the configured
+ repository/workspace relation;
+- abandonment requires explicit authority and a receipt proving resource policy.
+
+Local green tests, ancestry equality, workspace cleanup eligibility, saved plan
+presence, or an agent's completion assertion cannot establish a goal. External
+unknown never establishes terminal. Once terminal, unrelated local projections
+cannot resume the flow without a new goal or registered correction transition.
+
+## 14. Package and dependency architecture
+
+All V2 implementation lives below `boatstack/`; the top-level `boatstack` package
+is a product facade with no independent durable state or decision law.
+Dependencies point downward in this table and are acyclic.
+
+| Package | Owns | Public boundary and verifier | Allowed dependencies | Forbidden dependencies |
+| --- | --- | --- | --- | --- |
+| `internal/kernel/model` | typed facts, identity, snapshot, goal, fingerprints | constructors/canonical encoding; schema and invariant tests | standard library | plant, effects, surfaces, facade |
+| `internal/kernel/catalog` | 61 transition declarations and catalog invariants | read-only registry; uniqueness/completeness/reachability verifier | model | effects implementations, surfaces |
+| `internal/kernel/supervisor` | admissible-set and deterministic outcome law | pure `Resolve`; exhaustive reachable-state/property tests | model, catalog | I/O, effects, surfaces |
+| `internal/kernel/protocol` | prescriptions, admission, receipts, recovery records | typed codecs and content identity verifier | model, catalog | concrete I/O and surfaces |
+| `internal/kernel/durable` | strict machine-state and detached-binding codecs | canonical encode/decode and invariant validation | model, catalog | observation, effects, surfaces |
+| `internal/kernel/ports` | observer, clock, lock, journal, local/external effect ports | compile-time narrow interfaces and fakes | model, protocol | concrete adapters |
+| `internal/kernel/reducer` | the sole durable lifecycle reduction for admitted controllable events | `Apply` plus exhaustive catalog coverage tests | model, catalog, durable, protocol | I/O, plant, surfaces |
+| `internal/kernel/engine` | observe-resolve-admit-execute-reobserve-verify-record orchestration | `Resolve`, `Apply`, `Recover`; protocol/conformance tests | model, catalog, supervisor, protocol, ports | concrete surfaces and host logic |
+| `internal/plant` | Git/worktree identity, layout, configuration, runtime, durable-state and journal observation | one read-only composite observer; fact/fingerprint fixtures | model, protocol, ports, durable codecs | engine decisions, mutating effects, surfaces |
+| `internal/effects` | transactions, local/external effect drivers and recovery | port implementations; fault-injection/postcondition tests | model, catalog, durable, protocol, ports, reducer, shared supervisor command classifier | surfaces and independent lifecycle reduction |
+| `internal/surfaces` | request decoding and decision/prescription rendering | CLI/hook/host/SDK/MCP adapter protocol; golden parity tests | model, protocol, engine facade interfaces | plant/effect implementations, lifecycle logic |
+| top-level `boatstack` | product construction and stable V2 facade | dependency injection and public operations; end-to-end tests | engine, plant, effects, surfaces | independent durable state or alternate decisions |
+| `cmd/boatstack-helper` | process startup and command parsing | parse -> facade request -> render; command tests | top-level facade/surfaces | direct plant writes or workflow decisions |
+| `sdk` | public Go aliases and client | schema-2 request/response and one facade delegate | top-level facade and public aliases | internal decision or effect implementations |
+| `analysis` | passive retrospective API | bounded deterministic report | `internal/retromine` | lifecycle decisions or managed writes |
+
+Pure deterministic helpers may be moved or reused. Package creation is justified
+only by owned state, invariant, plant interface, effect boundary, or surface
+projection. The kernel never imports CLI/hosts; observer never imports writers;
+renderers never import effects; adapters never decide lifecycle; effect packages
+cannot bypass admission; test helpers cannot become production authorities.
+
+## 15. CLI, hook, SDK, MCP, and host-adapter contracts
+
+All surfaces use the same versioned protocol:
+
+```text
+SurfaceRequest {
+ schema_version, operation(resolve|apply|recover|doctor|catalog|events|guard),
+ repository, host, correlation_id, flow_id?, goal?, transition_id?,
+ authority?, parameters?, idempotency_key?, command?
+}
+
+SurfaceResponse {
+ schema_version, operation, goal?, snapshot?, decision?, admission?, receipt?,
+ replayed?, catalog?, events?, doctor?, guard?, error?
+}
+```
+
+The CLI maps verbs to queries or semantic transition IDs and invokes the facade.
+`cmd/boatstack-helper` performs parsing and dispatch only. Hooks make one bounded
+query/admission request and fail according to the returned typed decision; they
+never inspect state files to reconstruct policy.
+
+SDK and MCP expose the protocol, not internal Go packages. The facade resolves
+explicit repository/worktree and executing-runtime identity before observation;
+hosts supply the repository, host, correlation, goal, transition, authority, and
+typed parameters. Cursor, Codex, Claude, Gemini, CLI, and MCP prescriptions are
+projections of one command AST plus host capability data. Host capability can
+affect rendering, never admissibility or target semantics.
+
+POSIX, PowerShell, and supported Git Bash prescriptions are semantic projections
+of one command AST. Golden parity tests compare normalized operations, resources,
+authority prompts, and postconditions rather than fragile whitespace.
+
+Status, next-status, doctor, catalog, guard, and event export are read-only
+queries. Retrospective analysis is passive. Visual evidence enters lifecycle
+state only through `evidence.visual.attach`; no independent insight or capture
+writer remains.
+
+## 16. Process telemetry contract
+
+Receipts are the factual source. The facade exposes a passive JSONL reader,
+`boatstack events [--follow] --format jsonl`, over committed receipt projections.
+Telemetry is consumer-neutral and privacy-safe.
+
+Allowlisted fields are schema version, flow ID, sequence, timestamp, goal ID,
+transition ID, source/target fingerprints, outcome, duration, recovery and
+authority classifications, terminal status, and controlled failure class.
+Prompts, reasoning, source code, diffs, arbitrary command output, secrets,
+environment variables, and user documents are prohibited.
+
+Telemetry read/write failure cannot block, admit, mutate, recover, or change a
+transition. `J_flow`, `J_cost`, summaries, and regret are downstream projections
+of receipts. The kernel contains no optimizer weights and this rewrite does not
+build Observatory.
+
+## 17. Test and formal-property strategy
+
+Tests exercise the runtime catalog, supervisor, engine, and concrete ports. The
+registry generates the reachable graph, event inventory, diagrams, surface
+prescriptions, and completeness expectations. Static source inventory classifies
+every controlling reader, resolver, renderer, surface, and managed writer as one
+registry relation or an explicit noncontrolling exclusion.
+
+Required properties are: safety; inertness/nonblockingness outside scope;
+coreachability inside scope; projection fidelity; deterministic resolution;
+explicit uncertainty; identity fidelity; event and writer completeness; consumer
+parity; postcondition fidelity; interruption safety; idempotency; bounded
+recovery; terminal correctness; preservation under ambiguity; no
+self-invalidating success; no host decisions; no lifecycle decisions outside the
+kernel; and no path-only effect identity.
+
+Reachable-state generation avoids the full facet Cartesian product. Dangerous
+compositions receive exhaustive fixtures; remaining independent dimensions use
+pairwise generation across topology, workspace/Git relation, engagement,
+publication, delivery, authority, configuration, runtime, host, shell, and every
+transaction interruption boundary. External tests cover failure before request,
+unknown after request, settlement before receipt, and restart reconciliation.
+
+### Locus preimplementation disposition
+
+All formal claims below concern assumed design models until implementation binds
+the catalog to code. They are theorem-only or advisory, not live-system proof.
+
+| Claim/operator | Result | Claim level | Remaining obligation/disposition |
+| --- | --- | --- | --- |
+| `practice.root-cause` | distributed transition authority over control-insufficient projections; result `res-b027d5...` | advisory | close with source inventory and historical fixtures |
+| boundary conformance | one exact admitted transition gates every managed effect; seven conformance classes | advisory | bind every surface and writer |
+| `verification.safety-reachability` | `UNADMITTED_EFFECT` and `ACCEPTED_MIXED_EPOCH` unreachable in guarded model | theorem-only | event completeness and code fidelity |
+| `verification.guard-essentiality` | exact-admission guard is essential; removing it yields `DORMANT -> OBSERVED -> PRESCRIBED -> UNADMITTED_EFFECT` | theorem-only | implementation mutation test |
+| `control.nonblockingness` | all 13 live protocol states reachable and coreachable; no blocking state | theorem-only | event completeness |
+| `control.supervisory-rw` | full-observation internal model controllable | theorem-only | bind internal events to catalog |
+| `control.diagnosability` | partial surface projection diagnosable | theorem-only | consumer parity fixtures |
+| `control.supervisory-rw` on partial observation | refused because unobservable events make that operator inapplicable | correct refusal | diagnosability is the applicable surface claim |
+| `verification.conservative-feature-extension` | refused: `intentional-redesign` | correct refusal | none; V2 has no compatibility obligation |
+| `verification.trace-refinement` | corrected abstract protocol refines a minimal control envelope | non-normative theorem-only | not a V2 release gate or V1 compatibility claim |
+
+Derivation `drv-bbc6258499be4e1739a9d344f1d211682476da18be46c4bcee80227ed55f7d82`
+has current claim `theorem-only`. The explicit `verified` frontier terminates as
+`work-remaining`; rank 1 is
+`discharge-obligation:control.nonblockingness:event-completeness`. V2 therefore
+cannot claim verified liveness until the real reader/writer/event/surface
+inventory is bound and accepted.
+
+Capability analysis records three separate dispositions without modifying Locus:
+
+- `verification.event-surface-completeness`: extend verifier coverage over the
+ source-generated runtime catalog (advisory admission `adm-708d...`);
+- `control.projection-fidelity`: a genuinely distinct finite-state operator is
+ warranted because existing safety/refinement operators do not compare action
+ equivalence classes (advisory admission `adm-d641...`);
+- `verification.failure-class-elimination`: compose root cause, safety, guard
+ essentiality, and non-normative control-envelope refinement; no primitive is
+ needed (advisory admission `adm-6770...`).
+
+### Locus postimplementation disposition
+
+The executable registry now deterministically generates the checked
+[safety model](boatstack-v2-locus-safety.json) and
+[liveness model](boatstack-v2-locus-liveness.json). Both contain exactly the 61
+runtime events. The liveness abstraction expands the declared phase predicates
+to 363 inferred stable-phase edges over eight reachable phases; the safety
+model adds one guarded counterfactual edge and `UNADMITTED_EFFECT` state.
+Repository and Go tests reject byte drift or an alphabet mismatch.
+
+Observed Locus runs over those generated artifacts produced:
+
+| Claim/operator | Postimplementation result | Disposition |
+| --- | --- | --- |
+| `verification.safety-reachability` | `UNADMITTED_EFFECT` is unreachable; result `res-e90ff62f70169697c81e851f44fc4f423a0c26ca2d589f69b59b663200f4913e` | accepted finite-model result; advisory claim |
+| `verification.guard-essentiality` | `exact-admission` is essential; removing it admits `DORMANT --publication.execute--> UNADMITTED_EFFECT`; result `res-b095833a4f708aedec42f62f3c10ab9634ae102e88f31522ca4aa97f1d6c2481` | accepted finite-model result; advisory claim |
+| `control.nonblockingness` | all eight reachable stable phases are coreachable; no blocking states; result `res-f8ca50706925277190a6c0fba49a3ac44665d0a894203ea88c321434a7a4e549` | accepted finite-model result; advisory claim |
+| event-completeness obligation | source inventory, sole reducer, generated-model parity, and repository-contract refusing tests were accepted as complete | closes the declared event alphabet obligation |
+
+Safety/guard derivation
+`drv-bfc66012cb01e28ea3e1ef7407acf433d2340a166a14a4120a09a27fc77f1859`
+and liveness derivation
+`drv-a9534324fdc7e93fcfba3fd6919cbe9513a7dff9e97428f66f46b5489c39c9a4`
+remain advisory because the applicable Locus operators are currently candidate
+capabilities and because the model deliberately names real-system unknowns.
+The verified liveness frontier returned `current_claim: advisory`,
+`termination: blocked`, and no `EvidenceAction`; there is no honest additional
+action to invent inside V2.
+
+This closes the finite stable-phase abstraction, not the whole live system.
+The source-phase by target-phase expansion is conservative. Exact 17-facet
+predicates, reducer branches, operating-system interruption behavior, and
+external-provider truth are separately closed by executable unit, fault,
+integration, historical, repository, and platform tests.
+
+## 18. Complete V2 replacement work order
+
+This is one atomic branch and one final PR. The order controls build safety, not
+rollout compatibility.
+
+1. Freeze this specification against base `c5b5e10...` and record historical
+ fixtures.
+2. Add slice 1 model, catalog, supervisor, protocol, ports, engine, generated
+ graph, and formal/property tests.
+3. Add one read-only plant observer and explicit invocation identity.
+4. Add journaled local and external effects, independent verification, receipts,
+ recovery, and fault injection.
+5. Inventory every V1 reader, decision, renderer, surface, and writer; route each
+ valuable operation through the catalog or classify a read-only exclusion.
+6. Add slice 2 facade, CLI, hooks, host assets, SDK/MCP protocol, shell rendering,
+ passive events, and consumer parity tests.
+7. Port historical incidents, including every PR #172-#185 class, into the
+ registry-driven scenario corpus.
+8. Delete V1 decision authorities, unmanaged writers, migration/coexistence code,
+ shadow controller, duplicate graph/digests, and obsolete docs.
+9. Regenerate catalog artifacts and run static closure, full Go/repository tests,
+ race tests, platform builds, formal properties, and Locus verified frontier.
+10. Update public docs and one release note, verify the exact pushed head, and
+ open one concise PR. Do not merge.
+
+Both logical slices must be present before any V2 runtime is publishable. No
+partial package rollout, feature flag, fallback, shadow execution, or second PR
+is permitted.
+
+## 19. Explicit deletion list for old authorities
+
+The final tree must delete, not retain “just in case”:
+
+- `boatstack/internal/deliverycontrol/**` as a shadow/non-authoritative graph,
+ after any useful pure algorithms are made catalog-driven;
+- V1 machine-state migration and grading authorities in `migrate.go`,
+ `delivery_migrate.go`, `detached_migration.go`, and `migrate_effect_grade.go`;
+- static duplicate-control digests such as `lifecycle_event_registry_test.go`,
+ `deliverycontrol_parity_test.go`, and engagement/surface inventories once
+ replaced by source/catalog completeness checks;
+- lifecycle and completion decisions currently owned independently by
+ `lifecycle.go`, `engagement.go`, `workspace.go`, `workspace_sync.go`,
+ `delivery_terminal.go`, `pr_phase.go`, `next.go`, `run.go`, and `decision.go`;
+- effect authority or direct managed writers in activation, planning, plan,
+ delivery, mutation, configuration, runtime, publication, update, safety,
+ recovery, attach/detach, init/provision, visual publication, and helper command
+ paths; pure algorithms may survive only behind V2 ports;
+- direct workflow dispatch in `cmd/boatstack-helper`;
+- handwritten host/shell prescriptions that duplicate registry knowledge;
+- path-only effect APIs, first-match alias resolution, ambient engagement, saved-
+ plan activation, ancestry-as-publication, presence-as-validity, cleanup-as-
+ completion, boolean uncertainty collapse, repairing status reads, host-specific
+ state machines, raw state writers, and success before postcondition proof;
+- documentation or public claims describing deleted V1 authority.
+
+No compatibility wrapper may preserve an old internal API. If a preserved
+product operation needs an adapter, it targets the new facade/protocol directly.
+
+## 20. Completion criteria
+
+V2 is complete only when all criteria are evidenced at the exact final head.
+
+Architecture: one runtime kernel, catalog, observer, explicit identity,
+admission path, receipt model, recovery model, and goal model own their respective
+laws. The package graph is acyclic and the facade owns no independent durable
+state.
+
+Static closure: zero lifecycle decisions outside the kernel; zero ambiguous
+effect identity; zero managed writers outside registered effects; zero
+unclassified controlling fields/sites; zero unregistered surfaces; zero host-
+specific transition decisions; zero duplicate graphs; zero path-only effect APIs.
+
+Dynamic closure: zero reachable managed deadlocks; zero nonterminal states
+without progress/recovery/frontier/safe terminal; zero consumer prescription
+disagreements; zero accepted failed postconditions or mixed epochs; zero default
+cleanup of unpublished/unresolved work; zero stale prescription admissions.
+
+Behavior: valuable workflows remain possible through V2; safety is equal or
+stronger; the historical corpus passes; adapted full Go and repository contract
+tests pass; race tests pass; Windows/macOS/Linux compile/check jobs pass; POSIX
+and PowerShell are semantically equivalent; all hosts consume kernel decisions.
+
+Formal closure: the executable catalog is the checked model; event, writer, and
+consumer inventories are complete; Locus safety and live coreachability results
+are supported by observed code evidence; the explicit `verified` frontier is
+`target-met` or any remaining action is proved outside the declared V2 target.
+
+Documentation and delivery: this specification matches code; diagrams are
+generated from the registry; public claims bind to tests; one release note
+describes V2; one final PR has exact-head green CI; the PR is not automatically
+merged.
+
+## Appendix A. Historical control-law episodes and regression corpus
+
+Each fixture contains initial plant facts, canonical observation, goal, event,
+expected admitted transition, expected postcondition, forbidden transition,
+source provenance, and failure class. Rows may share a stronger class fixture,
+but every cited PR has an explicit provenance edge.
+
+| Episode/provenance | Symptom and missing distinction | Split/mis-owned authority | V2 structural repair | Required fixture / removed accident |
+| --- | --- | --- | --- | --- |
+| Initialization and repair, PRs #35-#37 | Partial initialization and repair could leave mixed or misleading state | Filesystem writes vs installed binding/runtime | Journaled staged initialization with binding last and verified receipt | Fail every write boundary; remove repair-by-presence |
+| Run/recovery, PRs #38-#39 | Interrupted commands could strand progress | Command success vs recovery state | Recovery is catalog state with bounded resume/rollback | Restart at each interruption; remove exception-path recovery |
+| Hooks and malformed host events, PRs #42-#46 | Host-specific inputs diverged or bypassed policy | Hooks/hosts vs native controller | Typed surface request and one admission path | Malformed and replayed host request; remove host decisions |
+| Workspace/config foundation, PRs #51-#52 | Workspace and config projections lost topology/authority distinctions | Workspace lifecycle vs config writer | Composite facts with evidence and one observer | Detached/embedded/hybrid configuration fixtures |
+| Approval/grounding/worktrees, PRs #56-#59 | Authority or worktree identity was inferred from insufficient context | Approval artifacts and path lookup | Exact authority and `InvocationContext` binding | Ambiguous worktree/approval fingerprint fixtures |
+| Deterministic plan and multi-delivery, PRs #61-#64 | One local slice or artifact could choose the wrong delivery | Plan/safety/workflow resolvers | Goal-scoped snapshot and deterministic supervisor | Two deliveries sharing artifacts; remove first-match selection |
+| Publication/config corrections, PRs #68-#78 | Publication, mutation, update, or correction could invalidate its own proof | External provider/config writers vs lifecycle | Preview/admit/execute/observe/reconcile and postcondition receipts | Unknown publication; post-publication correction; remove accepted unverified success |
+| Dual layout and state ledger, PRs #79, #89-#100 | Embedded/detached layouts and stale ledgers produced incompatible answers | Layout/path state vs delivery authority | Topology facts plus authoritative observation/canonicalization | Same logical plant in all topologies; remove path-as-authority |
+| Shadow flow model, PRs #101-#106 | Useful graph/oracle/trajectory existed but was not runtime authority | `internal/deliverycontrol` vs production functions | Executable catalog is runtime and formal model | Generated reachability parity; delete shadow graph |
+| Concurrency/worktree runtime, PRs #111-#123 | Stale runtime/worktree selection and destructive guards raced | Runtime launcher, worktree, cleanup, safety | Exact identity, source fingerprint, scoped lock, preservation on uncertainty | Stale runtime, shared aliases, branch/worktree combinations |
+| Recovery/denial/owners, PRs #124-#138 | Recovery or denial could be overridden or fail to name a path | Local status slices vs repair/ownership policy | Recovery precedence and typed denial with registered correction | Budget exhaustion and contradictory owner evidence |
+| PR state and terminal, PRs #145-#150 | Open/closed/merged and ancestry were collapsed | GitHub projection vs Git graph vs goal | Multi-state publication and goal-specific terminal verifier | Open, closed-unmerged, merged, unavailable, published-not-landed |
+| Retro/readiness/visuals/insights, PRs #151-#159 | Ancillary evidence could leak into authority or lack freshness | Evidence services vs lifecycle | Separate services; managed writes cross effects, facts retain freshness | Stale evidence and privacy allowlist; remove evidence-presence authority |
+| Update recovery and explicit goal, PRs #161-#163 | Update postconditions or local lifecycle ignored the requested terminal | Update writer/local phase vs goal | Independent verification and goal-first supervisor precedence | Configured PR vs merged terminals; self-invalidating update |
+| Detached controller/privacy/cloud, PRs #164-#167 | Shared controller paths and external config/cloud facts were non-injective or sensitive | Detached registry/adapters vs repository identity | Explicit invocation plus evidence-source and privacy classifications | Two repos sharing controller alias; unknown external config |
+| Operation/shell/readiness, PRs #168-#170 | Operation drivers and shell guidance could encode different control decisions | Native code vs POSIX/PowerShell/host text | Typed prescription rendered per environment | Semantic shell/host parity; remove hand-authored workflow logic |
+| PR #172, deterministic worktree runtime launcher | Active worktree could select stale/wrong runtime | Launcher lookup vs worktree identity | Bind runtime source/version to explicit invocation | `stale-worktree-runtime-selection` |
+| PR #173, detached launcher hydration | Detached bootstrap lacked a verified runtime and could dead-end | Bootstrap vs detached runtime owner | `runtime.hydrate` recovery before managed execution | `detached-bootstrap-hydration` |
+| PR #174, workspace transition deadlock | Valid workspace states had no next transition | Workspace slice vs delivery resolver | Catalog coreachability and explicit recovery | `workspace-transition-deadlock` |
+| PR #175-#176, cleanup/public lifecycle | Cleanup could act on weak completion/publication signals | Cleanup policy vs publication evidence | Cleanup requires goal/lifecycle predicate and explicit authority | `cleanup-before-publication`; remove cleanup-as-proof |
+| PR #177, saved plans are not active authority | Mere plan presence activated ambient restrictions | Filesystem presence vs engagement | Engagement fact/lease and command scope | `saved-plan-ambient-restriction` |
+| PR #178, detached command admission | Native and detached surfaces disagreed on command permission | Detached launcher vs controller admission | One surface request and admission protocol | `detached-command-admission-mismatch` |
+| PR #179, planning bootstrap authority | Bootstrap commands independently reconstructed planning authority | Helper command vs lifecycle | Map command to catalog ID; kernel resolves | `split-bootstrap-command-authority` |
+| PR #180, detached configuration authority | Detached config projection drifted from repository/external source | Config readers/writers vs topology | Evidence-backed config authority and reconcile transition | `configuration-projection-drift` |
+| PR #181, composite lifecycle authority | Slice status collapsed states needing different actions | Lifecycle vs plan/workspace/publication | One composite snapshot and reachable constraints | `partial-delivery-vs-merged-projection` |
+| PR #182, explicit engagement | Dormant repositories were affected by ambient Boatstack state | Repository presence/plan vs engagement | Dormant/command/active/conflict facet | `dormant-repository-interference` |
+| PR #183, verified configuration mutation | Successful mutation could invalidate verification | Config writer vs verifier/runtime binding | Binding last, re-observe, independent target check | `configuration-mutation-self-invalidation` |
+| PR #184, test sharding | Large test topology exposed implicit shared assumptions | Test partitions vs hidden global state | Isolated catalog/plant/effect fixtures and deterministic seeds | Cross-shard/race parity; remove test-order authority |
+| PR #185, preserve active workspaces | Branch equal to main or incomplete publication could be read as landed and cleaned | Git ancestry, publication, workspace, active delivery, configured goal | Durable publication evidence, active-delivery precedence, preserve on ambiguity | `unpublished-equal-main-not-landed`, `closed-unmerged-not-cleanup-eligible`, `configured-merged-terminal`, `active-workspace-preserved` |
+
+Additional class fixtures required even when covered by stronger rows are:
+activation from the wrong worktree identity; ambiguous detached controller alias;
+runtime publication before lock release; amendment deadlock; invalid-plan recovery;
+partial multi-slice delivery overridden by merged provider state; CI/provider
+unknown; external request settled before receipt; and every transactional
+interruption point.
diff --git a/docs/architecture/boatstack-v2-locus-liveness.json b/docs/architecture/boatstack-v2-locus-liveness.json
new file mode 100644
index 0000000..4ca0ab6
--- /dev/null
+++ b/docs/architecture/boatstack-v2-locus-liveness.json
@@ -0,0 +1,4447 @@
+{
+ "schema_version": 1,
+ "id": "boatstack-v2-executable-catalog-liveness-v1",
+ "subject": "Finite stable-phase abstraction generated from the executable Boatstack V2 registry. It contains one event for every runtime catalog entry and expands each declared source and target phase set. The 17-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.",
+ "evidence": [
+ {
+ "path": "boatstack/internal/kernel/catalog/default.go",
+ "note": "Executable registry, exact transition count, event classes, phase predicates, authority, and materialization."
+ },
+ {
+ "path": "docs/architecture/boatstack-v2-transition-catalog.md",
+ "note": "Generated readable projection from the same runtime registry."
+ },
+ {
+ "path": "boatstack/internal/kernel/protocol/admission.go",
+ "note": "Exact admission, authority, parameter, source-revision, provider-request, expiry, and stale-snapshot checks."
+ },
+ {
+ "path": "boatstack/internal/kernel/engine/engine.go",
+ "note": "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery."
+ },
+ {
+ "path": "boatstack/internal/kernel/reducer/reducer.go",
+ "note": "Single executable reducer for every controllable semantic transition."
+ },
+ {
+ "path": "boatstack/internal/kernel/catalog/completeness_test.go",
+ "note": "Runtime facet/event classification, writer-boundary inventory, and reducer-completeness refusing tests."
+ },
+ {
+ "path": "boatstack/internal/kernel/engine/engine_test.go",
+ "note": "Exact-admission, stale-snapshot, postcondition, interruption, idempotency, and unknown-outcome tests."
+ },
+ {
+ "path": "boatstack/internal/effects/prepared.go",
+ "note": "Staged effect ordering, atomic resource application, rollback, and external settlement boundary."
+ },
+ {
+ "path": "boatstack/internal/kernel/catalog/historical_test.go",
+ "note": "Historical incidents resolved through the executable runtime supervisor."
+ }
+ ],
+ "states": [
+ {
+ "id": "DORMANT"
+ },
+ {
+ "id": "OBSERVED"
+ },
+ {
+ "id": "ACTIVE"
+ },
+ {
+ "id": "RECOVERY"
+ },
+ {
+ "id": "UNRESOLVED"
+ },
+ {
+ "id": "FRONTIER",
+ "marked": true
+ },
+ {
+ "id": "TERMINAL",
+ "marked": true
+ },
+ {
+ "id": "ABANDONED",
+ "marked": true
+ }
+ ],
+ "events": [
+ {
+ "id": "configuration.initialize",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "configuration.mutate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "configuration.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "delivery.slice.advance",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "engagement.begin",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "engagement.release",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "engagement.renew",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "evidence.approval.revoke",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "evidence.visual.attach",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.branch-changed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.ci-completed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.configuration-drifted",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.files-changed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.head-changed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.host-interrupted",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.lease-expired",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-closed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-merged",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-opened",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-updated",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.provider-unavailable",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.runtime-disappeared",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.build.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.change.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.journey.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.review.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.test.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "goal.configure",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "installation.initialize",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "installation.update",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "invocation.rebind",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.abandon",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.activate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.amend",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.approve",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.approve-amendment",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.create",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.invalidate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.validate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.abandon",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.correct",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.execute",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.observe",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.preview",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "recovery.escalate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "recovery.resume",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "recovery.rollback",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "repository.attach",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "repository.detach",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "runtime.hydrate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "runtime.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "runtime.replace",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.abandon",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.activate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.cleanup",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.cut",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.publish",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.reap",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.sync",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ }
+ ],
+ "transitions": [
+ {
+ "from": "OBSERVED",
+ "event": "configuration.initialize",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.initialize",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "configuration.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "configuration.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "configuration.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "delivery.slice.advance",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "delivery.slice.advance",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "engagement.begin",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "engagement.begin",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "engagement.begin",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "engagement.begin",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "engagement.release",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "engagement.release",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "engagement.renew",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "evidence.approval.revoke",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "evidence.approval.revoke",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "evidence.visual.attach",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "evidence.visual.attach",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.build.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.change.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.journey.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.review.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.review.record",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.test.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.test.record",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "installation.initialize",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.initialize",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.update",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.update",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.update",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "installation.update",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "installation.update",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "installation.update",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "invocation.rebind",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "invocation.rebind",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.amend",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.amend",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.approve",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.approve",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.approve",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.approve",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.approve-amendment",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.approve-amendment",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.create",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.create",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.invalidate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.invalidate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.validate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.validate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.validate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.validate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "publication.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.correct",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.correct",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.correct",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.correct",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "publication.correct",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "publication.correct",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.execute",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.execute",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.preview",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.escalate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "recovery.escalate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "repository.attach",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "repository.attach",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "repository.detach",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "repository.detach",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "repository.detach",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.hydrate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.hydrate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.hydrate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.hydrate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.hydrate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.hydrate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.hydrate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.hydrate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.hydrate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.replace",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.replace",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.replace",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.replace",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "workspace.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cut",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cut",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.publish",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.reap",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.reap",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.reap",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.reap",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.reap",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.reap",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.reap",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.reap",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.reap",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.sync",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.sync",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ }
+ ],
+ "spec": {
+ "description": "Every reachable stable catalog phase retains a path to terminal, explicit frontier, or safe abandonment.",
+ "forbidden_states": [],
+ "forbidden_transitions": []
+ },
+ "targets": {
+ "selector": "marked"
+ },
+ "unknowns": [
+ "The stable-phase graph is a conservative expansion of declared source and target phase sets; facet combinations and deterministic reducer branches remain executable-test obligations.",
+ "External provider state and operating-system crash behavior are represented by declared outcomes and restart tests, not exhaustively observed by Locus."
+ ]
+}
diff --git a/docs/architecture/boatstack-v2-locus-safety.json b/docs/architecture/boatstack-v2-locus-safety.json
new file mode 100644
index 0000000..32d4c0f
--- /dev/null
+++ b/docs/architecture/boatstack-v2-locus-safety.json
@@ -0,0 +1,4469 @@
+{
+ "schema_version": 1,
+ "id": "boatstack-v2-executable-catalog-safety-v1",
+ "subject": "Finite stable-phase abstraction generated from the executable Boatstack V2 registry. It contains one event for every runtime catalog entry and expands each declared source and target phase set. The 17-facet predicates, operating-system behavior, and external-provider truth remain executable evidence obligations rather than theorem assumptions.",
+ "evidence": [
+ {
+ "path": "boatstack/internal/kernel/catalog/default.go",
+ "note": "Executable registry, exact transition count, event classes, phase predicates, authority, and materialization."
+ },
+ {
+ "path": "docs/architecture/boatstack-v2-transition-catalog.md",
+ "note": "Generated readable projection from the same runtime registry."
+ },
+ {
+ "path": "boatstack/internal/kernel/protocol/admission.go",
+ "note": "Exact admission, authority, parameter, source-revision, provider-request, expiry, and stale-snapshot checks."
+ },
+ {
+ "path": "boatstack/internal/kernel/engine/engine.go",
+ "note": "Single apply path across lock, journal, effect, fresh observation, target predicate, receipt, and recovery."
+ },
+ {
+ "path": "boatstack/internal/kernel/reducer/reducer.go",
+ "note": "Single executable reducer for every controllable semantic transition."
+ },
+ {
+ "path": "boatstack/internal/kernel/catalog/completeness_test.go",
+ "note": "Runtime facet/event classification, writer-boundary inventory, and reducer-completeness refusing tests."
+ },
+ {
+ "path": "boatstack/internal/kernel/engine/engine_test.go",
+ "note": "Exact-admission, stale-snapshot, postcondition, interruption, idempotency, and unknown-outcome tests."
+ },
+ {
+ "path": "boatstack/internal/effects/prepared.go",
+ "note": "Staged effect ordering, atomic resource application, rollback, and external settlement boundary."
+ },
+ {
+ "path": "boatstack/internal/kernel/catalog/historical_test.go",
+ "note": "Historical incidents resolved through the executable runtime supervisor."
+ }
+ ],
+ "states": [
+ {
+ "id": "DORMANT"
+ },
+ {
+ "id": "OBSERVED"
+ },
+ {
+ "id": "ACTIVE"
+ },
+ {
+ "id": "RECOVERY"
+ },
+ {
+ "id": "UNRESOLVED"
+ },
+ {
+ "id": "FRONTIER",
+ "marked": true
+ },
+ {
+ "id": "TERMINAL",
+ "marked": true
+ },
+ {
+ "id": "ABANDONED",
+ "marked": true
+ },
+ {
+ "id": "UNADMITTED_EFFECT"
+ }
+ ],
+ "events": [
+ {
+ "id": "configuration.initialize",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "configuration.mutate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "configuration.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "delivery.slice.advance",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "engagement.begin",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "engagement.release",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "engagement.renew",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "evidence.approval.revoke",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "evidence.visual.attach",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.branch-changed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.ci-completed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.configuration-drifted",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.files-changed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.head-changed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.host-interrupted",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.lease-expired",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-closed",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-merged",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-opened",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.pr-updated",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.provider-unavailable",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "external.runtime-disappeared",
+ "controllable": false,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.build.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.change.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.journey.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.review.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "gate.test.record",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "goal.configure",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "installation.initialize",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "installation.update",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "invocation.rebind",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.abandon",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.activate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.amend",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.approve",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.approve-amendment",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.create",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.invalidate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "plan.validate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.abandon",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.correct",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.execute",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.observe",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.preview",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "publication.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "recovery.escalate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "recovery.resume",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "recovery.rollback",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "repository.attach",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "repository.detach",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "runtime.hydrate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "runtime.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "runtime.replace",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.abandon",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.activate",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.cleanup",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.cut",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.publish",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.reap",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.reconcile",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ },
+ {
+ "id": "workspace.sync",
+ "controllable": true,
+ "observable": true,
+ "basis": "observed"
+ }
+ ],
+ "transitions": [
+ {
+ "from": "OBSERVED",
+ "event": "configuration.initialize",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.initialize",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.mutate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.mutate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.mutate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "configuration.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "configuration.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "configuration.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "configuration.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "delivery.slice.advance",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "delivery.slice.advance",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "engagement.begin",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "engagement.begin",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "engagement.begin",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "engagement.begin",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "engagement.release",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "engagement.release",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "engagement.renew",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "evidence.approval.revoke",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "evidence.approval.revoke",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "evidence.visual.attach",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "evidence.visual.attach",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.branch-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.ci-completed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.ci-completed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.ci-completed",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.configuration-drifted",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.configuration-drifted",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.files-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.head-changed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.host-interrupted",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.lease-expired",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.lease-expired",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-closed",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-closed",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-closed",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-merged",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-merged",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-merged",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-opened",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-opened",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-opened",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-updated",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-updated",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.pr-updated",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.provider-unavailable",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.provider-unavailable",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.runtime-disappeared",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "external.runtime-disappeared",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.build.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.change.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.journey.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.review.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.review.record",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.test.record",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "gate.test.record",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "goal.configure",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "goal.configure",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "goal.configure",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "installation.initialize",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.initialize",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.update",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.update",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "installation.update",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "installation.update",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "installation.update",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "installation.update",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "invocation.rebind",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "invocation.rebind",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.amend",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.amend",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.approve",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.approve",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.approve",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.approve",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.approve-amendment",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "plan.approve-amendment",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.create",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.create",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.invalidate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.invalidate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.validate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "plan.validate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.validate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "plan.validate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "publication.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.correct",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.correct",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.correct",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.correct",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "publication.correct",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "publication.correct",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.execute",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.execute",
+ "to": "RECOVERY",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.observe",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "publication.preview",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "publication.reconcile",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "publication.reconcile",
+ "to": "UNRESOLVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.escalate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "recovery.escalate",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.resume",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "recovery.rollback",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "repository.attach",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "repository.attach",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "repository.detach",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "repository.detach",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "repository.detach",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.hydrate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.hydrate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.hydrate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.hydrate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.hydrate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.hydrate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.hydrate",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.hydrate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.hydrate",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "runtime.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.replace",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "runtime.replace",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.replace",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "runtime.replace",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "FRONTIER",
+ "event": "workspace.abandon",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.activate",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.cleanup",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.cleanup",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.cleanup",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.cut",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.cut",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.publish",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.reap",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.reap",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "OBSERVED",
+ "event": "workspace.reap",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.reap",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.reap",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "TERMINAL",
+ "event": "workspace.reap",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.reap",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.reap",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ABANDONED",
+ "event": "workspace.reap",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "RECOVERY",
+ "event": "workspace.reconcile",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "DORMANT",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "OBSERVED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "TERMINAL",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "UNRESOLVED",
+ "event": "workspace.reconcile",
+ "to": "ABANDONED",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.sync",
+ "to": "ACTIVE",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "ACTIVE",
+ "event": "workspace.sync",
+ "to": "FRONTIER",
+ "evidence": [
+ 0,
+ 1,
+ 4
+ ],
+ "basis": "inferred"
+ },
+ {
+ "from": "DORMANT",
+ "event": "publication.execute",
+ "to": "UNADMITTED_EFFECT",
+ "guard": "exact-admission",
+ "evidence": [
+ 2,
+ 3,
+ 6
+ ],
+ "basis": "inferred"
+ }
+ ],
+ "spec": {
+ "description": "Managed publication is unreachable from DORMANT without the exact-admission guard.",
+ "forbidden_states": [
+ "UNADMITTED_EFFECT"
+ ],
+ "forbidden_transitions": []
+ },
+ "targets": {
+ "selector": "marked"
+ },
+ "mutation": {
+ "id": "remove-exact-admission",
+ "kind": "remove-guard",
+ "guard_id": "exact-admission"
+ },
+ "unknowns": [
+ "The stable-phase graph is a conservative expansion of declared source and target phase sets; facet combinations and deterministic reducer branches remain executable-test obligations.",
+ "External provider state and operating-system crash behavior are represented by declared outcomes and restart tests, not exhaustively observed by Locus."
+ ]
+}
diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md
new file mode 100644
index 0000000..505cd6c
--- /dev/null
+++ b/docs/architecture/boatstack-v2-transition-catalog.md
@@ -0,0 +1,72 @@
+
+# Boatstack V2 executable transition catalog
+
+Registry size: **61** transitions. Event classes: authority 9; owned-local 30; owned-external 2; recovery 7; observed-external 13.
+
+Controlling facets: `phase`, `topology`, `engagement`, `delivery`, `workspace`, `plan`, `configuration`, `configuration-policy`, `runtime`, `publication`, `verification`, `recovery`, `transaction`, `recovery-info`, `transaction-info`, `terminal`, `goal`.
+
+| Transition | Class | Source phases | Target phases | Authority | Parameters | Owned resources | Recovery |
+|---|---|---|---|---|---|---|---|
+| `configuration.initialize` | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `config_path*`, `config_sha256*` | `configuration` | `configuration.reconcile` |
+| `configuration.mutate` | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `config_path*`, `config_sha256*` | `configuration` | `configuration.reconcile` |
+| `configuration.reconcile` | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `transaction_id*` | `configuration` | `recovery.escalate` |
+| `delivery.slice.advance` | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `slice_id*`, `source_revision*` | `delivery-state` | `recovery.resume` |
+| `engagement.begin` | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | - | `engagement` | `recovery.resume` |
+| `engagement.release` | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | - | `engagement` | `recovery.resume` |
+| `engagement.renew` | authority | ACTIVE | ACTIVE | repository-policy/autonomy | - | `engagement` | `recovery.resume` |
+| `evidence.approval.revoke` | authority | ACTIVE / FRONTIER | FRONTIER | human | - | `approval` | `recovery.resume` |
+| `evidence.visual.attach` | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `recovery.resume` |
+| `external.branch-changed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `-` |
+| `external.ci-completed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` |
+| `external.configuration-drifted` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | `-` |
+| `external.files-changed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `-` |
+| `external.head-changed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | `-` |
+| `external.host-interrupted` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | RECOVERY | none | - | - | `-` |
+| `external.lease-expired` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | DORMANT / FRONTIER | none | - | - | `-` |
+| `external.pr-closed` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / FRONTIER | none | - | - | `-` |
+| `external.pr-merged` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` |
+| `external.pr-opened` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` |
+| `external.pr-updated` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | `-` |
+| `external.provider-unavailable` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | `-` |
+| `external.runtime-disappeared` | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | `-` |
+| `gate.build.record` | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` |
+| `gate.change.record` | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` |
+| `gate.journey.record` | owned-local | ACTIVE | ACTIVE | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` |
+| `gate.review.record` | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` |
+| `gate.test.record` | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `recovery.resume` |
+| `goal.configure` | authority | OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `goal_kind*`, `delivery_id*` | `goal` | `recovery.resume` |
+| `installation.initialize` | owned-local | DORMANT / OBSERVED | OBSERVED | human | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `runtime.reconcile` |
+| `installation.update` | owned-local | OBSERVED / ACTIVE | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `installation` | `runtime.reconcile` |
+| `invocation.rebind` | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | - | `identity-binding` | `recovery.resume` |
+| `plan.abandon` | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | - | `plan` | `recovery.resume` |
+| `plan.activate` | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | - | `delivery-state` | `recovery.resume` |
+| `plan.amend` | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `recovery.resume` |
+| `plan.approve` | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `recovery.resume` |
+| `plan.approve-amendment` | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `plan_fingerprint*`, `actor*` | `approval` | `recovery.resume` |
+| `plan.create` | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `source_path*`, `delivery_id*` | `plan` | `recovery.resume` |
+| `plan.invalidate` | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | - | `plan-evidence` | `recovery.resume` |
+| `plan.validate` | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | - | `plan-evidence` | `recovery.resume` |
+| `publication.abandon` | authority | ACTIVE / FRONTIER | ABANDONED | human | - | `publication` | `recovery.resume` |
+| `publication.correct` | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `publication.reconcile` |
+| `publication.execute` | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `preview_fingerprint*` | `publication` | `publication.reconcile` |
+| `publication.observe` | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `publication_id*` | `publication-evidence` | `recovery.resume` |
+| `publication.preview` | owned-local | ACTIVE | ACTIVE | repository-policy | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `recovery.resume` |
+| `publication.reconcile` | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `publication_id*`, `transaction_id*` | `publication` | `recovery.escalate` |
+| `recovery.escalate` | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `transaction_id*` | `recovery-journal` | `recovery.escalate` |
+| `recovery.resume` | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `transaction_id*` | `recovery-journal` | `recovery.escalate` |
+| `recovery.rollback` | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `recovery-journal` | `recovery.escalate` |
+| `repository.attach` | owned-local | DORMANT / OBSERVED | OBSERVED | human | `topology*`, `config_authority*` | `repository-binding` | `recovery.resume` |
+| `repository.detach` | owned-local | DORMANT / OBSERVED / FRONTIER | DORMANT | human | - | `repository-binding` | `recovery.resume` |
+| `runtime.hydrate` | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `runtime.reconcile` |
+| `runtime.reconcile` | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `recovery.escalate` |
+| `runtime.replace` | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `source_revision*`, `runtime_path*`, `runtime_sha256*` | `runtime` | `runtime.reconcile` |
+| `workspace.abandon` | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `branch*` | `workspace` | `recovery.resume` |
+| `workspace.activate` | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace` | `recovery.resume` |
+| `workspace.cleanup` | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `branch*` | `workspace` | `recovery.escalate` |
+| `workspace.cut` | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `branch*`, `base_ref*`, `destination*` | `workspace` | `workspace.reconcile` |
+| `workspace.publish` | owned-local | ACTIVE | ACTIVE | repository-policy | `branch*` | `workspace-state` | `recovery.resume` |
+| `workspace.reap` | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `branch*` | `workspace` | `recovery.escalate` |
+| `workspace.reconcile` | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `transaction_id*` | `workspace` | `recovery.escalate` |
+| `workspace.sync` | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `branch*` | `workspace` | `recovery.resume` |
+
+`*` 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/architecture/boatstack-v2-transition-catalog.mmd b/docs/architecture/boatstack-v2-transition-catalog.mmd
new file mode 100644
index 0000000..9fff145
--- /dev/null
+++ b/docs/architecture/boatstack-v2-transition-catalog.mmd
@@ -0,0 +1,73 @@
+%% Generated from catalog.Default by surfaces.RenderCatalogMermaid. Do not edit.
+flowchart TB
+ subgraph authority["authority"]
+ t00["engagement.begin
DORMANT | OBSERVED → OBSERVED | ACTIVE"]
+ t01["engagement.release
ACTIVE | FRONTIER → DORMANT"]
+ t02["engagement.renew
ACTIVE → ACTIVE"]
+ t03["evidence.approval.revoke
ACTIVE | FRONTIER → FRONTIER"]
+ t04["goal.configure
OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED → OBSERVED | ACTIVE | FRONTIER"]
+ t05["plan.abandon
OBSERVED | ACTIVE | FRONTIER → ABANDONED"]
+ t06["plan.approve
ACTIVE | FRONTIER → ACTIVE | TERMINAL"]
+ t07["plan.approve-amendment
ACTIVE | FRONTIER → ACTIVE"]
+ t08["publication.abandon
ACTIVE | FRONTIER → ABANDONED"]
+ end
+ subgraph owned_local["owned-local"]
+ t09["configuration.initialize
OBSERVED → OBSERVED | TERMINAL"]
+ t10["configuration.mutate
OBSERVED | ACTIVE | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"]
+ t11["delivery.slice.advance
ACTIVE → ACTIVE | TERMINAL"]
+ t12["evidence.visual.attach
ACTIVE → ACTIVE | TERMINAL"]
+ t13["gate.build.record
ACTIVE → ACTIVE"]
+ t14["gate.change.record
ACTIVE → ACTIVE"]
+ t15["gate.journey.record
ACTIVE → ACTIVE"]
+ t16["gate.review.record
ACTIVE → ACTIVE | TERMINAL"]
+ t17["gate.test.record
ACTIVE → ACTIVE | TERMINAL"]
+ t18["installation.initialize
DORMANT | OBSERVED → OBSERVED"]
+ t19["installation.update
OBSERVED | ACTIVE → OBSERVED | ACTIVE | TERMINAL"]
+ t20["invocation.rebind
OBSERVED | UNRESOLVED → OBSERVED"]
+ t21["plan.activate
OBSERVED | ACTIVE → ACTIVE"]
+ t22["plan.amend
ACTIVE | FRONTIER → ACTIVE"]
+ t23["plan.create
OBSERVED | ACTIVE → ACTIVE"]
+ t24["plan.invalidate
ACTIVE | OBSERVED → FRONTIER"]
+ t25["plan.validate
OBSERVED | ACTIVE → ACTIVE | FRONTIER"]
+ t26["publication.observe
OBSERVED | ACTIVE | RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"]
+ t27["publication.preview
ACTIVE → ACTIVE"]
+ t28["repository.attach
DORMANT | OBSERVED → OBSERVED"]
+ t29["repository.detach
DORMANT | OBSERVED | FRONTIER → DORMANT"]
+ t30["runtime.hydrate
OBSERVED | RECOVERY | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"]
+ t31["runtime.replace
OBSERVED | RECOVERY → OBSERVED | TERMINAL"]
+ t32["workspace.abandon
ACTIVE | FRONTIER → ABANDONED"]
+ t33["workspace.activate
OBSERVED | ACTIVE → ACTIVE"]
+ t34["workspace.cleanup
OBSERVED | ACTIVE | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"]
+ t35["workspace.cut
OBSERVED | ACTIVE → ACTIVE"]
+ t36["workspace.publish
ACTIVE → ACTIVE"]
+ t37["workspace.reap
OBSERVED | TERMINAL | ABANDONED → OBSERVED | TERMINAL | ABANDONED"]
+ t38["workspace.sync
ACTIVE → ACTIVE | FRONTIER"]
+ end
+ subgraph owned_external["owned-external"]
+ t39["publication.correct
OBSERVED | ACTIVE | TERMINAL → ACTIVE | RECOVERY"]
+ t40["publication.execute
ACTIVE → ACTIVE | RECOVERY"]
+ end
+ subgraph recovery["recovery"]
+ t41["configuration.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"]
+ t42["publication.reconcile
RECOVERY | UNRESOLVED → ACTIVE | TERMINAL | FRONTIER | UNRESOLVED"]
+ t43["recovery.escalate
RECOVERY | UNRESOLVED → FRONTIER"]
+ t44["recovery.resume
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"]
+ t45["recovery.rollback
RECOVERY → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"]
+ t46["runtime.reconcile
RECOVERY | UNRESOLVED → OBSERVED | FRONTIER | TERMINAL"]
+ t47["workspace.reconcile
RECOVERY | UNRESOLVED → DORMANT | OBSERVED | ACTIVE | FRONTIER | TERMINAL | ABANDONED"]
+ end
+ subgraph observed_external["observed-external"]
+ t48["external.branch-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"]
+ t49["external.ci-completed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"]
+ t50["external.configuration-drifted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | UNRESOLVED"]
+ t51["external.files-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"]
+ t52["external.head-changed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED"]
+ t53["external.host-interrupted
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → RECOVERY"]
+ t54["external.lease-expired
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → DORMANT | FRONTIER"]
+ t55["external.pr-closed
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | FRONTIER"]
+ t56["external.pr-merged
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"]
+ t57["external.pr-opened
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"]
+ t58["external.pr-updated
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | ACTIVE | TERMINAL"]
+ t59["external.provider-unavailable
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → UNRESOLVED | RECOVERY"]
+ t60["external.runtime-disappeared
DORMANT | OBSERVED | ACTIVE | RECOVERY | FRONTIER | UNRESOLVED → OBSERVED | RECOVERY"]
+ end
diff --git a/docs/benchmark-corpus-audit.md b/docs/benchmark-corpus-audit.md
deleted file mode 100644
index e38baf3..0000000
--- a/docs/benchmark-corpus-audit.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Local Terminal-Bench corpus audit
-
-> Generated by `scripts/audit_terminal_bench.py`. This proves aggregate read coverage of every local trial result and available signal stream; it does not claim every transcript was manually interpreted.
-
-## Coverage
-
-- Per-trial results read: **3571**
-- Run/corpus groups: **19**
-- Signal streams read: **3540** (99.13% coverage)
-- Aggregate Harbor result files excluded: **18**
-- Unreadable trial results: **0**
-
-## Groups
-
-| Corpus | Run | Model | Trials | Pass | Partial | Fail | Errors | Signals | Parse errors | Command timeouts |
-|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
-| jobs-cloud | 2026-06-09__16-53-01 | gemini/gemini-3.1-pro-preview | 445 | 300 | 0 | 136 | 9 | 445 | 27 | 154 |
-| jobs-fullrun-2.1 | 2026-06-10__15-35-20 | gemini/gemini-3.1-pro-preview | 445 | 291 | 0 | 144 | 10 | 444 | 55 | 159 |
-| jobs-gate | 2026-06-10__01-44-32 | gemini/gemini-3.1-pro-preview | 225 | 84 | 0 | 132 | 9 | 225 | 19 | 93 |
-| jobs-gate | 2026-06-10__06-50-59 | gemini/gemini-3.1-pro-preview | 225 | 79 | 0 | 136 | 10 | 224 | 19 | 250 |
-| jobs-gate | 2026-06-10__06-51-19 | gemini/gemini-3.1-pro-preview | 225 | 67 | 0 | 151 | 7 | 224 | 12 | 200 |
-| jobs-gate | 2026-06-10__09-38-07 | gemini/gemini-3.1-pro-preview | 221 | 85 | 0 | 127 | 9 | 221 | 29 | 130 |
-| jobs-gate | 2026-06-10__11-15-41 | gemini/gemini-3.1-pro-preview | 225 | 84 | 0 | 131 | 10 | 225 | 20 | 119 |
-| jobs-gate | 2026-06-10__13-36-04 | gemini/gemini-3.1-pro-preview | 225 | 83 | 0 | 81 | 61 | 198 | 13 | 91 |
-| jobs-rerun | 2026-06-09__22-01-02 | gemini/gemini-3.1-pro-preview | 163 | 57 | 0 | 98 | 8 | 162 | 17 | 67 |
-| qwen-floor | 2026-06-11__16-36-32 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 100 | 18 | 0 | 80 | 2 | 100 | 0 | 32 |
-| qwen-floor-board | 2026-06-12__07-56-37 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 90 | 24 | 0 | 63 | 3 | 90 | 5 | 18 |
-| qwen-mutation-trial | 2026-06-12__02-23-37 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 8 | 3 | 0 | 5 | 0 | 8 | 0 | 0 |
-| qwen-raise40 | 2026-06-11__19-12-16 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 99 | 17 | 0 | 79 | 3 | 99 | 1 | 24 |
-| qwen-spec-first | 2026-06-11__23-54-32 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 99 | 25 | 0 | 69 | 5 | 99 | 1 | 22 |
-| qwen-spec-first-board | 2026-06-12__05-38-49 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 194 | 46 | 0 | 142 | 6 | 194 | 2 | 62 |
-| qwen-spec-first-s30 | 2026-06-11__23-07-24 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 39 | 5 | 0 | 34 | 0 | 39 | 0 | 4 |
-| qwen-verify-repair | 2026-06-11__22-05-07 | vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas | 99 | 18 | 0 | 80 | 1 | 99 | 1 | 27 |
-| submission-2.0 | 2026-06-10__18-19-50 | gemini/gemini-3.1-pro-preview | 432 | 264 | 0 | 151 | 17 | 432 | 35 | 234 |
-| submission-2.0 | 2026-06-10__21-03-33 | gemini/gemini-3.1-pro-preview | 12 | 5 | 0 | 7 | 0 | 12 | 1 | 4 |
-
-## Interpretation boundary
-
-The generated JSON contains outcome, terminal-reason, protocol-error, timeout, and selected loop-event aggregates for every group. Semantic claims about mechanisms still require the paired gates, experiment log, and representative trajectory inspection; aggregate coverage alone cannot establish causality.
diff --git a/docs/benchmark-submission-audit.md b/docs/benchmark-submission-audit.md
deleted file mode 100644
index 467d17b..0000000
--- a/docs/benchmark-submission-audit.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Local Terminal-Bench corpus audit
-
-> Generated by `scripts/audit_terminal_bench.py`. This proves aggregate read coverage of every local trial result and available signal stream; it does not claim every transcript was manually interpreted.
-
-## Coverage
-
-- Per-trial results read: **445**
-- Run/corpus groups: **1**
-- Signal streams read: **445** (100.00% coverage)
-- Aggregate Harbor result files excluded: **1**
-- Unreadable trial results: **0**
-
-## Groups
-
-| Corpus | Run | Model | Trials | Pass | Partial | Fail | Errors | Signals | Parse errors | Command timeouts |
-|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
-| 2026-07-15__18-08-50-submission | 2026-07-15__18-08-50 | gemini/gemini-3.1-pro-preview | 445 | 296 | 0 | 147 | 2 | 445 | 77 | 112 |
-
-## Interpretation boundary
-
-The generated JSON contains outcome, terminal-reason, protocol-error, timeout, and selected loop-event aggregates for every group. Semantic claims about mechanisms still require the paired gates, experiment log, and representative trajectory inspection; aggregate coverage alone cannot establish causality.
diff --git a/docs/configuration.md b/docs/configuration.md
index 3f89304..0ef3d79 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,219 +1,89 @@
-# Configure Boatstack outcomes
+# Boatstack V2 configuration
-
-
-Boatstack's installer owns the complete `.boatstack-project.json` shape. Edit only the controls below, then regenerate the export and review the infrastructure diff. Fields not listed here are identity, compatibility, or installer state rather than product policy.
-
-## Delivery readiness and journey evidence
-
-New Boatstack plans use schema v3. Before approval is shown, Boatstack fetches
-`origin` and verifies the current feature worktree, base/head commits, branch,
-upstream relation, and journey-oracle manifest. Activation repeats the check and
-stores the same readiness fingerprint in the immutable plan lock.
-
-Each plan declares `journey_evidence`. Use `not_relevant` with a reason when no
-user or operator journey can regress. Use `relevant` with typed runnable oracles
-mapped to acceptance criteria when a journey matters. Relevant results are
-recorded with `record-journey-results`; test and review gates reject missing,
-failed, or stale results.
-
-## Choose the outcome
-
-| Outcome | Control | Enforcement |
-|---|---|---|
-| Use the correct base branch | `project.default_branch` | Boatstack uses it for freshness, PR, update, and workspace boundaries. |
-| Give planning bounded durable context | `project.context` | The coding agent consults these paths when relevant; Boatstack does not load all of them automatically. |
-| Advertise repository-owned checks | `project.commands` | The coding agent receives these commands. `test` is required by configuration validation. |
-| Mark sensitive paths | `project.high_risk_paths` | Matching changed paths participate in safety and PR-risk classification. |
-| Require human plan authorization | `workflow.human_plan_approval` | `true` requires a current fingerprinted human receipt; `false` creates a fingerprinted policy-activation lock without claiming human approval. |
-| Require independent high-risk review | `workflow.independent_review_for_high_risk` | Matching diffs require a typed review receipt naming the reviewer and `human_peer` or `separate_agent` method. |
-| Permit visible verification gaps | `workflow.allow_pass_with_gaps` | `false` rejects `PASS_WITH_GAPS` at delivery and PR gates; `true` retains the gaps as evidence. |
-| Maintain reader-facing history | `workflow.maintain_changelog` | Managed delivery and Boatstack-prepared PRs require a categorized `CHANGELOG.md` entry. |
-| Check for a systemic boundary | `workflow.boundary_analysis` | Planning guidance asks whether the request is a local symptom before scope expands. |
-| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. A plan that approves visual scenarios lifts `suggest` to require semantics for that feature; `off` and a per-feature `not_relevant` decision (with a reason) are the escapes. Boatstack captures registered scenarios automatically during ship; per-surface harnesses register as `project.commands["visual:"]` (`capability-register --surface`) and scenarios select them with a `surface` field. |
-| Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. |
-| Require repository-only credentials | `workflow.external_authority.*` | `credential-enforced` blocks managed execution without a current external receipt signed by a configured Ed25519 issuer. Omission stays explicitly `HOOK_GUARDED`. |
-| Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. |
-| Pursue the PR to merge, not just to open | `delivery.terminal` | `merged` keeps the read-only flow advisors naming post-publish steps (watch checks, route corrections) until the PR is observed merged; the default `published` ends the flow when the PR is open, exactly as before. |
-| Preserve and evaluate product insights | `insights.*` | Manual, fingerprint-bound captures and events become tracked `docs/insights/` diffs; PR evidence can update readiness, but only a human completes an insight. |
-| Use fresh feature workspaces | `workspace.*` | Boatstack creates and cleans branches or linked worktrees under the selected policy. |
-| Limit generated host surfaces | `adapters` | Export generates only the selected supported adapters. |
-
-The distinction in the Enforcement column matters: context, commands, and boundary analysis guide the coding agent; approval, gap, review, changelog, workspace, adapter, and visual-evidence policies also have deterministic Boatstack checks.
-
-## Project controls
+`.boatstack/project.json` is the repository-owned policy input. V2 accepts only
+schema version 2. Unknown top-level fields, unsupported policy values, duplicate
+hosts, trailing JSON, and missing required fields fail closed.
```json
{
+ "schema_version": 2,
"project": {
+ "name": "example-product",
"default_branch": "main",
- "context": ["README.md", "AGENTS.md", "docs/decisions/"],
+ "context": ["README.md", "docs/architecture/"],
"commands": {
- "test": "npm test",
- "lint": "npm run lint",
- "typecheck": "npm run typecheck"
+ "build": "npm run build",
+ "test": "npm test"
},
- "high_risk_paths": ["migrations/**", "auth/**", "billing/**"]
- }
-}
-```
-
-`context` is a bounded discovery hint, not a request to scan every path. Command names other than `test` are optional and become available to the coding agent under their chosen names.
-
-## Workflow controls
-
-```json
-{
- "workflow": {
- "human_plan_approval": true,
+ "high_risk_paths": ["migrations/**", "billing/**"]
+ },
+ "policy": {
+ "plan_approval": "human",
"independent_review_for_high_risk": true,
- "allow_pass_with_gaps": false
- }
-}
-```
-
-When human approval is disabled, Boatstack still locks the exact plan and inputs using `authorization_mode: policy`. For high-risk review, the review gate records reviewer provenance; this is an auditable claim, not cryptographic identity proof.
-
-```json
-{
- "workflow": {
- "maintain_changelog": true,
- "boundary_analysis": true
- }
-}
-```
-
-Changelog enforcement is mechanical. Boundary analysis is model-mediated planning guidance and cannot silently expand approved scope.
-
-```json
-{
- "workflow": {
- "external_authority": {
- "mode": "credential-enforced",
- "trust_store": "/etc/boatstack/authority-issuers.json"
- }
- }
-}
-```
-
-Strict mode requires an external service-IAM, credential-broker, or isolated-host attestor. The JSON trust store maps issuer IDs to base64 Ed25519 public keys and must be operator-owned outside the repository; Boatstack rejects a file or parent directory owned or writable by the managed principal. Obtain the expected binding with `.product-loop/boatstack authority-context --repo .`; the attestor signs a receipt for that repository, worktree, host session, principal, and a maximum 15-minute lifetime. Set the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the attested session in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. Boatstack never holds the signing key. Missing or invalid evidence blocks `run-preflight` and remains `HOOK_GUARDED`; only a valid external receipt reports `CREDENTIAL_ENFORCED`.
-
-```json
-{
- "workflow": {
- "pr_visual_evidence": "suggest"
- }
-}
-```
-
-Visual-evidence values are `off`, `suggest`, and `require`. Screenshot bytes stay outside Git history until explicitly attached to the PR.
-
-By default Boatstack can publish those screenshots inline only for a **public** repository (it commits the bytes to a Boatstack-owned public branch and renders them from an immutable raw URL). On a **private** repository GitHub cannot fetch those bytes for the comment, so it falls back to manual attachment. To render inline on a private repository, opt into the external-host mode:
-
-```json
-{
- "workflow": {
- "visual_evidence_publish": {
- "mode": "external-host",
- "host": "litterbox",
- "expiry": "72h"
- }
- }
-}
-```
-
-`mode: external-host` uploads the exact captured PNG bytes to an anonymous host (`litterbox`, which auto-expires uploads after `expiry` — one of `1h`, `12h`, `24h`, `72h`; or `catbox`, permanent) and posts the returned URLs inline. It is **never automatic** — only this explicit value turns it on — because the bytes leave your repository to a third party. The PR comment carries a standing reminder naming the host and expiry, so do not use this mode for sensitive screenshots.
-
-```json
-{
- "workflow": {
- "ignored_deliveries": ["old-feature-slug", "another-past-feature"]
- }
-}
-```
-
-List feature slugs here to drop past deliveries from the ambiguity check so historical work no longer blocks new work. Any new, unlisted ambiguous delivery still pauses the workflow.
-
-## Workspace and adapter controls
-
-```json
-{
- "workspace": {
- "enabled": true,
- "mode": "worktree",
- "cleanup": "confirm",
- "cleanup_after": "merge",
- "reap": "confirm"
+ "visual_evidence": "optional",
+ "external_effect_authority": "human-or-autonomy-plus-provider"
},
- "adapters": ["cursor", "claude", "codex", "github"]
+ "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"]
}
```
-Workspace `mode` is `worktree` or `branch`; cleanup is `confirm`, `auto`, or `off`; and cleanup eligibility begins after `merge` or `ship`. `reap` is `confirm`, `auto`, or `off`: when a delivery's PR is confirmed merged, Boatstack sweeps every terminal (merged or abandoned) Boatstack workspace at once — `confirm` asks the operator once before reclaiming them, `auto` reclaims without asking, and `off` disables the sweep. Supported adapters are `cursor`, `claude`, `codex`, `gemini`, and `github`. Empty or omitted adapters enable all supported surfaces.
-
-## Delivery goal
-
-```json
-{
- "delivery": {
- "terminal": "merged"
- }
-}
+## Required values
+
+- `project.name`, `project.default_branch`, and `project.commands`;
+- `policy.plan_approval`: `human` or `human-or-autonomy`;
+- `policy.visual_evidence`: `off`, `optional`, or `required`;
+- at least the `cli` host.
+
+The only accepted external-effect authority policy is
+`human-or-autonomy-plus-provider`. Provider authority is an independent
+mandatory clause; it cannot be replaced by a human receipt.
+
+The canonical snapshot carries this policy projection as controlling evidence.
+`human` plan approval rejects autonomy receipts. `human-or-autonomy` accepts
+either class. When independent high-risk review is enabled, the observer derives
+the changed paths from `default_branch...HEAD` plus tracked and untracked working
+tree changes; a matching `high_risk_paths` glob makes `gate.review.record`
+require human authority. A required visual policy prevents the verified terminal
+until a revision-bound `evidence.visual.attach` receipt exists, while `off`
+refuses attachment. A host omitted from `hosts` cannot request managed
+transitions. If the configured default branch cannot be inspected, the
+high-risk derivation fails closed whenever that policy is active.
+
+`project.commands` names the repository's canonical product checks. Build and
+test gate transitions execute those exact repository-owned commands inside the
+effect boundary and install evidence only after a zero exit status. The command
+is screened by the same constitutional guard first, and its output is never
+persisted. Gate authority also requires a strict revision-bound passed-evidence
+document; an arbitrary fingerprint string is insufficient.
+
+To change configuration, write a candidate file elsewhere, then request
+`configuration.mutate`. The CLI derives `config_sha256` from the strict decoded
+schema-2 value in canonical JSON form. Formatting, object-key order, and LF/CRLF
+checkout conversion therefore retain the same authority, while any controlling
+value change produces a new fingerprint. The kernel still copies the exact
+candidate bytes, installs state last, re-observes the tracked file, and accepts
+success only if its semantic fingerprint remains current.
+
+## Configuration authority and topology
+
+Embedded repositories read `.boatstack/project.json`. `repository.attach`
+requires an explicit `config_authority` of `repository` or `external`:
+
+- `repository` keeps the committed repository document authoritative;
+- `external` transactionally copies the currently verified bytes into the
+ clone-family external controller before installing the detached binding.
+
+`repository.detach` performs the inverse verified-byte transfer when external
+authority is active, then removes the binding last. A missing, invalid, or
+fingerprint-mismatched source is never promoted as verified configuration.
+The detached binding records the selected authority, so readers never choose a
+configuration source by first-match path discovery.
+
+```sh
+boatstack attach --repo . --human alice \
+ --goal-id bootstrap --goal-kind approved-plan --delivery bootstrap \
+ --param topology=detached --param config_authority=external
```
-`delivery.terminal` names the state a delivery pursues before the flow reports nothing left to do. The default `published` ends the flow when the slice's pull request is open, exactly as before. `merged` keeps the read-only flow advisors (`next-status`, `flow next`, `flow frontier`, `flow watch`) naming post-publish steps — watch the checks, route a correction, surface merge eligibility — until the pull request is observed merged. The goal a delivery starts under is snapshotted with the delivery, so changing this value never changes an in-progress delivery's goal. Boatstack itself never merges a pull request under any setting.
-
-## Independent insight controls
-
-```json
-{
- "insights": {
- "enabled": true,
- "capture_mode": "manual",
- "value_map": "required",
- "suggest_features": true,
- "evaluate_on_pr": true,
- "pending_frontier": true,
- "completion_mode": "human_confirmed"
- }
-}
-```
-
-Every save follows a complete Value Map preview, a warning that the exact content will enter Git history, and a separate confirmation bound to that preview. Captures, their human-readable projections, and append-only events live below `docs/insights//`. Each mutation is therefore a reviewable repository diff. Boatstack stores no insight content in detached or Git control state. Topic suggestions do not create deliveries. PR publication or terminal observation may append evaluation evidence, but never completes an insight. The insight frontier remains separate from Boatstack's authoritative delivery next action.
-
-## Installer-owned fields
-
-The installer maintains `schema_version`, `project.name`, and integration records. Select gstack or Spec Kit through installation and update flows. Their `requested`, `status`, `version`, and `detail` values are receipts and provenance, not hand-edited workflow switches.
-
-For serialization, defaults, migration, and installer compatibility details, see the generated internal configuration schema in `.product-loop/config-schema.md`.
+V1 configuration schemas are intentionally unsupported. Reinstall or supply a
+new V2 document; no compatibility conversion runs.
diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md
deleted file mode 100644
index 399e751..0000000
--- a/docs/evidence-engineered-coding.md
+++ /dev/null
@@ -1,149 +0,0 @@
-# Evidence-engineered coding
-
-Boatstack is a mathematically modeled coding node, not a prescribed loop. It leaves implementation open and makes authority, evidence, and accepted outcomes observable at the node boundary.
-
-```text
-product intent + repository state
- |
- v
- [ BOATSTACK ]
- |
- v
-diff + evidence + decisions + known gaps
-```
-
-## The minimal state
-
-For current repository state `x_t`, select only the task-relevant slice:
-
-```text
-z_t = P_s(x_t)
-```
-
-Canonicalize that slice into one domain, one contract, one outcome, and one next operator:
-
-```text
-r_t = R(z_t)
-u_t = f(r_t)
-x_(t+1) = V(u_t, acceptance, invariants)
-```
-
-In the repository, those terms are not decorative notation:
-
-| Term | Boatstack artifact or check |
-|---|---|
-| `P_s` | context paths in `project.json` plus the current feature boundary |
-| `R` | question ledger, feature spec, acceptance criteria, structured plan |
-| `f` | one operation: auto-plan, plan-gate, build, test, review, or ship |
-| `V` | requirement/test matrix, command evidence, review findings, plan hashes |
-| `x_(t+1)` | locked plan, bounded diff, gate result, PR, or recorded gap |
-
-ZCA creates immediate value by reducing a vague feature request to one verifiable slice. For a shipped SDK, API, or CLI, Boatstack uses two slices: the implementation boundary and one representative consumer path.
-
-Delivery phases use the same separation. Internal phases are tasks inside one slice.
-If the accepted result truly needs multiple PRs, the plan defines ordered delivery
-slices, but the control state activates only one. Every active slice must produce
-fresh diff-bound test and review receipts and receive its own ship confirmation;
-approval of the parent plan carries scope, not publication authority.
-
-The entry state is not an unstructured chat message. Ordinary product intent is first explored in the active host's Plan mode and saved as a source plan. The host passes that file's path to `auto-plan` explicitly (`--plan `); Boatstack never scans directories for plans, and requires that file before projecting repository context:
-
-```text
-ordinary intent --host Plan mode--> source plan file --auto-plan--> reviewable feature package
-```
-
-The source plan is neither approval nor executable truth. It is the provenance-bearing proposal that Boatstack questions, grounds in repository evidence, and refines. Its file and hash are required through build. After build, evidence gates operate from the approved lock and actual change, so they do not depend on continuing to load the original source plan.
-
-## Preserve the source; project the slice
-
-Context is potential value, not guaranteed value. Let `C` be canonical repository context, `Y` the desired outcome, and `T(C)` a deterministic translation into another representation. The data processing inequality gives the motivating bound:
-
-```text
-I(Y; T(C)) <= I(Y; C)
-```
-
-A translation cannot create information about `Y` that was absent from `C`, and it may lose relationships, provenance, or constraints. This is a mathematical bound on information, not proof that every summary or projection makes an AI system perform worse. Under a finite context window and finite attention, a relevant projection can improve effective task performance by excluding noise.
-
-Boatstack therefore keeps two distinct context slices:
-
-1. **Canonical product knowledge:** repository-owned documents, code, decisions, and history remain authoritative and keep their existing structure.
-2. **Temporary task projection:** the smallest relevant subset is selected for the current operator, with source references and reviewable transformations.
-
-Generated specs and plans may clarify or canonicalize the task, but they never silently replace their sources. Value emerges from lowering task entropy while retaining a path back to the information from which each claim was derived.
-
-## Open execution, controlled claims
-
-Boatstack does not constrain the implementation operator. A model may explore, edit, test, backtrack, delegate, or select any suitable method inside the approved boundary. What it cannot do is silently convert activity into authority or a completion claim.
-
-```text
-open: implementation method, model, tools, local tactics
-controlled: approved intent, evidence, acceptance, review, shipping authority
-```
-
-This is the central separation: **you are free in how you build; only claims of completion require evidence.**
-
-## Optimization is constrained, not blind compression
-
-Boatstack aims to minimize the cost of context and ceremony subject to an accepted outcome:
-
-```text
-minimize C(context) + C(ceremony) + C(rework)
-subject to acceptance criteria pass
- project invariants hold
- required evidence exists
- approval is current
-```
-
-That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **24542 estimated tokens**, while host adapters point to one operation at a time.
-
-## Control appears at transitions
-
-The Plan-mode approval gate is a concrete controller boundary:
-
-```text
-fingerprint = hash(source_plan + spec + complete_plan_md)
-if fingerprint != approval_md.approval_fingerprint:
- BLOCKED: planning input changed after approval
-```
-
-Build activation adds the machine coverage boundary:
-
-```text
-uncovered = acceptance_ids - task_acceptance_ids
-if uncovered:
- BLOCKED: acceptance criteria lack tasks or verification
-```
-
-The test gate maps each claim to evidence instead of asking the implementer whether it feels finished:
-
-```text
-AC-4: ASCII output stays byte-compatible
- -> diff -u expected-output.txt actual-output.txt
- -> PASS | FAIL | BLOCKED
-```
-
-These boundaries do not guarantee correct software. They make missing authority, missing coverage, stale state, and failed evidence observable before shipping.
-
-The full oracle, ambiguity, and evidence model is documented in [Validation and evidence](validation-and-evidence.md).
-
-## The graph follows the work
-
-Boatstack does not force every change through a feedback cycle:
-
-```text
-linear: intent -> Boatstack -> accepted change
-feedback: evidence -> revision -> new evidence
-branch/merge: path A --\
- compare -> accepted change
- path B --/
-```
-
-A successful first pass stays linear. A failed or incomplete check creates a feedback edge. Parallel implementation or review creates branches. Boatstack can therefore be a node in a loop without defining itself by the loop.
-
-Delivery and system improvement also remain separate. A failed task may suggest a better harness move, but one anecdote cannot silently rewrite every future instruction. Promotion requires a representative comparison and a non-regression boundary.
-
-## What is evidence-backed
-
-The current moves were derived from an audited benchmark corpus and product-repository studies. Current implementation and verification evidence lives in this repository.
-
-The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results.
diff --git a/docs/generated-files.md b/docs/generated-files.md
index 690e60e..b8be4e9 100644
--- a/docs/generated-files.md
+++ b/docs/generated-files.md
@@ -1,70 +1,49 @@
-# What Boatstack adds to a repository
+# V2 files and ownership
-**For:** anyone reviewing an installation or feature PR.
-**Outcome:** know what to commit, what can be edited, and what Boatstack regenerates.
+## Committed product evidence
-Boatstack creates installation state once and feature evidence repeatedly. Keeping those two groups separate is what makes later product diffs understandable.
+Boatstack may create these reviewable paths through registered effects:
-## Installation PR
-
-| Path | What it is | What you do |
+| Path | Owner | Meaning |
|---|---|---|
-| `.boatstack-project.json` | Project-owned repository facts and commands | Review and edit |
-| `.product-loop/` references, templates, hooks, launchers, and generated lock | Shared Boatstack contract | Commit; regenerate rather than hand-edit |
-| `.product-loop/boatstack` | POSIX exact-runtime command launcher | Commit with executable mode; invoke for Boatstack commands |
-| `.product-loop/boatstack.ps1` | PowerShell exact-runtime command launcher | Commit; invoke with `& .product-loop/boatstack.ps1` |
-| `.cursor/`, `.agents/`, and `.claude/` Boatstack adapters | Cursor commands, the Codex router, and Claude's visible workflow skills plus hidden natural-language router | Commit |
-| `.github/PULL_REQUEST_TEMPLATE/boatstack.md` | Fallback PR structure | Commit |
-| `.cursor/hooks.json`, `.claude/settings.json`, `.codex/hooks.json` | Boatstack fragments merged with existing host settings | Review and commit |
-| `.product-loop/bin/` | Verified worktree-local helper and install lock | Never commit; it is ignored and hydrates automatically |
-| `release-notes/*.md` | Canonical user-facing messages reused by pull requests and tagged releases | Add directly in Boatstack; append-only |
-
-The installer prints the exact staging command and runs `doctor`. Put this state in `chore/install-boatstack`, review it once, and merge it before feature work.
-
-## Feature PR
-
-Boatstack stores feature artifacts under `.product-loop/features//`:
-
-| Artifact | Why it exists |
-|---|---|
-| `source-plan.md` | Preserves the host's first interpretation of the request |
-| `feature-spec.md` | Defines the accepted outcome and exclusions |
-| `questions.md` | Separates repository facts, proposals, human answers, and unknowns |
-| `gaps.md` | Keeps deferred or incomplete work visible |
-| `test-plan.md` | Connects promised outcomes to checks |
-| `plan.md` | Holds the human-readable approved plan |
-| `approval.md` | Records who approved which exact plan |
-| `autonomy.md` | Records one explicit run target and its bounded policy decisions without claiming human approval |
-| `compiled/` and `plan.lock.json` | Prove that build activated the approved inputs without drift |
-| `evidence.md` | Records commands, results, review findings, and gate status |
-| `pr.md` | Contains the exact reviewer-ready title and body preview |
-
-These artifacts travel with the feature because they explain what was agreed and what supports completion. Changing the source plan, spec, or plan invalidates approval until the plan gate runs again.
+| `.boatstack/project.json` | repository policy | strict V2 configuration |
+| `.boatstack/plans/