diff --git a/.github/reviews/codex-go-module-program-recovery.receipt.json b/.github/reviews/codex-go-module-program-recovery.receipt.json new file mode 100644 index 0000000..f9740ff --- /dev/null +++ b/.github/reviews/codex-go-module-program-recovery.receipt.json @@ -0,0 +1,4 @@ +{ + "reviewed_tree": "95b93fceaa40213ef42678c0e46c47207671ff31", + "program_fingerprint": "3ca3397ff275d89bdb6d5c934b86b51d3cbdfab0ee628c47fe94d1d4f5767155" +} diff --git a/.github/scripts/publish_release_tags.py b/.github/scripts/publish_release_tags.py new file mode 100755 index 0000000..17272db --- /dev/null +++ b/.github/scripts/publish_release_tags.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Publish one stable Boatstack root/module tag pair atomically.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + + +STABLE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") + + +class PublicationBlocked(RuntimeError): + """The selected tag pair cannot be published safely.""" + + +def git(repository: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["git", *arguments], + cwd=repository, + text=True, + capture_output=True, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise PublicationBlocked(f"git {' '.join(arguments)} failed: {detail}") + return result + + +def git_output(repository: Path, *arguments: str) -> str: + return git(repository, *arguments).stdout.strip() + + +def remote_ref(repository: Path, remote: str, ref: str) -> tuple[str, str] | None: + result = git(repository, "ls-remote", remote, ref, f"{ref}^{{}}") + refs: dict[str, str] = {} + for line in result.stdout.splitlines(): + object_id, name = line.split(maxsplit=1) + refs[name] = object_id + direct = refs.get(ref) + if direct is None: + return None + return direct, refs.get(f"{ref}^{{}}", direct) + + +def exact_commit(repository: Path, source: str) -> str: + resolved = git_output(repository, "rev-parse", "--verify", f"{source}^{{commit}}") + if resolved != source: + raise PublicationBlocked(f"release source must be an exact commit SHA: {source}") + return resolved + + +def require_source(repository: Path, remote: str, source: str) -> None: + exact_commit(repository, source) + head = git_output(repository, "rev-parse", "HEAD") + if head != source: + raise PublicationBlocked(f"checked-out source {head} does not match release source {source}") + main = remote_ref(repository, remote, "refs/heads/main") + if main is None: + raise PublicationBlocked("remote main is absent") + if main[0] != source: + raise PublicationBlocked(f"remote main moved from {source} to {main[0]}") + + +def inspect_pair( + repository: Path, + remote: str, + root_tag: str, + module_tag: str, +) -> tuple[tuple[str, str] | None, tuple[str, str] | None]: + root = remote_ref(repository, remote, f"refs/tags/{root_tag}") + module = remote_ref(repository, remote, f"refs/tags/{module_tag}") + return root, module + + +def require_pair_absent( + repository: Path, + remote: str, + root_tag: str, + module_tag: str, +) -> None: + root, module = inspect_pair(repository, remote, root_tag, module_tag) + if root is not None and module is not None and root[1] != module[1]: + raise PublicationBlocked( + f"paired tag targets differ: {root_tag}={root[1]}, {module_tag}={module[1]}" + ) + existing = [ + tag + for tag, target in ((root_tag, root), (module_tag, module)) + if target is not None + ] + if existing: + raise PublicationBlocked(f"release tag already exists: {', '.join(existing)}") + + +def publish( + repository: Path, + remote: str, + source: str, + root_tag: str, + module_tag: str, +) -> dict[str, str]: + if STABLE_TAG.fullmatch(root_tag) is None: + raise PublicationBlocked(f"root tag is not a stable vMAJOR.MINOR.PATCH tag: {root_tag}") + expected_module_tag = f"boatstack/{root_tag}" + if module_tag != expected_module_tag: + raise PublicationBlocked( + f"module tag {module_tag} does not match derived tag {expected_module_tag}" + ) + + require_source(repository, remote, source) + require_pair_absent(repository, remote, root_tag, module_tag) + for tag in (root_tag, module_tag): + local = git(repository, "show-ref", "--verify", "--quiet", f"refs/tags/{tag}", check=False) + if local.returncode == 0: + raise PublicationBlocked(f"local release tag already exists: {tag}") + if local.returncode != 1: + raise PublicationBlocked(f"could not inspect local release tag: {tag}") + + git(repository, "tag", "-a", root_tag, "-m", f"Boatstack {root_tag}", source) + git( + repository, + "tag", + "-a", + module_tag, + "-m", + f"Boatstack Go module {root_tag}", + source, + ) + for tag in (root_tag, module_tag): + target = git_output(repository, "rev-parse", f"refs/tags/{tag}^{{commit}}") + if target != source: + raise PublicationBlocked(f"local tag {tag} resolves to {target}, expected {source}") + + # These checks deliberately run again after local tag creation. A racing + # remote write is then rejected by the final non-force atomic push. + require_source(repository, remote, source) + require_pair_absent(repository, remote, root_tag, module_tag) + git( + repository, + "push", + "--atomic", + remote, + f"refs/tags/{root_tag}", + f"refs/tags/{module_tag}", + ) + + root, module = inspect_pair(repository, remote, root_tag, module_tag) + if root is None or module is None or root[1] != source or module[1] != source: + raise PublicationBlocked("published tag pair does not resolve to the release source") + return { + "module_tag": module_tag, + "release_source": source, + "root_tag": root_tag, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--remote", default="origin") + parser.add_argument("--source", required=True) + parser.add_argument("--root-tag", required=True) + parser.add_argument("--module-tag", required=True) + arguments = parser.parse_args() + + try: + result = publish( + arguments.repo.resolve(), + arguments.remote, + arguments.source, + arguments.root_tag, + arguments.module_tag, + ) + except PublicationBlocked as error: + print(f"BLOCKED: {error}", file=sys.stderr) + return 2 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_candidate.py b/.github/scripts/release_candidate.py index cff6264..5373a67 100644 --- a/.github/scripts/release_candidate.py +++ b/.github/scripts/release_candidate.py @@ -86,6 +86,7 @@ def classify(repository: Path, source: str) -> dict[str, str]: "release_required": "true" if added else "false", "latest_tag": latest_tag, "next_tag": next_tag, + "module_tag": f"boatstack/{next_tag}", "release_source": source, } diff --git a/.github/tests/test_docs_contract.py b/.github/tests/test_docs_contract.py index 9c8d8c6..be37d84 100644 --- a/.github/tests/test_docs_contract.py +++ b/.github/tests/test_docs_contract.py @@ -147,6 +147,22 @@ def assert_pages_contract(testcase: unittest.TestCase, workflow: str) -> None: class DocumentationContractTests(unittest.TestCase): + def test_getting_started_distinguishes_cli_and_go_module_installation(self) -> None: + getting_started = (REPO / "docs" / "getting-started.md").read_text() + readme = (REPO / "README.md").read_text() + module = "github.com/operatorstack/boatstack/boatstack" + self.assertIn("## Install the CLI", getting_started) + self.assertIn("checksum-verifying installer", getting_started) + self.assertIn("## Import the Go module", getting_started) + self.assertIn(f"go get {module}@vX.Y.Z", getting_started) + self.assertIn(f'"{module}/kernel"', getting_started) + self.assertIn(f'"{module}/kernel/conformance"', getting_started) + self.assertIn(f"GOWORK=off go list -m {module}@vX.Y.Z", getting_started) + self.assertIn("root and nested module tags", getting_started) + self.assertIn("alpha", getting_started.lower()) + self.assertIn("CLI or import the Go module", readme) + self.assertIn(module, readme) + def test_documentation_entrypoint_links_resolve(self) -> None: for path in ( REPO / "README.md", diff --git a/.github/tests/test_external_go_module.py b/.github/tests/test_external_go_module.py new file mode 100644 index 0000000..26809f6 --- /dev/null +++ b/.github/tests/test_external_go_module.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[2] +MODULE = REPO / "boatstack" +MODULE_PATH = "github.com/operatorstack/boatstack/boatstack" + + +class ExternalGoModuleTest(unittest.TestCase): + def test_public_kernel_imports_compile_from_clean_module(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + consumer = root / "consumer" + consumer.mkdir() + (consumer / "go.mod").write_text( + "\n".join( + ( + "module example.invalid/boatstack-consumer", + "", + "go 1.26", + "", + f"require {MODULE_PATH} v0.0.0", + "", + f"replace {MODULE_PATH} => {MODULE.as_posix()}", + "", + ) + ) + ) + (consumer / "consumer.go").write_text( + f'''package consumer + +import ( + "{MODULE_PATH}/kernel" + "{MODULE_PATH}/kernel/conformance" +) + +func ReferenceFixture() (kernel.Program, conformance.KernelConformance, error) {{ + program, err := conformance.IntegerProgram() + return program, conformance.IntegerFixture(), err +}} +''' + ) + environment = os.environ.copy() + environment.update( + { + "GOCACHE": str(root / "go-cache"), + "GOMODCACHE": str(root / "go-mod-cache"), + "GOWORK": "off", + } + ) + result = subprocess.run( + ["go", "test", "./..."], + cwd=consumer, + env=environment, + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stderr or result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/test_publish_release_tags.py b/.github/tests/test_publish_release_tags.py new file mode 100644 index 0000000..836fe73 --- /dev/null +++ b/.github/tests/test_publish_release_tags.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import json +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / ".github" / "scripts" / "publish_release_tags.py" + + +class PublishReleaseTagsTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + root = Path(self.temporary.name) + self.repository = root / "source" + self.remote = root / "remote.git" + self.git("init", "--initial-branch=main", str(self.repository), cwd=root) + self.git("init", "--bare", str(self.remote), cwd=root) + self.git("config", "user.email", "release-test@example.invalid") + self.git("config", "user.name", "Release Test") + self.write("release-notes/base.md", "### Base release\n") + self.base = self.commit("Create base release") + self.git("tag", "v1.2.3", self.base) + self.git("remote", "add", "origin", str(self.remote)) + self.git("push", "origin", "main", "refs/tags/v1.2.3") + self.write("release-notes/change.md", "### Changed behavior\n") + self.source = self.commit("Add release-bearing change") + self.git("push", "origin", "main") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def git(self, *arguments: str, cwd: Path | None = None, check: bool = True) -> str: + result = subprocess.run( + ["git", *arguments], + cwd=cwd or self.repository, + text=True, + capture_output=True, + ) + if check and result.returncode != 0: + self.fail(result.stderr or result.stdout) + return result.stdout.strip() + + def write(self, name: str, content: str) -> None: + path = self.repository / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + def commit(self, message: str) -> str: + self.git("add", ".") + self.git("commit", "-m", message) + return self.git("rev-parse", "HEAD") + + def publish(self, source: str | None = None, module_tag: str = "boatstack/v1.2.4") -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + str(SCRIPT), + "--repo", + str(self.repository), + "--source", + source or self.source, + "--root-tag", + "v1.2.4", + "--module-tag", + module_tag, + ], + text=True, + capture_output=True, + ) + + def remote_target(self, tag: str) -> str | None: + result = subprocess.run( + ["git", "ls-remote", str(self.remote), f"refs/tags/{tag}^{{}}", f"refs/tags/{tag}"], + text=True, + capture_output=True, + check=True, + ) + values = {} + for line in result.stdout.splitlines(): + object_id, ref = line.split(maxsplit=1) + values[ref] = object_id + return values.get(f"refs/tags/{tag}^{{}}", values.get(f"refs/tags/{tag}")) + + def create_remote_tag(self, tag: str, target: str) -> None: + self.git("tag", "-a", tag, "-m", tag, target) + self.git("push", "origin", f"refs/tags/{tag}") + + def test_publishes_annotated_pair_to_exact_source(self) -> None: + result = self.publish() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + json.loads(result.stdout), + { + "module_tag": "boatstack/v1.2.4", + "release_source": self.source, + "root_tag": "v1.2.4", + }, + ) + self.assertEqual(self.remote_target("v1.2.4"), self.source) + self.assertEqual(self.remote_target("boatstack/v1.2.4"), self.source) + for tag in ("v1.2.4", "boatstack/v1.2.4"): + self.assertEqual(self.git("cat-file", "-t", f"refs/tags/{tag}"), "tag") + + def test_refuses_when_root_tag_exists_without_module_tag(self) -> None: + self.create_remote_tag("v1.2.4", self.source) + result = self.publish() + self.assertEqual(result.returncode, 2) + self.assertIn("release tag already exists: v1.2.4", result.stderr) + self.assertIsNone(self.remote_target("boatstack/v1.2.4")) + + def test_refuses_when_module_tag_exists_without_root_tag(self) -> None: + self.create_remote_tag("boatstack/v1.2.4", self.source) + result = self.publish() + self.assertEqual(result.returncode, 2) + self.assertIn("release tag already exists: boatstack/v1.2.4", result.stderr) + self.assertIsNone(self.remote_target("v1.2.4")) + + def test_refuses_mismatched_existing_pair(self) -> None: + self.create_remote_tag("v1.2.4", self.source) + self.create_remote_tag("boatstack/v1.2.4", self.base) + result = self.publish() + self.assertEqual(result.returncode, 2) + self.assertIn("paired tag targets differ", result.stderr) + self.assertEqual(self.remote_target("v1.2.4"), self.source) + self.assertEqual(self.remote_target("boatstack/v1.2.4"), self.base) + + def test_refuses_when_checked_out_source_changed(self) -> None: + result = self.publish(source=self.base) + self.assertEqual(result.returncode, 2) + self.assertIn("does not match release source", result.stderr) + self.assertIsNone(self.remote_target("v1.2.4")) + self.assertIsNone(self.remote_target("boatstack/v1.2.4")) + + def test_refuses_when_remote_main_moved(self) -> None: + self.write("later.txt", "later\n") + moved = self.commit("Move main") + self.git("push", "origin", "main") + self.git("reset", "--hard", self.source) + result = self.publish() + self.assertEqual(result.returncode, 2) + self.assertIn(f"remote main moved from {self.source} to {moved}", result.stderr) + self.assertIsNone(self.remote_target("v1.2.4")) + self.assertIsNone(self.remote_target("boatstack/v1.2.4")) + + def test_rejected_atomic_push_leaves_no_remote_tag(self) -> None: + hook = self.remote / "hooks" / "pre-receive" + hook.write_text("#!/bin/sh\nexit 1\n") + hook.chmod(hook.stat().st_mode | stat.S_IXUSR) + result = self.publish() + self.assertEqual(result.returncode, 2) + self.assertIn("git push --atomic", result.stderr) + self.assertIsNone(self.remote_target("v1.2.4")) + self.assertIsNone(self.remote_target("boatstack/v1.2.4")) + + def test_refuses_non_derived_module_tag(self) -> None: + result = self.publish(module_tag="boatstack/v1.2.5") + self.assertEqual(result.returncode, 2) + self.assertIn("does not match derived tag", result.stderr) + + def test_release_workflow_remains_root_tag_only(self) -> None: + workflow = (REPO / ".github" / "workflows" / "release.yml").read_text() + trigger = workflow.split("permissions:", 1)[0] + self.assertIn('tags: ["v*"]', trigger) + self.assertNotIn("boatstack/v", trigger) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/test_release_candidate.py b/.github/tests/test_release_candidate.py index 8e1838e..ef8582b 100644 --- a/.github/tests/test_release_candidate.py +++ b/.github/tests/test_release_candidate.py @@ -89,6 +89,7 @@ def test_added_note_selects_next_patch_tag(self) -> None: json.loads(result.stdout), { "latest_tag": "v1.2.3", + "module_tag": "boatstack/v1.2.4", "next_tag": "v1.2.4", "release_required": "true", "release_source": source, @@ -136,6 +137,17 @@ def test_prerelease_and_malformed_tags_do_not_replace_latest_stable(self) -> Non self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout)["latest_tag"], "v1.2.3") + def test_module_tags_do_not_change_root_patch_calculation(self) -> None: + self.git("tag", "boatstack/v9.9.9") + self.write("release-notes/change.md", "### Changed behavior\n") + self.commit("Add release-bearing change") + result = self.classify() + self.assertEqual(result.returncode, 0, result.stderr) + classified = json.loads(result.stdout) + self.assertEqual(classified["latest_tag"], "v1.2.3") + self.assertEqual(classified["next_tag"], "v1.2.4") + self.assertEqual(classified["module_tag"], "boatstack/v1.2.4") + def test_existing_candidate_tag_on_unrelated_history_is_blocked(self) -> None: unrelated = self.git("commit-tree", "HEAD^{tree}", "-m", "Unrelated release") self.git("tag", "v1.2.4", unrelated) diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index ce5fe8c..fab76d3 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -265,6 +265,7 @@ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() release = (REPO / ".github" / "workflows" / "release.yml").read_text() automatic = (REPO / ".github" / "workflows" / "auto-release.yml").read_text() + publisher = (REPO / ".github" / "scripts" / "publish_release_tags.py").read_text() for asset in ( "boatstack-helper_linux_amd64", "boatstack-helper_linux_arm64", @@ -291,8 +292,15 @@ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: self.assertIn('git ls-remote origin refs/heads/main', automatic) self.assertGreaterEqual(automatic.count('git ls-remote origin refs/heads/main'), 2) self.assertIn("git fetch --force --tags origin", automatic) - self.assertIn('git ls-remote --exit-code --tags origin "refs/tags/$next_tag"', automatic) self.assertIn("release_candidate.py", automatic) + self.assertIn("publish_release_tags.py", automatic) + self.assertIn("EXPECTED_MODULE_TAG", automatic) + self.assertIn('f"boatstack/{root_tag}"', publisher) + self.assertIn("require_source(repository, remote, source)", publisher) + self.assertIn("require_pair_absent(repository, remote, root_tag, module_tag)", publisher) + self.assertIn('"--atomic"', publisher) + self.assertIn('f"refs/tags/{root_tag}"', publisher) + self.assertIn('f"refs/tags/{module_tag}"', publisher) self.assertIn("cancel-in-progress: false", automatic) self.assertIn("no verified unreleased changes; no release was created", automatic) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index a8ce11d..c0f3503 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -87,11 +87,12 @@ jobs: ref: ${{ steps.source.outputs.sha }} fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - - name: Create next verified patch tag + - name: Create next verified patch tag pair if: steps.classify.outputs.release_required == 'true' env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} EXPECTED_LATEST_TAG: ${{ steps.classify.outputs.latest_tag }} + EXPECTED_MODULE_TAG: ${{ steps.classify.outputs.module_tag }} EXPECTED_NEXT_TAG: ${{ steps.classify.outputs.next_tag }} RELEASE_SOURCE: ${{ steps.source.outputs.sha }} shell: bash @@ -110,21 +111,22 @@ jobs: candidate="$(python3 .github/scripts/release_candidate.py --repo . --source "$RELEASE_SOURCE")" release_required="$(jq -r .release_required <<< "$candidate")" latest_tag="$(jq -r .latest_tag <<< "$candidate")" + module_tag="$(jq -r .module_tag <<< "$candidate")" next_tag="$(jq -r .next_tag <<< "$candidate")" [[ "$release_required" == true ]] || { echo "BLOCKED: no unreleased change remains after refreshing tags." >&2 exit 2 } - [[ "$latest_tag" == "$EXPECTED_LATEST_TAG" && "$next_tag" == "$EXPECTED_NEXT_TAG" ]] || { + [[ "$latest_tag" == "$EXPECTED_LATEST_TAG" && "$next_tag" == "$EXPECTED_NEXT_TAG" && "$module_tag" == "$EXPECTED_MODULE_TAG" ]] || { echo "BLOCKED: stable release tags changed during this run." >&2 exit 2 } - if git ls-remote --exit-code --tags origin "refs/tags/$next_tag" >/dev/null 2>&1; then - echo "BLOCKED: tag already exists: $next_tag" >&2 - exit 2 - fi git config user.name "${APP_SLUG}[bot]" git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" - git tag -a "$next_tag" -m "Boatstack $next_tag" "$RELEASE_SOURCE" - git push origin "refs/tags/$next_tag" - echo "Published verified release tag $next_tag from $RELEASE_SOURCE." + python3 .github/scripts/publish_release_tags.py \ + --repo . \ + --remote origin \ + --source "$RELEASE_SOURCE" \ + --root-tag "$next_tag" \ + --module-tag "$module_tag" + echo "Published verified release tags $next_tag and $module_tag from $RELEASE_SOURCE." diff --git a/README.md b/README.md index c046a52..10dda73 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ the defining abstraction of the kernel. > generated projections, and persisted formats may change without a > compatibility path. Audit it before using it on important work. +## Install the CLI or import the Go module + +The checksum-verifying installer and the Go module are separate consumer +paths. Install the `boatstack` command with the documented installer, or import +`github.com/operatorstack/boatstack/boatstack` from a stable release that has +published both its root and nested module tags. See [Getting started](docs/getting-started.md) +for the exact commands, public kernel imports, and alpha-version warning. + ## The supervisory loop ```text diff --git a/boatstack/kernel/conformance/conformance.go b/boatstack/kernel/conformance/conformance.go index 379bf85..7519d4f 100644 --- a/boatstack/kernel/conformance/conformance.go +++ b/boatstack/kernel/conformance/conformance.go @@ -88,6 +88,7 @@ func (suite KernelConformance) Run(t *testing.T) { t.Run("objective_revision_invalidates_prescription_before_effects", suite.objectiveRevisionInvalidatesPrescription) t.Run("state_revision_invalidates_prescription_before_effects", suite.stateRevisionInvalidatesPrescription) t.Run("program_fingerprint_invalidates_prescription_before_effects", suite.programFingerprintInvalidatesPrescription) + t.Run("program_bound_recovery", suite.programBoundRecovery) t.Run("stale_prescription_precedes_effects", suite.stalePrescriptionPrecedesEffects) t.Run("authority_denial_fails_closed", suite.authorityDenialFailsClosed) t.Run("future_authority_fails_closed", suite.futureAuthorityFailsClosed) @@ -218,6 +219,96 @@ func (suite KernelConformance) programFingerprintInvalidatesPrescription(t *test }) } +func (suite KernelConformance) programBoundRecovery(t *testing.T) { + fixture := suite.fixture(t, SetupBound) + domain := &verificationCountingDomain{Domain: fixture.Domain} + runtimeA, err := kernel.NewRuntime(fixture.Program, domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) + if err != nil { + t.Fatal(err) + } + transition := fixture.Scenario.AdvanceTransitions[0] + fixture.Scenario.InterruptNextOperator() + originalRequest, originalPrescription := resolve(t, runtimeA, fixture.Scenario, transition, &fixture.Scenario.Objective, fixture.Scenario.Authority) + before := fixture.Scenario.Snapshot() + _, err = runtimeA.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: originalRequest, Prescription: originalPrescription}) + interrupted := fixture.Scenario.Snapshot() + if attemptErr := unresolvedAttemptError(before, interrupted, originalPrescription); !kernel.IsRecoveryRequired(err) || attemptErr != nil || effectCount(interrupted, transition) != effectCount(before, transition)+1 { + t.Fatalf("control-law program-bound-recovery setup: state=%#v error=%v attempt=%v", interrupted, err, attemptErr) + } + if domain.verificationCount() != 0 { + t.Fatalf("control-law program-bound-recovery setup: interrupted operator unexpectedly reached verification") + } + recoveryRequest, recoveryPrescription := resolve(t, runtimeA, fixture.Scenario, fixture.Scenario.RecoveryTransition, &fixture.Scenario.Objective, fixture.Scenario.Authority) + + runtimeB, err := kernel.NewRuntime(fixture.Scenario.AlternateProgram, domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Scenario.IndependentLocker(), fixture.Clock) + if err != nil { + t.Fatal(err) + } + protected := fixture.Scenario.Snapshot() + verificationCount := domain.verificationCount() + for _, candidate := range []struct { + name string + request kernel.ResolveRequest + }{ + {"original", originalRequest}, + {"recovery", recoveryRequest}, + } { + resolution, resolveErr := resolveWithoutMutation(context.Background(), runtimeB, fixture.Scenario, candidate.request) + if resolveErr != nil || resolution.Decision.Kind != kernel.Unresolved || resolution.Prescription != nil { + t.Fatalf("control-law program-bound-recovery: Program B resolved %s work: decision=%#v error=%v", candidate.name, resolution.Decision, resolveErr) + } + if !reflect.DeepEqual(fixture.Scenario.Snapshot(), protected) || domain.verificationCount() != verificationCount { + t.Fatalf("control-law program-bound-recovery: Program B resolve changed protected evidence for %s", candidate.name) + } + } + for _, candidate := range []struct { + name string + request kernel.ResolveRequest + prescription kernel.Prescription + }{ + {"original", originalRequest, originalPrescription}, + {"recovery", recoveryRequest, recoveryPrescription}, + } { + receipt, applyErr := runtimeB.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: candidate.request, Prescription: candidate.prescription}) + after := fixture.Scenario.Snapshot() + if unchangedErr := unchangedSnapshotError(protected, after); applyErr == nil || unchangedErr != nil || !reflect.DeepEqual(receipt, kernel.Receipt{}) || domain.verificationCount() != verificationCount { + t.Fatalf("control-law program-bound-recovery: Program B applied %s work: receipt=%#v error=%v mutation=%v verifications=%d", candidate.name, receipt, applyErr, unchangedErr, domain.verificationCount()) + } + } + + reopenedA, err := kernel.NewRuntime(fixture.Program, domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Scenario.IndependentLocker(), fixture.Clock) + if err != nil { + t.Fatal(err) + } + recoveryReceipt := applyAndRequireCommit(t, reopenedA, fixture.Program, fixture.Scenario, recoveryRequest, recoveryPrescription) + settled := fixture.Scenario.Snapshot() + if recoveryReceipt.TransitionID != fixture.Scenario.RecoveryTransition || settled.State.Recovery != nil || + effectCount(settled, transition) != effectCount(interrupted, transition) || + effectCount(settled, fixture.Scenario.RecoveryTransition) != effectCount(interrupted, fixture.Scenario.RecoveryTransition)+1 || + domain.verificationCount() != verificationCount+1 { + t.Fatalf("control-law program-bound-recovery: Program A did not settle exactly once: receipt=%#v before=%#v after=%#v verifications=%d", recoveryReceipt, interrupted, settled, domain.verificationCount()) + } +} + +type verificationCountingDomain struct { + kernel.Domain + mu sync.Mutex + calls int +} + +func (d *verificationCountingDomain) Verify(ctx context.Context, evaluation kernel.Evaluation, effect kernel.Effect, target kernel.Observation) error { + d.mu.Lock() + d.calls++ + d.mu.Unlock() + return d.Domain.Verify(ctx, evaluation, effect, target) +} + +func (d *verificationCountingDomain) verificationCount() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.calls +} + func (suite KernelConformance) stalePrescriptionPrecedesEffects(t *testing.T) { fixture, runtime := suite.fresh(t, SetupBound) transition := fixture.Scenario.AdvanceTransitions[0] diff --git a/boatstack/kernel/conformance/integer.go b/boatstack/kernel/conformance/integer.go index ff954d0..dd84307 100644 --- a/boatstack/kernel/conformance/integer.go +++ b/boatstack/kernel/conformance/integer.go @@ -11,6 +11,11 @@ import ( "github.com/operatorstack/boatstack/boatstack/kernel" ) +const ( + integerDomainContractFingerprint = "61af3c7a11200a92087cbff475230ff6b19bb0440e3ba77dd0247ea60227f6b8" + alternateIntegerDomainContractFingerprint = "af13a3dd586f031d960a72ef1dfa58e0cef139b91a334b38aab39b3af772073f" +) + // IntegerDomain is the reference non-software domain. type IntegerDomain struct { mu sync.Mutex @@ -392,7 +397,7 @@ func (c *FixedClock) advance(duration time.Duration) { // IntegerProgram compiles the reference control program. func IntegerProgram() (kernel.Program, error) { - return kernel.CompileProgram("integer-control", "1.0.0", "kernel-v1", "unbound", []string{"two"}, []kernel.Transition{ + return kernel.CompileDomainProgram("integer-control", "1.0.0", "kernel-v1", integerDomainContractFingerprint, "unbound", []string{"two"}, []kernel.Transition{ {ID: "objective.bind", SourceModes: []string{"unbound"}, TargetMode: "zero", ObjectiveScope: kernel.ObjectiveNone, ObjectiveMutation: kernel.BindInitialObjective, RequiredCapabilities: []kernel.Capability{"objective.bind"}, OwnedFacets: []string{"supervisor.objective"}, Operation: "objective.bind", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 5}, {ID: "counter.increment-first", SourceModes: []string{"zero"}, TargetMode: "one", ObjectiveScope: kernel.ObjectiveBoundExact, ObjectiveMutation: kernel.PreserveObjective, RequiredCapabilities: []kernel.Capability{"counter.increment"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.increment", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 10}, {ID: "counter.increment-second", SourceModes: []string{"one"}, TargetMode: "two", ObjectiveScope: kernel.ObjectiveBoundExact, ObjectiveMutation: kernel.PreserveObjective, RequiredCapabilities: []kernel.Capability{"counter.increment"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.increment", SelectionRank: 1, Selection: kernel.SelectionImplicit, Priority: 10}, @@ -428,13 +433,15 @@ func newIntegerFixture(setup Setup) KernelConformance { if err != nil { panic(err) } - alternateTransitions := append([]kernel.Transition(nil), program.Transitions...) - for index := range alternateTransitions { - if alternateTransitions[index].ID == "counter.increment-first" { - alternateTransitions[index].TargetMode = "two" - } - } - alternateProgram, err := kernel.CompileProgram(program.ID, program.Version, program.RuntimeCompatibility, program.InitialMode, program.MarkedModes, alternateTransitions) + alternateProgram, err := kernel.CompileDomainProgram( + program.ID, + program.Version, + program.RuntimeCompatibility, + alternateIntegerDomainContractFingerprint, + program.InitialMode, + program.MarkedModes, + program.Transitions, + ) if err != nil { panic(err) } diff --git a/boatstack/kernel/program_test.go b/boatstack/kernel/program_test.go index 7d5aa69..94775d6 100644 --- a/boatstack/kernel/program_test.go +++ b/boatstack/kernel/program_test.go @@ -37,6 +37,29 @@ func TestProgramFingerprintCanonicalizesSemanticSets(t *testing.T) { } } +func TestDomainContractFingerprintChangesProgramFingerprint(t *testing.T) { + compile := func(domainContractFingerprint string) Program { + program, err := CompileDomainProgram("domain-contract", "1", "kernel-v1", domainContractFingerprint, "idle", []string{"done"}, []Transition{ + {ID: "advance", SourceModes: []string{"idle"}, TargetMode: "done", ObjectiveScope: ObjectiveNone, ObjectiveMutation: PreserveObjective, RequiredCapabilities: []Capability{"counter.increment"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.increment", SelectionRank: 1, Selection: SelectionImplicit, Priority: 1}, + {ID: "recover", SourceModes: []string{"idle"}, TargetMode: "idle", ObjectiveScope: ObjectiveNone, ObjectiveMutation: PreserveObjective, RequiredCapabilities: []Capability{"counter.reset"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.reset", SelectionRank: 1, Selection: SelectionImplicit, Priority: 2, Recovers: []string{"advance", "recover"}}, + }) + if err != nil { + t.Fatal(err) + } + return program + } + left := compile(strings.Repeat("a", 64)) + right := compile(strings.Repeat("b", 64)) + if left.Fingerprint == right.Fingerprint { + t.Fatal("changing only the domain contract fingerprint did not change the Program fingerprint") + } + left.DomainContractFingerprint = right.DomainContractFingerprint + left.Fingerprint = right.Fingerprint + if err := left.Validate(); err != nil { + t.Fatalf("domain contract fingerprint was not the only executable identity change: %v", err) + } +} + func TestProgramRejectsRecoveryThatCannotRunFromRecoveredSourceMode(t *testing.T) { _, err := CompileProgram("blocked-recovery", "1", "kernel-v1", "one", []string{"done"}, []Transition{ {ID: "increment", SourceModes: []string{"one"}, TargetMode: "two", ObjectiveScope: ObjectiveNone, ObjectiveMutation: PreserveObjective, RequiredCapabilities: []Capability{"counter.increment"}, OwnedFacets: []string{"counter.value"}, Operation: "counter.increment", SelectionRank: 1, Selection: SelectionImplicit, Priority: 1}, diff --git a/docs/getting-started.md b/docs/getting-started.md index 780918e..902d537 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting started with Boatstack -## Install once +## Install the CLI Run the checksum-verifying installer from the repository root: @@ -18,6 +18,37 @@ infers an actor from the operating system. Replace the literal descriptor with a structured command descriptor when the repository should ask its host to resolve the proposed actor. +## Import the Go module + +CLI installation and Go embedding are separate paths. The checksum-verifying +installer above installs the `boatstack` command; it does not add a Go module +dependency. + +Boatstack is alpha software. Use a semantic version only after that release has +published both its root and nested module tags from the same source. In the +commands below, `vX.Y.Z` is a placeholder for such a published release, not a +claim that an existing root-only release resolves the nested module: + +```sh +go get github.com/operatorstack/boatstack/boatstack@vX.Y.Z +``` + +Kernel domains and reusable conformance laws use these public imports: + +```go +import ( + "github.com/operatorstack/boatstack/boatstack/kernel" + "github.com/operatorstack/boatstack/boatstack/kernel/conformance" +) +``` + +After a paired-tag release, verify the exact version from a clean external +module with workspace discovery disabled: + +```sh +GOWORK=off go list -m github.com/operatorstack/boatstack/boatstack@vX.Y.Z +``` + ## Configure one exact objective Repositories with a compiled Flow normally select a named entry instead: diff --git a/release-notes/2026-08-26-go-module-and-program-recovery.md b/release-notes/2026-08-26-go-module-and-program-recovery.md new file mode 100644 index 0000000..6898697 --- /dev/null +++ b/release-notes/2026-08-26-go-module-and-program-recovery.md @@ -0,0 +1,5 @@ +### Publish the Go module with Program-bound recovery evidence + +Stable releases now publish the repository and nested Go module tags together, +and Go consumers can run the public conformance suite against unresolved work +that remains bound to its exact Program identity.