diff --git a/.agents/skills/boatstack-autoplan/SKILL.md b/.agents/skills/boatstack-autoplan/SKILL.md deleted file mode 100644 index e80e461..0000000 --- a/.agents/skills/boatstack-autoplan/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-autoplan -description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Autoplan - -Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.agents/skills/boatstack-autoplan/agents/openai.yaml b/.agents/skills/boatstack-autoplan/agents/openai.yaml deleted file mode 100644 index bb178f0..0000000 --- a/.agents/skills/boatstack-autoplan/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Boatstack Autoplan" - short_description: "Create and approve a verified delivery plan." - default_prompt: "Use $boatstack-autoplan to follow the authority-preserving Boatstack V2 driver." -policy: - allow_implicit_invocation: false diff --git a/.agents/skills/boatstack-run/SKILL.md b/.agents/skills/boatstack-run/SKILL.md deleted file mode 100644 index 10358c0..0000000 --- a/.agents/skills/boatstack-run/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-run -description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Run - -Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.agents/skills/boatstack-run/agents/openai.yaml b/.agents/skills/boatstack-run/agents/openai.yaml deleted file mode 100644 index 030b17b..0000000 --- a/.agents/skills/boatstack-run/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Boatstack Run" - short_description: "Drive delivery to an open or updated pull request." - default_prompt: "Use $boatstack-run to follow the authority-preserving Boatstack V2 driver." -policy: - allow_implicit_invocation: false diff --git a/.claude/skills/boatstack-autoplan/SKILL.md b/.claude/skills/boatstack-autoplan/SKILL.md deleted file mode 100644 index e80e461..0000000 --- a/.claude/skills/boatstack-autoplan/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-autoplan -description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Autoplan - -Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.claude/skills/boatstack-run/SKILL.md b/.claude/skills/boatstack-run/SKILL.md deleted file mode 100644 index 10358c0..0000000 --- a/.claude/skills/boatstack-run/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-run -description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Run - -Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.cursor/commands/boatstack-autoplan.md b/.cursor/commands/boatstack-autoplan.md deleted file mode 100644 index e80e461..0000000 --- a/.cursor/commands/boatstack-autoplan.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-autoplan -description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Autoplan - -Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.cursor/commands/boatstack-run.md b/.cursor/commands/boatstack-run.md deleted file mode 100644 index 10358c0..0000000 --- a/.cursor/commands/boatstack-run.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-run -description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Run - -Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.gemini/skills/boatstack-autoplan/SKILL.md b/.gemini/skills/boatstack-autoplan/SKILL.md deleted file mode 100644 index e80e461..0000000 --- a/.gemini/skills/boatstack-autoplan/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-autoplan -description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Autoplan - -Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.gemini/skills/boatstack-run/SKILL.md b/.gemini/skills/boatstack-run/SKILL.md deleted file mode 100644 index 10358c0..0000000 --- a/.gemini/skills/boatstack-run/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: boatstack-run -description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. ---- - -# Boatstack Run - -Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. - -Run `boatstack status --repo . --format json` once for observation. An authority-free -`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. - -Bind one command-scoped context containing the exact goal, delivery, repository, -worktree, flow, actor, and supplied authority receipts. Preserve that context -through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing -authority or infer it from authentication, files, branches, or prior conversation. -Within that context, track requested authority sources separately from currently -materialized authority receipts. - -For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass `--repository-authority` -until the current configuration has exact verified fingerprint evidence. - -After a complete `installation.initialize`, `configuration.initialize`, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding `--repository-authority` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt. - -Begin each cycle with an untargeted authority-bearing `next`. A `CANDIDATE` -identifies the next transition but is not permission to apply it: bind only its -declared parameters and re-resolve that exact transition. Apply only the stable -transition ID from the immediately preceding `PRESCRIBED` result and only its -declared parameters. Preserve the complete apply response and stderr, including -admission, receipt, postcondition, error, recovery, and transaction fields. -Re-resolve with the same context after every complete receipt. - -Evaluate a frontier only after every requested authority source is materialized -or conclusively rejected against the post-receipt state. Stop only on an -authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or -`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. -If recovery is active, use only a transition in `recovery_info.permitted` and -the exact transaction ID. Never choose maintenance, correction, abandonment, -merge, provider, or destructive authority as an escape from a frontier. diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index 602cec9..3fbde9c 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -192,23 +192,13 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No self.porcelain(), "\n".join( [ - "?? .agents/skills/boatstack-autoplan/SKILL.md", - "?? .agents/skills/boatstack-autoplan/agents/openai.yaml", - "?? .agents/skills/boatstack-run/SKILL.md", - "?? .agents/skills/boatstack-run/agents/openai.yaml", "?? .agents/skills/boatstack-update/SKILL.md", "?? .agents/skills/boatstack-update/agents/openai.yaml", "?? .boatstack/host-skills.json", "?? .boatstack/project.json", "?? .boatstack/runtime.json", - "?? .claude/skills/boatstack-autoplan/SKILL.md", - "?? .claude/skills/boatstack-run/SKILL.md", "?? .claude/skills/boatstack-update/SKILL.md", - "?? .cursor/commands/boatstack-autoplan.md", - "?? .cursor/commands/boatstack-run.md", "?? .cursor/commands/boatstack-update.md", - "?? .gemini/skills/boatstack-autoplan/SKILL.md", - "?? .gemini/skills/boatstack-run/SKILL.md", "?? .gemini/skills/boatstack-update/SKILL.md", ] ), @@ -245,7 +235,7 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - "--objective-kind", "open-or-updated-pr", "--delivery", "codex-driver-authority-triggers", ) - flow = ("--flow", "flow-codex-driver-authority-triggers") + flow = ("--run-id", "flow-codex-driver-authority-triggers") self.helper_json( "attach", "--repo", self.repo, *objective, *flow, "--human", "contract", "--param", "topology=detached", "--param", "config_authority=repository", @@ -335,7 +325,7 @@ def test_one_delivery_context_rematerializes_repository_authority_after_initiali "--objective-kind", "open-or-updated-pr", "--delivery", "preserve-repository-authority-context", ) - flow = ("--flow", "flow-preserve-repository-authority-context") + flow = ("--run-id", "flow-preserve-repository-authority-context") actor = ("--human", "contract") self.helper_json( "attach", "--repo", self.repo, *objective, *flow, *actor, @@ -461,7 +451,7 @@ def test_repository_authority_rematerialization_fails_closed_without_verified_co "--objective-id", "unverified-authority", "--objective-kind", "open-or-updated-pr", "--delivery", "unverified-authority", - "--flow", "flow-unverified-authority", + "--run-id", "flow-unverified-authority", "--human", "contract", "--repository-authority", cwd=root, expected=1, ) diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 5d56248..1068141 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -289,8 +289,8 @@ def test_manual_release_is_prerelease_only_and_exact_source_bound(self) -> None: self.assertIn("VERSION: ${{ inputs.prerelease_tag || github.ref_name }}", release) self.assertIn('RELEASE_SOURCE: ${{ github.sha }}', release) - def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) -> None: - # control-law: operation-skill-discovery-preserves-authority-and-exact-cardinality + def test_kernel_skill_is_maintenance_only_and_delivery_entries_are_repository_owned(self) -> None: + # control-law: kernel-skill-discovery-cannot-invent-domain-entry-authority skills = { path.parent.name: path.read_text() for path in sorted((REPO / ".agents" / "skills").glob("boatstack-*/SKILL.md")) @@ -301,7 +301,7 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) } readme = (REPO / "README.md").read_text() - self.assertEqual(set(skills), {"boatstack-autoplan", "boatstack-run", "boatstack-update"}) + self.assertEqual(set(skills), {"boatstack-update"}) self.assertEqual(set(prompts), set(skills)) for host_root in (REPO / ".claude" / "skills", REPO / ".gemini" / "skills"): projected = { @@ -315,11 +315,7 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) } self.assertEqual(cursor, skills) self.assertFalse((REPO / "boatstack" / "SKILL.md").exists()) - mappings = { - "boatstack-autoplan": "`approved-plan` terminal", - "boatstack-run": "`open-or-updated-pr` terminal", - "boatstack-update": "`installation.reconcile-update`", - } + mappings = {"boatstack-update": "`installation.reconcile-update`"} for name, mapping in mappings.items(): self.assertIn(f"name: {name}", skills[name]) self.assertIn(mapping, skills[name]) @@ -337,17 +333,6 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) ): for name, skill in skills.items(): self.assertIn(contract, skill, name) - self.assertIn("never grants merge authority", skills["boatstack-run"]) - for name in ("boatstack-autoplan", "boatstack-run"): - for contract in ( - "request human and repository-policy authority sources", - "repository-policy source remains requested", - "do not pass `--repository-authority`\nuntil the current configuration has exact verified fingerprint evidence", - "one bounded attempt for that receipt", - "The kernel must derive the\nreceipt from that exact verified fingerprint", - "do not retry it\nagain for the same receipt", - ): - self.assertIn(contract, skills[name], name) update = skills["boatstack-update"] self.assertIn("request only checksum-verified installation authority", update) self.assertIn( @@ -365,7 +350,9 @@ def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) ): self.assertIn(contract, update) self.assertIn("Untargeted resolution selects\nonly a transition that advances the configured objective", readme) - self.assertIn("exactly three operation skills", readme) + self.assertIn("installer generates the maintenance skill", readme) + self.assertIn("A repository Flow declares its own entries", readme) + self.assertIn("does\nnot interpret the word `run`", readme) _, kernel_and_later = readme.split("### Kernel", 1) kernel_section, delivery_and_later = kernel_and_later.split("### Software delivery", 1) @@ -584,7 +571,7 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> path.read_text() for path in sorted((REPO / "boatstack").rglob("*.go")) ) for retired in ( - "boatstack/control", "internal/kernel", "internal/effects", + "boatstack/control\"", "internal/kernel", "internal/effects", "internal/plant", "internal/surfaces", "goal.configure", "GOAL_REQUIRED", 'json:"goal', ): @@ -592,17 +579,18 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> component_ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() for current in ( - "./kernel", "./delivery", "./internal/softwaredelivery/protocol", + "./kernel", "./controlprogram", "./delivery", "./internal/softwaredelivery/protocol", "./internal/softwaredelivery/surfaces", "./internal/softwaredelivery/plant", "./internal/softwaredelivery/effects", ): self.assertIn(current, component_ci) for retired in ( - "./control", "./internal/kernel", "./internal/plant", + "./internal/kernel", "./internal/plant", "./internal/effects", "./internal/surfaces", ): self.assertNotIn(retired, component_ci) + self.assertNotIn("packages: ./control\n", component_ci) self.assertIn("run: go test -race ./...", component_ci) self.assertIn( "run: go test -race ./kernel ./internal/softwaredelivery/effects", @@ -673,7 +661,7 @@ def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: "workspace-cleanup", "workspace-reap", "record-build", "record-test", "record-review", "record-change", "record-journey", "publication-preview", "publish-pr", "observe-pr", "correct-pr", - "abandon", + "abandon", "flow", } pattern = re.compile( r"(?m)^[ \t]*(?:\$[A-Za-z_][A-Za-z0-9_]*/)?" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd136c4..e0063ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,27 @@ concurrency: cancel-in-progress: true jobs: + flow-sdk: + name: flow-sdk + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - uses: actions/setup-go@v7 + with: + go-version-file: boatstack/go.mod + cache-dependency-path: boatstack/go.sum + - name: Build TypeScript frontends + run: npm ci && npm run build:flow-sdk + - name: Prove frontend canonical equivalence + working-directory: boatstack + env: + BOATSTACK_REQUIRE_FLOW_FRONTEND: '1' + run: go test ./controlprogram -run TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint + component: name: component-${{ matrix.name }} strategy: @@ -23,7 +44,7 @@ jobs: - name: kernel-mechanism packages: ./kernel - name: control-program-compiler - packages: ./delivery ./core + packages: ./controlprogram ./delivery ./core ./flow/softwaredelivery - name: standard-flow packages: ./flow/standard ./internal/softwaredelivery/protocol - name: extension-conformance diff --git a/.gitignore b/.gitignore index 4534109..987f1d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ boatstack/boatstack-helper boatstack/boatstack-helper.exe dist/ +node_modules/ +*.tsbuildinfo .DS_Store .venv/ venv/ diff --git a/README.md b/README.md index 1a8752a..a85a3bc 100644 --- a/README.md +++ b/README.md @@ -65,15 +65,19 @@ repository, creates the initial configuration, and generates integrations for the enabled coding-agent hosts. Review and commit the generated `.boatstack/` files and host skills before starting delivery. -Then use one of the exactly three operation skills from a supported coding -agent: +Boatstack keeps runtime maintenance separate from repository delivery. The +installer generates the maintenance skill: ```text -$boatstack-autoplan # create, validate, and approve a plan -$boatstack-run # deliver to an open or updated pull request; never merge $boatstack-update # install a checksum-verified runtime update ``` +A repository Flow declares its own entries. `boatstack flow compile` projects +those entries into host skills such as `$product-delivery-run`; Boatstack does +not interpret the word `run`. Compilation requires an explicitly selected, +absolute frontend path and never executes an automatically discovered +repository binary. + If the agent was already running during installation, start a fresh task so it can discover the generated skills. See [Getting started](docs/getting-started.md) for the lower-level CLI path and [Configuration](docs/configuration.md) for the @@ -131,6 +135,7 @@ control graph. The complete list is generated from the registry in the | Surface | Shipped functionality | | --- | --- | | **Protocol and SDK** | One versioned protocol shared by the CLI, RPC, MCP, Go SDK, Cursor, Codex, Claude Code, and Gemini CLI. Hosts do not maintain independent delivery state machines. | +| **Flow IR and TypeScript frontend** | A domain-neutral, canonical Control Program IR plus `@operatorstack/boatstack`. Trusted software-delivery bindings live in the separate `@operatorstack/boatstack-software-delivery` package. | | **Extensions** | Additive, checksum-bound subprocess extensions with declarative manifests, JSON-schema settings, bounded I/O, deadlines, capability checks, and exact-byte execution. | | **Analysis** | Passive retrospective analysis, generated Markdown and Mermaid catalogs, privacy-safe events, and checked Locus safety/liveness models. Formal whole-system claims remain advisory. | @@ -144,13 +149,17 @@ boatstack status --repo . --format json boatstack next --repo . --objective-id --objective-kind \ --delivery --format json +# Resolve one repository-owned entry. +boatstack next --repo . --flow product-delivery --entry run --format json + # Inspect the exact program and transition surface. boatstack doctor --repo . --format text boatstack catalog --format json boatstack events --repo . --format jsonl # Low-level integrations forward the complete prescription unchanged. -boatstack apply --repo . --transition --flow \ +boatstack apply --repo . --transition --run-id \ + --flow --entry \ --prescription-id --expected-state-revision \ --expected-program-fingerprint \ --expected-snapshot-fingerprint --format json @@ -165,7 +174,7 @@ only a transition that advances the configured objective. Maintenance, correction, abandonment, provider actions, and merge authority are never invented as a way around a frontier. After an operation is selected, generated host drivers keep one command-scoped objective, repository, -worktree, flow, actor, and authority context through every resolution, effect, +worktree, program, entry, run, actor, and authority context through every resolution, effect, recovery, and re-resolution. ## Objectives and control state @@ -227,10 +236,11 @@ The current authoring boundary already includes: capabilities, effects, verifiers, recovery, and context predicates; - a protocol execution boundary for repository-authored transitions. -The ergonomic repository Flow authoring experience—project layout, authoring -tools, examples, diagnostics, and a complete guide—is still under active -development. StandardFlow remains the only first-party Flow. The ABI and SDK -are useful for exploring the model today, but they are not stable APIs yet. +Repository Flows are authored in `.boatstack/flows/*.flow.ts` and compiled into +committed `.flow.ir.json` artifacts. Runtime commands load only canonical IR. +Compilation parses a restricted declaration subset and never executes +repository modules. Local Flow imports fail closed. +The TypeScript SDK and IR remain alpha APIs. ## Safety model diff --git a/boatstack/cmd/boatstack-helper/flow_command.go b/boatstack/cmd/boatstack-helper/flow_command.go new file mode 100644 index 0000000..fb63ff0 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/flow_command.go @@ -0,0 +1,364 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/delivery" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" +) + +const flowCompilerVersion = "control-program/v1.compiler.1" + +type flowCommandOptions struct { + repository string + source string + artifact string + lock string + frontend string +} + +func runFlowCommand(arguments []string) error { + if len(arguments) == 0 { + return fmt.Errorf("usage: boatstack flow [flags]") + } + action := arguments[0] + flags := flag.NewFlagSet("flow "+action, flag.ContinueOnError) + flags.SetOutput(os.Stderr) + options := flowCommandOptions{} + flags.StringVar(&options.repository, "repo", ".", "repository containing .boatstack/flows") + flags.StringVar(&options.source, "source", "", "Flow TypeScript source path") + flags.StringVar(&options.artifact, "artifact", "", "compiled Flow artifact path") + flags.StringVar(&options.lock, "lock", "package-lock.json", "frontend dependency lock path") + flags.StringVar(&options.frontend, "frontend", "", "exact boatstack-flow-frontend executable path") + if err := flags.Parse(arguments[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected flow arguments: %s", strings.Join(flags.Args(), " ")) + } + repository, err := filepath.Abs(options.repository) + if err != nil { + return err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return err + } + options.repository = repository + switch action { + case "compile": + return compileFlow(context.Background(), options) + case "check": + return checkFlow(context.Background(), options) + default: + return fmt.Errorf("unknown flow action %q", action) + } +} + +func compileFlow(ctx context.Context, options flowCommandOptions) error { + source, err := resolveFlowSource(options.repository, options.source) + if err != nil { + return err + } + if !strings.HasSuffix(source, ".flow.ts") { + return fmt.Errorf("Flow source must end with .flow.ts") + } + lockPath, err := exactRepositoryPath(options.repository, options.lock) + if err != nil { + return err + } + frontend, err := resolveFrontend(options.repository, options.frontend) + if err != nil { + return err + } + sourceRaw, err := os.ReadFile(source) + if err != nil { + return err + } + lockRaw, err := os.ReadFile(lockPath) + if err != nil { + return err + } + rawIR, err := boatstackruntime.RunFlowFrontend(ctx, frontend, source, sourceRaw) + if err != nil { + return err + } + if err := requireUnchangedCompileInput(source, sourceRaw); err != nil { + return err + } + if err := requireUnchangedCompileInput(lockPath, lockRaw); err != nil { + return err + } + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return err + } + compiled, err := controlprogram.Load(bytes.NewReader(rawIR), resolver) + if err != nil { + return err + } + if err := validateSoftwareFlow(ctx, options.repository, compiled, resolver); err != nil { + return err + } + artifactPath, err := resolveArtifactPath(options.repository, options.artifact, compiled.Document.Program.ID) + if err != nil { + return err + } + skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + return err + } + sourceRelative, _ := filepath.Rel(options.repository, source) + lockRelative, _ := filepath.Rel(options.repository, lockPath) + artifact, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: flowCompilerVersion, SourcePath: filepath.ToSlash(sourceRelative), Source: sourceRaw, + DependencyLockPath: filepath.ToSlash(lockRelative), DependencyLock: lockRaw, GeneratedSkills: skills, + }) + if err != nil { + return err + } + removals, artifactPrevious, priorSkills, ownership, err := ownedProjectionChanges(options.repository, filepath.ToSlash(sourceRelative), artifactPath, artifact.GeneratedSkills) + if err != nil { + return err + } + paths := make([]string, 0, len(skills)) + for path := range skills { + paths = append(paths, path) + } + sort.Strings(paths) + writes := make([]boatstackruntime.ProjectionWrite, 0, len(paths)+1) + for _, path := range paths { + absolute, pathErr := exactRepositoryPath(options.repository, path) + if pathErr != nil { + return pathErr + } + writes = append(writes, boatstackruntime.ProjectionWrite{ + Path: absolute, Content: skills[path], Mode: 0o644, ExpectedPreviousSHA256: priorSkills[path], + }) + } + writes = append(writes, boatstackruntime.ProjectionWrite{ + Path: artifactPath, Content: artifactRaw, Mode: 0o644, ExpectedPreviousSHA256: artifactPrevious, PublishLast: true, + }) + if err := rejectProjectionInputOverlap(lockPath, writes, removals); err != nil { + return err + } + expectations := []boatstackruntime.ProjectionExpectation{ + {Path: source, Exists: true, ExpectedSHA256: fileDigest(sourceRaw)}, + {Path: lockPath, Exists: true, ExpectedSHA256: fileDigest(lockRaw)}, + } + artifactRelative, _ := filepath.Rel(options.repository, artifactPath) + nextOwnership := boatstackruntime.NewFlowProjectionOwnership(filepath.ToSlash(sourceRelative), filepath.ToSlash(artifactRelative), artifactRaw, skills) + if err := boatstackruntime.ApplyOwnedFlowProjection(options.repository, writes, removals, expectations, ownership, nextOwnership); err != nil { + return err + } + return renderFlowResult("compiled", artifactPath, artifact) +} + +func rejectProjectionInputOverlap(lockPath string, writes []boatstackruntime.ProjectionWrite, removals []boatstackruntime.ProjectionRemoval) error { + lockPath = filepath.Clean(lockPath) + for _, write := range writes { + if filepath.Clean(write.Path) == lockPath { + return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a projection output") + } + } + for _, removal := range removals { + if filepath.Clean(removal.Path) == lockPath { + return fmt.Errorf("FLOW_COMPILE_INPUT_OVERLAP: dependency lock is a retired projection output") + } + } + return nil +} + +func requireUnchangedCompileInput(path string, expected []byte) error { + current, err := os.ReadFile(path) + if err != nil || !bytes.Equal(current, expected) { + return fmt.Errorf("FLOW_COMPILE_INPUT_CHANGED: %s changed while the frontend was running", path) + } + return nil +} + +func ownedProjectionChanges(repository, sourceRelative, artifactPath string, next map[string]string) ([]boatstackruntime.ProjectionRemoval, string, map[string]string, boatstackruntime.FlowProjectionOwnershipSnapshot, error) { + ownership, err := boatstackruntime.LoadFlowProjectionOwnership(repository, sourceRelative) + if err != nil || !ownership.Exists() { + return nil, "", map[string]string{}, ownership, err + } + prior := ownership.Record + retired := make([]boatstackruntime.ProjectionRemoval, 0, len(prior.GeneratedSkills)+1) + for relative, expected := range prior.GeneratedSkills { + if _, retained := next[relative]; retained { + continue + } + path, pathErr := exactRepositoryPath(repository, relative) + if pathErr != nil { + return nil, "", nil, ownership, pathErr + } + retired = append(retired, boatstackruntime.ProjectionRemoval{Path: path, ExpectedSHA256: expected, AllowMissing: true}) + } + artifactRelative, _ := filepath.Rel(repository, artifactPath) + artifactPrevious := "" + if filepath.ToSlash(artifactRelative) == prior.ArtifactPath { + artifactPrevious = prior.ArtifactSHA256 + } else { + priorArtifact, pathErr := exactRepositoryPath(repository, prior.ArtifactPath) + if pathErr != nil { + return nil, "", nil, ownership, pathErr + } + retired = append(retired, boatstackruntime.ProjectionRemoval{Path: priorArtifact, ExpectedSHA256: prior.ArtifactSHA256, AllowMissing: true}) + } + sort.Slice(retired, func(i, j int) bool { return retired[i].Path < retired[j].Path }) + return retired, artifactPrevious, prior.GeneratedSkills, ownership, nil +} + +func fileDigest(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} + +func checkFlow(ctx context.Context, options flowCommandOptions) error { + artifactPath, err := resolveCheckArtifact(options.repository, options.artifact) + if err != nil { + return err + } + raw, err := os.ReadFile(artifactPath) + if err != nil { + return err + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + return err + } + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return err + } + compiled, err := controlprogram.CheckArtifact(options.repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) + if err != nil { + return err + } + if err := validateSoftwareFlow(ctx, options.repository, compiled, resolver); err != nil { + return err + } + return renderFlowResult("valid", artifactPath, artifact) +} + +func validateSoftwareFlow(ctx context.Context, _ string, compiled controlprogram.Compiled, resolver softwareflow.Resolver) error { + for _, flowEntry := range compiled.Document.Entries { + if _, err := softwareflow.PlanInboxForEntry(flowEntry); err != nil { + return fmt.Errorf("FLOW_RUNTIME_INVALID: %w", err) + } + } + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err != nil { + return fmt.Errorf("FLOW_RUNTIME_INVALID: %w", err) + } + if _, err := delivery.Compile(ctx, delivery.CompileRequest{KernelVersion: boatstack.Version, Core: core.System(), Runtime: definition, Settings: map[string]string{"validation": "flow"}}); err != nil { + return fmt.Errorf("FLOW_RUNTIME_INVALID: %w", err) + } + return nil +} + +func resolveFlowSource(repository, requested string) (string, error) { + if requested != "" { + return exactRepositoryPath(repository, requested) + } + matches, err := filepath.Glob(filepath.Join(repository, ".boatstack", "flows", "*.flow.ts")) + if err != nil { + return "", err + } + if len(matches) != 1 { + return "", fmt.Errorf("FLOW_SOURCE_SELECTION_REQUIRED: found %d Flow sources", len(matches)) + } + return filepath.Clean(matches[0]), nil +} + +func resolveArtifactPath(repository, requested, programID string) (string, error) { + expected, err := exactRepositoryPath(repository, filepath.Join(".boatstack", "flows", programID+".flow.ir.json")) + if err != nil { + return "", err + } + if requested == "" { + return expected, nil + } + actual, err := exactRepositoryPath(repository, requested) + if err != nil { + return "", err + } + if actual != expected { + return "", fmt.Errorf("FLOW_ARTIFACT_ID_MISMATCH: program %s must compile to %s", programID, expected) + } + return actual, nil +} + +func resolveCheckArtifact(repository, requested string) (string, error) { + if requested != "" { + return exactRepositoryPath(repository, requested) + } + matches, err := filepath.Glob(filepath.Join(repository, ".boatstack", "flows", "*.flow.ir.json")) + if err != nil { + return "", err + } + if len(matches) != 1 { + return "", fmt.Errorf("FLOW_ARTIFACT_SELECTION_REQUIRED: found %d Flow artifacts", len(matches)) + } + return filepath.Clean(matches[0]), nil +} + +func resolveFrontend(_ string, requested string) (string, error) { + if requested == "" { + return "", fmt.Errorf("FLOW_FRONTEND_REQUIRED: pass an explicitly authorized absolute --frontend path") + } + if !filepath.IsAbs(requested) || filepath.Clean(requested) != requested { + return "", fmt.Errorf("--frontend must be exact and absolute") + } + info, err := os.Stat(requested) + if err != nil || info.IsDir() { + return "", fmt.Errorf("FLOW_FRONTEND_REQUIRED: explicit frontend is unavailable") + } + return requested, nil +} + +func exactRepositoryPath(repository, relative string) (string, error) { + if relative == "" || filepath.IsAbs(relative) { + return "", fmt.Errorf("repository path must be non-empty and relative") + } + clean := filepath.Clean(relative) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("repository path escapes the repository") + } + absolute := filepath.Join(repository, clean) + rel, err := filepath.Rel(repository, absolute) + if err != nil || rel != clean { + return "", fmt.Errorf("repository path is not canonical") + } + return absolute, nil +} + +func renderFlowResult(status, artifactPath string, artifact controlprogram.Artifact) error { + return json.NewEncoder(os.Stdout).Encode(map[string]any{ + "status": status, "program_id": artifact.Program.Program.ID, "program_fingerprint": artifact.ProgramFingerprint, + "artifact": artifactPath, "entries": entryIDs(artifact.Program.Entries), + }) +} + +func entryIDs(entries []controlprogram.Entry) []string { + result := make([]string, len(entries)) + for index, entry := range entries { + result[index] = entry.ID + } + sort.Strings(result) + return result +} diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go new file mode 100644 index 0000000..f5bdeff --- /dev/null +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -0,0 +1,407 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +var flowSegment = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, error) { + if options.programID == "" && options.entryID == "" { + return options, nil + } + if !flowSegment.MatchString(options.programID) || !flowSegment.MatchString(options.entryID) { + return commandOptions{}, fmt.Errorf("FLOW_ENTRY_INVALID: --flow and --entry require semantic identifiers") + } + repository, err := filepath.Abs(options.repository) + if err != nil { + return commandOptions{}, err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return commandOptions{}, err + } + artifactPath := filepath.Join(repository, ".boatstack", "flows", options.programID+".flow.ir.json") + artifactRaw, err := os.ReadFile(artifactPath) + if err != nil { + return commandOptions{}, fmt.Errorf("FLOW_ARTIFACT_REQUIRED: %w", err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(artifactRaw)) + if err != nil { + return commandOptions{}, err + } + if artifact.Program.Program.ID != options.programID { + return commandOptions{}, fmt.Errorf("FLOW_PROGRAM_MISMATCH: selected %q but artifact declares %q", options.programID, artifact.Program.Program.ID) + } + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return commandOptions{}, err + } + compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) + if err != nil { + return commandOptions{}, err + } + if options.flowProgramFingerprint != "" && options.flowProgramFingerprint != compiled.Fingerprint { + return commandOptions{}, fmt.Errorf("FLOW_PROGRAM_DRIFT: run fingerprint does not match the current artifact") + } + objective, err := softwareflow.ObjectiveForEntry(ctx, compiled, resolver, options.entryID) + if err != nil { + return commandOptions{}, err + } + entry, ok := findEntry(compiled.Document.Entries, options.entryID) + if !ok { + return commandOptions{}, fmt.Errorf("FLOW_ENTRY_UNKNOWN: %s", options.entryID) + } + plan, deliveryID, err := resolveBoundPlan(repository, entry, options) + if err != nil { + return commandOptions{}, err + } + planRaw, err := os.ReadFile(plan) + if err != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", err) + } + planDigest := sha256.Sum256(planRaw) + planFingerprint := hex.EncodeToString(planDigest[:]) + repositoryIdentity, err := flowRepositoryIdentity(repository) + if err != nil { + return commandOptions{}, err + } + runID := flowRunID(repositoryIdentity, compiled.Fingerprint, options.entryID, deliveryID, planFingerprint) + if options.runID != "" && options.runID != runID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + } + options.repository = repository + options.flowProgramFingerprint = compiled.Fingerprint + options.runID = runID + if options.objectiveKind == "" { + options.objectiveKind = string(objective) + } + if options.deliveryID == "" { + options.deliveryID = deliveryID + } + expectedObjectiveID := "objective-" + options.programID + "-" + options.entryID + "-" + deliveryID + if options.objectiveID == "" { + options.objectiveID = expectedObjectiveID + } + if options.objectiveKind != string(objective) || options.deliveryID != deliveryID || options.objectiveID != expectedObjectiveID { + return commandOptions{}, fmt.Errorf("FLOW_CONTEXT_MISMATCH: objective or delivery changed across the run") + } + parameters, err := parseParameters(options.parameters) + if err != nil { + return commandOptions{}, err + } + for name, expected := range map[string]string{ + "objective_kind": string(objective), + "delivery_id": deliveryID, + "source_path": plan, + "source_fingerprint": planFingerprint, + } { + if err := validateResolvedParameter(parameters, name, expected); err != nil { + return commandOptions{}, err + } + } + switch options.transitionID { + case "objective.bind": + if err := bindResolvedParameter(&options, parameters, "objective_kind", string(objective)); err != nil { + return commandOptions{}, err + } + if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { + return commandOptions{}, err + } + case "plan.create", "plan.amend": + if err := bindResolvedParameter(&options, parameters, "source_path", plan); err != nil { + return commandOptions{}, err + } + if err := bindResolvedParameter(&options, parameters, "delivery_id", deliveryID); err != nil { + return commandOptions{}, err + } + if err := bindResolvedParameter(&options, parameters, "source_fingerprint", planFingerprint); err != nil { + return commandOptions{}, err + } + } + return options, nil +} + +func validateResolvedParameter(parameters protocol.Parameters, name, expected string) error { + if actual, exists := parameters.Get(name); exists && actual != expected { + return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name) + } + return nil +} + +func bindResolvedParameter(options *commandOptions, parameters protocol.Parameters, name, expected string) error { + if actual, exists := parameters.Get(name); exists { + if actual != expected { + return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name) + } + return nil + } + options.parameters = append(options.parameters, name+"="+expected) + return nil +} + +func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.Request, error) { + if request.ProgramID == "" && request.EntryID == "" { + return request, nil + } + parameterFlags := make([]string, 0, len(request.Parameters)) + for _, parameter := range request.Parameters { + parameterFlags = append(parameterFlags, parameter.Name+"="+parameter.Value) + } + bound, err := bindFlowEntry(ctx, commandOptions{ + repository: request.Repository, host: request.Host, programID: request.ProgramID, entryID: request.EntryID, + flowProgramFingerprint: request.ProgramFingerprint, + runID: request.FlowID, objectiveID: request.Objective.ID, objectiveKind: string(request.Objective.Kind), deliveryID: request.Objective.DeliveryID, + transitionID: string(request.TransitionID), parameters: parameterFlags, + }) + if err != nil { + return surfaces.Request{}, err + } + parameters, err := parseParameters(bound.parameters) + if err != nil { + return surfaces.Request{}, err + } + request.Repository = bound.repository + request.ProgramFingerprint = bound.flowProgramFingerprint + request.FlowID = bound.runID + request.Objective.ID = bound.objectiveID + request.Objective.Kind = model.ObjectiveKind(bound.objectiveKind) + request.Objective.DeliveryID = bound.deliveryID + request.Parameters = parameters + return request, nil +} + +func resolveBoundPlan(repository string, entry controlprogram.Entry, options commandOptions) (string, string, error) { + if options.runID == "" { + return resolvePlanInput(repository, entry) + } + if !flowSegment.MatchString(options.deliveryID) { + return "", "", fmt.Errorf("FLOW_CONTEXT_MISMATCH: active run requires its delivery identity") + } + managed := filepath.Join(repository, ".boatstack", "plans", options.deliveryID+".source") + if _, err := os.Lstat(managed); err == nil { + resolved, resolveErr := resolveRegularRepositoryFile(repository, managed, "active run plan") + return resolved, options.deliveryID, resolveErr + } else if !os.IsNotExist(err) { + return "", "", fmt.Errorf("FLOW_INPUT_INVALID: inspect active run plan: %w", err) + } + inbox, _, err := resolvePlanInbox(repository, entry) + if err != nil { + return "", "", err + } + resolved, err := resolveActiveInboxPlan(repository, inbox, options.deliveryID) + return resolved, options.deliveryID, err +} + +func resolveActiveInboxPlan(repository, inbox, deliveryID string) (string, error) { + entries, err := os.ReadDir(inbox) + if err != nil { + return "", fmt.Errorf("FLOW_INPUT_REQUIRED: active run inbox is unavailable: %w", err) + } + var candidates []string + for _, candidate := range entries { + extension := filepath.Ext(candidate.Name()) + if candidate.Type()&os.ModeSymlink != 0 || candidate.IsDir() || !strings.EqualFold(extension, ".md") || strings.TrimSuffix(candidate.Name(), extension) != deliveryID { + continue + } + info, infoErr := candidate.Info() + if infoErr == nil && info.Mode().IsRegular() { + candidates = append(candidates, candidate.Name()) + } + } + if len(candidates) == 0 { + return "", fmt.Errorf("FLOW_INPUT_REQUIRED: active run inbox plan %q is unavailable", deliveryID) + } + if len(candidates) != 1 { + return "", fmt.Errorf("FLOW_INPUT_INVALID: active run inbox plan %q is ambiguous", deliveryID) + } + return resolveRegularRepositoryFile(repository, filepath.Join(inbox, candidates[0]), "active run inbox plan") +} + +func resolveRegularRepositoryFile(repository, path, label string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", fmt.Errorf("FLOW_INPUT_REQUIRED: %s is unavailable: %w", label, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("FLOW_INPUT_INVALID: %s must be a regular non-symlink file", label) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("FLOW_INPUT_INVALID: resolve %s: %w", label, err) + } + relative, err := filepath.Rel(repository, resolved) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("FLOW_INPUT_INVALID: %s escapes the repository", label) + } + return resolved, nil +} + +func loadFlowDefinition(ctx context.Context, repository, programID string) (softwareflow.Definition, error) { + if !flowSegment.MatchString(programID) { + return softwareflow.Definition{}, fmt.Errorf("FLOW_PROGRAM_INVALID: program identity is not a semantic segment") + } + artifactPath := filepath.Join(repository, ".boatstack", "flows", programID+".flow.ir.json") + raw, err := os.ReadFile(artifactPath) + if err != nil { + return softwareflow.Definition{}, err + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(raw)) + if err != nil { + return softwareflow.Definition{}, err + } + resolver, err := softwareflow.NewResolver(ctx) + if err != nil { + return softwareflow.Definition{}, err + } + compiled, err := controlprogram.CheckArtifact(repository, artifact, flowCompilerVersion, resolver, generateSoftwareFlowSkills) + if err != nil { + return softwareflow.Definition{}, err + } + return softwareflow.NewDefinition(compiled, resolver) +} + +func resolvePlanInput(repository string, entry controlprogram.Entry) (string, string, error) { + inbox, config, err := resolvePlanInbox(repository, entry) + if err != nil { + return "", "", err + } + entries, err := os.ReadDir(inbox) + if err != nil { + if os.IsNotExist(err) { + return "", "", fmt.Errorf("PLAN_REQUIRED: no plan exists in %s", config.Path) + } + return "", "", err + } + var candidates []string + for _, candidate := range entries { + if candidate.Type()&os.ModeSymlink != 0 || candidate.IsDir() || !strings.EqualFold(filepath.Ext(candidate.Name()), ".md") { + continue + } + info, infoErr := candidate.Info() + if infoErr != nil || !info.Mode().IsRegular() { + continue + } + name := strings.TrimSuffix(candidate.Name(), filepath.Ext(candidate.Name())) + if flowSegment.MatchString(name) { + candidates = append(candidates, candidate.Name()) + } + } + sort.Strings(candidates) + if len(candidates) == 0 { + return "", "", fmt.Errorf("PLAN_REQUIRED: no eligible Markdown plan exists in %s", config.Path) + } + if len(candidates) != 1 { + return "", "", fmt.Errorf("PLAN_SELECTION_REQUIRED: found %d eligible plans in %s", len(candidates), config.Path) + } + deliveryID := strings.TrimSuffix(candidates[0], filepath.Ext(candidates[0])) + selected := filepath.Join(inbox, candidates[0]) + resolved, err := resolveRegularRepositoryFile(repository, selected, "selected plan") + if err != nil { + return "", "", err + } + return resolved, deliveryID, nil +} + +func resolvePlanInbox(repository string, entry controlprogram.Entry) (string, softwareflow.PlanInbox, error) { + config, err := softwareflow.PlanInboxForEntry(entry) + if err != nil { + return "", softwareflow.PlanInbox{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) + } + inbox, err := exactRepositoryPath(repository, filepath.FromSlash(config.Path)) + if err != nil { + return "", softwareflow.PlanInbox{}, fmt.Errorf("FLOW_INPUT_INVALID: %w", err) + } + resolvedInbox, err := filepath.EvalSymlinks(inbox) + if err != nil && !os.IsNotExist(err) { + return "", softwareflow.PlanInbox{}, fmt.Errorf("FLOW_INPUT_INVALID: resolve plan inbox: %w", err) + } + if err == nil { + relative, relativeErr := filepath.Rel(repository, resolvedInbox) + if relativeErr != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", softwareflow.PlanInbox{}, fmt.Errorf("FLOW_INPUT_INVALID: plan inbox escapes the repository") + } + inbox = resolvedInbox + } + return inbox, config, nil +} + +func findEntry(entries []controlprogram.Entry, id string) (controlprogram.Entry, bool) { + for _, entry := range entries { + if entry.ID == id { + return entry, true + } + } + return controlprogram.Entry{}, false +} + +func generateSoftwareFlowSkills(compiled controlprogram.Compiled) (map[string][]byte, error) { + return softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) +} + +func flowRepositoryIdentity(repository string) (string, error) { + marker := filepath.Join(repository, ".git") + info, err := os.Lstat(marker) + if err != nil { + return "", fmt.Errorf("FLOW_REPOSITORY_IDENTITY_REQUIRED: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("FLOW_REPOSITORY_IDENTITY_INVALID: .git is a symlink") + } + common := marker + if !info.IsDir() { + if !info.Mode().IsRegular() { + return "", fmt.Errorf("FLOW_REPOSITORY_IDENTITY_INVALID: .git is not a directory or worktree marker") + } + raw, readErr := os.ReadFile(marker) + if readErr != nil { + return "", readErr + } + line := strings.TrimSpace(string(raw)) + if !strings.HasPrefix(line, "gitdir: ") || strings.Contains(strings.TrimPrefix(line, "gitdir: "), "\n") { + return "", fmt.Errorf("FLOW_REPOSITORY_IDENTITY_INVALID: invalid worktree Git marker") + } + gitDirectory := strings.TrimPrefix(line, "gitdir: ") + if !filepath.IsAbs(gitDirectory) { + gitDirectory = filepath.Join(repository, gitDirectory) + } + common = gitDirectory + if rawCommon, commonErr := os.ReadFile(filepath.Join(gitDirectory, "commondir")); commonErr == nil { + common = strings.TrimSpace(string(rawCommon)) + if !filepath.IsAbs(common) { + common = filepath.Join(gitDirectory, common) + } + } else if !os.IsNotExist(commonErr) { + return "", commonErr + } + } + common, err = filepath.EvalSymlinks(filepath.Clean(common)) + if err != nil { + return "", fmt.Errorf("FLOW_REPOSITORY_IDENTITY_INVALID: %w", err) + } + if info, err := os.Stat(common); err != nil || !info.IsDir() { + return "", fmt.Errorf("FLOW_REPOSITORY_IDENTITY_INVALID: Git common directory is unavailable") + } + return common, nil +} + +func flowRunID(repositoryIdentity, fingerprint, entry, delivery, planFingerprint string) string { + value := strings.Join([]string{repositoryIdentity, fingerprint, entry, delivery, planFingerprint}, "\x00") + digest := sha256.Sum256([]byte(value)) + return "run-" + hex.EncodeToString(digest[:16]) +} diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go new file mode 100644 index 0000000..7078927 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -0,0 +1,1048 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" +) + +func flowRepository(t *testing.T) string { + t.Helper() + repository := t.TempDir() + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o700); err != nil { + t.Fatal(err) + } + document := productDeliveryDocument("product-delivery") + sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" + source, lock := []byte("flow source"), []byte("lock") + for path, content := range map[string][]byte{sourcePath: source, lockPath: lock} { + writeFixture(t, repository, path, content) + } + writeFlowArtifact(t, repository, document, sourcePath, source, lockPath, lock) + return repository +} + +func bindSharedGitCommon(t *testing.T, repository, gitDirectory, commonDirectory string) { + t.Helper() + if err := os.Remove(filepath.Join(repository, ".git")); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(gitDirectory, 0o700); err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(gitDirectory, commonDirectory) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDirectory, "commondir"), []byte(relative+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, ".git"), []byte("gitdir: "+gitDirectory+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeFlowArtifact(t *testing.T, repository string, document controlprogram.Document, sourcePath string, source []byte, lockPath string, lock []byte) { + t.Helper() + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + for path, content := range skills { + writeFixture(t, repository, path, content) + } + _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: flowCompilerVersion, SourcePath: sourcePath, Source: source, DependencyLockPath: lockPath, DependencyLock: lock, GeneratedSkills: skills, + }) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/flows/"+document.Program.ID+".flow.ir.json", artifactRaw) +} + +func productDeliveryDocument(programID string) controlprogram.Document { + truth := true + config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) + return controlprogram.Document{ + SchemaVersion: controlprogram.SchemaVersion, + Program: controlprogram.Program{ID: programID, Version: "1"}, + Declarations: controlprogram.Declarations{InputResolvers: []string{"software-delivery.plan-inbox"}}, + Facets: []controlprogram.Facet{ + {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, + {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, + }, + Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, + Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77}}, + Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ + flowFact("verification", "current"), flowFact("configuration", "verified"), flowFact("runtime", "verified"), flowFact("publication", "open"), + }}}}, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr", Inputs: []controlprogram.EntryInput{{ID: "plan", Type: "markdown-file", Required: true, Resolver: "software-delivery.plan-inbox", Config: config}}}}, + } +} + +func writeFixture(t *testing.T, repository, relative string, content []byte) { + t.Helper() + path := filepath.Join(repository, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } +} + +func flowFact(facet, value string) controlprogram.Predicate { + return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}, Values: []string{value}}} +} + +func TestFlowEntryRejectsPlanCardinalityBeforeManagedState(t *testing.T) { + // control-law: a run is not created until exactly one repository plan is selected + for name, count := range map[string]int{"none": 0, "multiple": 2} { + t.Run(name, func(t *testing.T) { + repository := flowRepository(t) + for index := 0; index < count; index++ { + writeFixture(t, repository, filepath.Join(".boatstack/plans/inbox", string(rune('a'+index))+".md"), []byte("plan")) + } + _, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err == nil { + t.Fatal("invalid plan cardinality was accepted") + } + if count == 0 && !strings.Contains(err.Error(), "PLAN_REQUIRED") { + t.Fatalf("zero-plan error = %v", err) + } + if count > 1 && !strings.Contains(err.Error(), "PLAN_SELECTION_REQUIRED") { + t.Fatalf("multiple-plan error = %v", err) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "state.json")); !os.IsNotExist(statErr) { + t.Fatalf("plan blocker created managed state: %v", statErr) + } + }) + } +} + +func TestFlowEntryRejectsAdditionalRequiredInputs(t *testing.T) { + config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) + entry := controlprogram.Entry{ID: "run", Inputs: []controlprogram.EntryInput{ + {ID: "first", Required: true, Resolver: "software-delivery.plan-inbox", Config: config}, + {ID: "second", Required: true, Resolver: "software-delivery.plan-inbox", Config: config}, + }} + if _, _, err := resolvePlanInput(t.TempDir(), entry); err == nil || !strings.Contains(err.Error(), "exactly one plan input") { + t.Fatalf("multiple required inputs result = %v", err) + } +} + +func TestRPCFlowEntryRejectsUnknownEntryAndInvalidInboxBeforeManagedState(t *testing.T) { + // control-law: every-surface-binds-the-repository-entry-before-resolution-or-effects + for name, entry := range map[string]string{"unknown-entry": "missing", "empty-inbox": "run"} { + t.Run(name, func(t *testing.T) { + repository := flowRepository(t) + _, err := bindRPCFlowEntry(context.Background(), surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, + Host: "claude", CorrelationID: "rpc-binding", ProgramID: "product-delivery", EntryID: entry, FlowID: "run-caller-supplied", + }) + if err == nil { + t.Fatal("unbound RPC Flow entry was accepted") + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "state.json")); !os.IsNotExist(statErr) { + t.Fatalf("RPC entry refusal created managed state: %v", statErr) + } + }) + } +} + +func TestRPCFlowEntryPreservesObjectiveEvidenceAndStopContext(t *testing.T) { + // control-law: entry-binding-preserves-nonidentity-objective-context + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + bound, err := bindRPCFlowEntry(context.Background(), surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, + Host: "claude", CorrelationID: "rpc-context", ProgramID: "product-delivery", EntryID: "run", + Objective: model.Objective{EvidenceFingerprint: strings.Repeat("a", 64), FrontierIsStop: true}, + }) + if err != nil { + t.Fatal(err) + } + if bound.Objective.EvidenceFingerprint != strings.Repeat("a", 64) || !bound.Objective.FrontierIsStop { + t.Fatalf("RPC binding dropped objective context: %#v", bound.Objective) + } +} + +func TestFlowRunIdentitySurvivesWorkspaceTransfer(t *testing.T) { + // control-law: a repository Flow run retains one identity when workspace.cut transfers authority + common := filepath.Join(t.TempDir(), "repository.git") + if err := os.MkdirAll(filepath.Join(common, "worktrees"), 0o700); err != nil { + t.Fatal(err) + } + source, destination := flowRepository(t), flowRepository(t) + bindSharedGitCommon(t, source, filepath.Join(common, "worktrees", "source"), common) + bindSharedGitCommon(t, destination, filepath.Join(common, "worktrees", "destination"), common) + plan := []byte("# Exact plan\n") + writeFixture(t, source, ".boatstack/plans/inbox/delivery-one.md", plan) + writeFixture(t, destination, ".boatstack/plans/delivery-one.source", plan) + + initial, err := bindFlowEntry(context.Background(), commandOptions{ + repository: source, programID: "product-delivery", entryID: "run", host: "codex", + }) + if err != nil { + t.Fatal(err) + } + resumed, err := bindFlowEntry(context.Background(), commandOptions{ + repository: destination, programID: "product-delivery", entryID: "run", host: "codex", + flowProgramFingerprint: initial.flowProgramFingerprint, runID: initial.runID, + deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, + transitionID: "plan.create", + }) + if err != nil { + t.Fatal(err) + } + if resumed.runID != initial.runID { + t.Fatalf("workspace transfer changed Flow run identity: %q != %q", resumed.runID, initial.runID) + } + parameters, err := parseParameters(resumed.parameters) + if err != nil { + t.Fatal(err) + } + if sourcePath, ok := parameters.Get("source_path"); !ok || sourcePath != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { + t.Fatalf("destination plan binding = %q, %t", sourcePath, ok) + } +} + +func TestFlowEntryRejectsCallerOverridesOfResolvedInputs(t *testing.T) { + // control-law: entry-resolved-inputs-cannot-be-replaced-by-callers + for _, surface := range []string{"cli", "rpc"} { + t.Run(surface, func(t *testing.T) { + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + other := filepath.Join(repository, "other.md") + writeFixture(t, repository, "other.md", []byte("other plan")) + if surface == "cli" { + _, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + transitionID: "plan.create", parameters: []string{"source_path=" + other}, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + t.Fatalf("CLI override result = %v", err) + } + return + } + _, err := bindRPCFlowEntry(context.Background(), surfaces.Request{ + SchemaVersion: surfaces.SchemaVersion, Operation: surfaces.OperationResolve, Repository: repository, + Host: "claude", CorrelationID: "rpc-override", ProgramID: "product-delivery", EntryID: "run", + TransitionID: "plan.create", Parameters: protocol.Parameters{{Name: "source_path", Value: other}}, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + t.Fatalf("RPC override result = %v", err) + } + }) + } +} + +func TestFlowEntryRejectsCallerOverridesDuringUntargetedResolution(t *testing.T) { + // control-law: untargeted-resolution-and-apply-share-the-exact-entry-input-boundary + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + other := filepath.Join(repository, "other.md") + writeFixture(t, repository, "other.md", []byte("other plan")) + _, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + parameters: []string{"source_path=" + other}, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_MISMATCH") { + t.Fatalf("untargeted override result = %v", err) + } +} + +func TestFlowCompileRejectsSourceChangedDuringFrontend(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ts", []byte("source A")) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\nprintf 'source B' > \"$2\"\nprintf '{}\\n'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + err = compileFlow(context.Background(), flowCommandOptions{ + repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_COMPILE_INPUT_CHANGED") { + t.Fatalf("source replacement result = %v", err) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack/flows/product-delivery.flow.ir.json")); !os.IsNotExist(statErr) { + t.Fatalf("source race created an artifact: %v", statErr) + } +} + +func TestFlowCompileDoesNotAutomaticallyExecuteRepositoryFrontend(t *testing.T) { + // control-law: repository-content-cannot-authorize-ambient-frontend-execution + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(repository, "sentinel") + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ts", []byte("source")) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + frontend := filepath.Join(repository, "node_modules", ".bin", "boatstack-flow-frontend") + if runtime.GOOS == "windows" { + frontend += ".cmd" + } + if err := os.MkdirAll(filepath.Dir(frontend), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(frontend, []byte("#!/bin/sh\nprintf executed > '"+sentinel+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + err = compileFlow(context.Background(), flowCommandOptions{repository: repository, lock: "package-lock.json"}) + if err == nil || !strings.Contains(err.Error(), "FLOW_FRONTEND_REQUIRED") { + t.Fatalf("automatic frontend result = %v", err) + } + if _, statErr := os.Stat(sentinel); !os.IsNotExist(statErr) { + t.Fatalf("repository frontend executed: %v", statErr) + } +} + +func TestFlowCompileNamesDefaultArtifactFromProgramID(t *testing.T) { + // control-law: compiled-artifact-is-discoverable-by-declared-program-identity + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + documentRaw, err := json.Marshal(productDeliveryDocument("bar")) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/flows/foo.flow.ts", []byte("declarative source")) + writeFixture(t, repository, ".git/keep", nil) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + writeFixture(t, repository, "raw-ir.json", documentRaw) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + if err := compileFlow(context.Background(), flowCommandOptions{ + repository: repository, source: ".boatstack/flows/foo.flow.ts", lock: "package-lock.json", frontend: frontend, + }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(repository, ".boatstack", "flows", "bar.flow.ir.json")); err != nil { + t.Fatalf("program artifact missing: %v", err) + } + if _, err := os.Stat(filepath.Join(repository, ".boatstack", "flows", "foo.flow.ir.json")); !os.IsNotExist(err) { + t.Fatalf("source-stem artifact exists: %v", err) + } +} + +func TestFlowCompileProjectsHyphenatedEntryIdentity(t *testing.T) { + // control-law: every valid IR entry identity has an injective artifact-valid skill path + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + document := productDeliveryDocument("product-delivery") + document.Entries[0].ID = "run-now" + documentRaw, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ts", []byte("declarative source")) + writeFixture(t, repository, ".git/keep", nil) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + writeFixture(t, repository, "raw-ir.json", documentRaw) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + if err := compileFlow(context.Background(), flowCommandOptions{ + repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend, + }); err != nil { + t.Fatal(err) + } + artifactRaw, err := os.ReadFile(filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json")) + if err != nil { + t.Fatal(err) + } + artifact, err := controlprogram.LoadArtifact(bytes.NewReader(artifactRaw)) + if err != nil { + t.Fatal(err) + } + if len(artifact.GeneratedSkills) != 3 { + t.Fatalf("generated skills = %v", artifact.GeneratedSkills) + } + for path := range artifact.GeneratedSkills { + if strings.Contains(path, "--") { + t.Fatalf("artifact contains invalid skill path %s", path) + } + } +} + +func TestFlowCompileRejectsDependencyLockProjectionOverlap(t *testing.T) { + // control-law: compile-inputs-cannot-be-replaced-or-retired-by-their-own-projection + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + documentRaw, err := json.Marshal(productDeliveryDocument("product-delivery")) + if err != nil { + t.Fatal(err) + } + sourcePath := ".boatstack/flows/product-delivery.flow.ts" + artifactPath := ".boatstack/flows/product-delivery.flow.ir.json" + writeFixture(t, repository, ".git/keep", nil) + writeFixture(t, repository, sourcePath, []byte("declarative source")) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + writeFixture(t, repository, "raw-ir.json", documentRaw) + frontend := filepath.Join(repository, "frontend.sh") + if err := os.WriteFile(frontend, []byte("#!/bin/sh\ncat >/dev/null\ncat '"+filepath.Join(repository, "raw-ir.json")+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + options := flowCommandOptions{repository: repository, source: sourcePath, lock: "package-lock.json", frontend: frontend} + if err := compileFlow(context.Background(), options); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(artifactPath))) + if err != nil { + t.Fatal(err) + } + options.lock = artifactPath + err = compileFlow(context.Background(), options) + if err == nil || !strings.Contains(err.Error(), "FLOW_COMPILE_INPUT_OVERLAP") { + t.Fatalf("overlapping lock result = %v", err) + } + after, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(artifactPath))) + if err != nil || !bytes.Equal(after, before) { + t.Fatalf("overlapping compile changed artifact: %v", err) + } + if err := checkFlow(context.Background(), flowCommandOptions{repository: repository}); err != nil { + t.Fatalf("preserved artifact no longer checks: %v", err) + } +} + +func TestFlowCompileRefusesUnmanagedGeneratedSkill(t *testing.T) { + // control-law: first-compile-cannot-adopt-or-overwrite-unmanaged-skill-bytes + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + documentRaw, err := json.Marshal(productDeliveryDocument("product-delivery")) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".git/keep", nil) + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ts", []byte("declarative source")) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + writeFixture(t, repository, "raw-ir.json", documentRaw) + skillPath := ".agents/skills/product-delivery-run/SKILL.md" + writeFixture(t, repository, skillPath, []byte("user-owned skill")) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + err = compileFlow(context.Background(), flowCommandOptions{ + repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend, + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_WRITE_UNAUTHORIZED") { + t.Fatalf("unmanaged skill compile result = %v", err) + } + if actual, readErr := os.ReadFile(filepath.Join(repository, filepath.FromSlash(skillPath))); readErr != nil || string(actual) != "user-owned skill" { + t.Fatalf("unmanaged skill changed: %q, %v", actual, readErr) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json")); !os.IsNotExist(statErr) { + t.Fatalf("unmanaged skill compile published artifact: %v", statErr) + } +} + +func TestFlowCompileRejectsForgedArtifactOwnership(t *testing.T) { + // control-law: repository-artifacts-cannot-grant-generated-output-ownership + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + document := productDeliveryDocument("product-delivery") + documentRaw, _ := json.Marshal(document) + source, lock := []byte("declarative source"), []byte("lock") + writeFixture(t, repository, ".git/keep", nil) + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ts", source) + writeFixture(t, repository, "package-lock.json", lock) + writeFixture(t, repository, "raw-ir.json", documentRaw) + unrelatedPath := ".agents/skills/unrelated/SKILL.md" + unrelated := []byte("user-owned skill") + writeFixture(t, repository, unrelatedPath, unrelated) + resolver, _ := softwareflow.NewResolver(context.Background()) + compiled, _ := controlprogram.Compile(document, resolver) + skills, _ := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + for path, content := range skills { + writeFixture(t, repository, path, content) + } + forged, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: flowCompilerVersion, SourcePath: ".boatstack/flows/product-delivery.flow.ts", Source: source, + DependencyLockPath: "package-lock.json", DependencyLock: lock, GeneratedSkills: skills, + }) + if err != nil { + t.Fatal(err) + } + forged.GeneratedSkills[unrelatedPath] = fileDigest(unrelated) + forgedRaw, _ := json.Marshal(forged) + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ir.json", forgedRaw) + frontend := filepath.Join(repository, "frontend.sh") + if err := os.WriteFile(frontend, []byte("#!/bin/sh\ncat >/dev/null\ncat '"+filepath.Join(repository, "raw-ir.json")+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + err = compileFlow(context.Background(), flowCommandOptions{repository: repository, source: ".boatstack/flows/product-delivery.flow.ts", lock: "package-lock.json", frontend: frontend}) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION") { + t.Fatalf("forged ownership result = %v", err) + } + if actual, readErr := os.ReadFile(filepath.Join(repository, filepath.FromSlash(unrelatedPath))); readErr != nil || !bytes.Equal(actual, unrelated) { + t.Fatalf("unrelated skill changed: %q, %v", actual, readErr) + } +} + +func TestFlowExecutionLeaseSerializesProjectionPublicationThroughEffect(t *testing.T) { + // control-law: official-flow-publication-cannot-cross-apply-or-recovery + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".git/keep", nil) + lease, err := acquireFlowExecutionLease(surfaces.Request{ProgramID: "product-delivery", Operation: surfaces.OperationApply, Repository: repository}) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + err = boatstackruntime.ApplyFlowProjection(repository, []boatstackruntime.ProjectionWrite{{Path: target, Content: []byte("program B"), Mode: 0o644}}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_BUSY") { + t.Fatalf("publication during effect result = %v", err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("blocked publication changed artifact: %v", statErr) + } + lease.Release() +} + +func TestFlowValidationRejectsMissingProductionRecoveryClosure(t *testing.T) { + // control-law: published-flows-close-recovery-in-the-production-composition + document := productDeliveryDocument("product-delivery") + document.Operators[0] = controlprogram.Operator{ID: "publication.execute", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.execute", Version: "1"}} + document.Transitions[0] = controlprogram.Transition{ID: "publication.execute", Operator: "publication.execute", Guard: document.Transitions[0].Guard, Target: document.Transitions[0].Target, Priority: 77} + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + resolved, err := resolver.ResolveOperator("software-delivery/publication.execute", "1") + if err != nil { + t.Fatal(err) + } + declared := map[string]bool{} + for _, facet := range document.Facets { + declared[facet.ID] = true + } + for _, precondition := range resolved.StateEffect.Preconditions { + if !declared[precondition.Facet] { + document.Facets = append(document.Facets, controlprogram.Facet{ID: precondition.Facet, Kind: "string"}) + declared[precondition.Facet] = true + } + } + for _, assignment := range resolved.StateEffect.Assignments { + if !declared[assignment.Facet] { + document.Facets = append(document.Facets, controlprogram.Facet{ID: assignment.Facet, Kind: "string"}) + declared[assignment.Facet] = true + } + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + if err := validateSoftwareFlow(context.Background(), t.TempDir(), compiled, resolver); err == nil || !strings.Contains(err.Error(), "FLOW_RUNTIME_INVALID") { + t.Fatalf("missing recovery closure result = %v", err) + } +} + +func TestFlowCompileRetiresProjectionWhenSourceChangesProgramID(t *testing.T) { + // control-law: one-flow-source-owns-one-current-program-projection + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sourcePath := ".boatstack/flows/delivery.flow.ts" + writeFixture(t, repository, ".git/keep", nil) + writeFixture(t, repository, sourcePath, []byte("declarative source")) + writeFixture(t, repository, "package-lock.json", []byte("lock")) + frontend := filepath.Join(repository, "frontend.sh") + if err := os.WriteFile(frontend, []byte("#!/bin/sh\ncat >/dev/null\ncat '"+filepath.Join(repository, "raw-ir.json")+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + for _, programID := range []string{"foo", "bar"} { + raw, _ := json.Marshal(productDeliveryDocument(programID)) + writeFixture(t, repository, "raw-ir.json", raw) + if err := compileFlow(context.Background(), flowCommandOptions{repository: repository, source: sourcePath, lock: "package-lock.json", frontend: frontend}); err != nil { + t.Fatalf("compile %s: %v", programID, err) + } + } + for _, stale := range []string{".boatstack/flows/foo.flow.ir.json", ".agents/skills/foo-run/SKILL.md", ".agents/skills/foo-run/agents/openai.yaml", ".claude/skills/foo-run/SKILL.md"} { + if _, err := os.Stat(filepath.Join(repository, filepath.FromSlash(stale))); !os.IsNotExist(err) { + t.Fatalf("stale projection remains at %s: %v", stale, err) + } + } + if err := checkFlow(context.Background(), flowCommandOptions{repository: repository}); err != nil { + t.Fatalf("renamed projection check failed: %v", err) + } +} + +func TestFlowCompileAndCheckRejectRuntimeInvalidSoftwareFlow(t *testing.T) { + // control-law: compiled-and-checked-artifacts-are-admissible-by-the-production-adapter + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + document := productDeliveryDocument("product-delivery") + document.Transitions[0].ID = "observe-alias" + for _, operation := range []string{"compile", "check"} { + t.Run(operation, func(t *testing.T) { + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" + source, lock := []byte("declarative source"), []byte("lock") + writeFixture(t, repository, sourcePath, source) + writeFixture(t, repository, lockPath, lock) + if operation == "compile" { + documentRaw, marshalErr := json.Marshal(document) + if marshalErr != nil { + t.Fatal(marshalErr) + } + writeFixture(t, repository, "raw-ir.json", documentRaw) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + err = compileFlow(context.Background(), flowCommandOptions{repository: repository, source: sourcePath, lock: lockPath, frontend: frontend}) + if err == nil || !strings.Contains(err.Error(), "FLOW_RUNTIME_INVALID") { + t.Fatalf("runtime-invalid compile result = %v", err) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json")); !os.IsNotExist(statErr) { + t.Fatalf("runtime-invalid compile published an artifact: %v", statErr) + } + return + } + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + for path, content := range skills { + writeFixture(t, repository, path, content) + } + _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: flowCompilerVersion, SourcePath: sourcePath, Source: source, + DependencyLockPath: lockPath, DependencyLock: lock, GeneratedSkills: skills, + }) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ir.json", artifactRaw) + err = checkFlow(context.Background(), flowCommandOptions{repository: repository}) + if err == nil || !strings.Contains(err.Error(), "FLOW_RUNTIME_INVALID") { + t.Fatalf("runtime-invalid check result = %v", err) + } + }) + } +} + +func TestFlowCompileAndCheckRejectUnbindableEntryInputs(t *testing.T) { + // control-law: artifact-admission-and-runtime-resolution-share-one-entry-input-contract + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + document := productDeliveryDocument("product-delivery") + document.Entries[0].Inputs = nil + for _, operation := range []string{"compile", "check"} { + t.Run(operation, func(t *testing.T) { + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sourcePath, lockPath := ".boatstack/flows/product-delivery.flow.ts", "package-lock.json" + source, lock := []byte("declarative source"), []byte("lock") + writeFixture(t, repository, sourcePath, source) + writeFixture(t, repository, lockPath, lock) + if operation == "compile" { + documentRaw, marshalErr := json.Marshal(document) + if marshalErr != nil { + t.Fatal(marshalErr) + } + writeFixture(t, repository, "raw-ir.json", documentRaw) + frontend := filepath.Join(repository, "frontend.sh") + script := []byte("#!/bin/sh\ncat >/dev/null\ncat '" + filepath.Join(repository, "raw-ir.json") + "'\n") + if err := os.WriteFile(frontend, script, 0o700); err != nil { + t.Fatal(err) + } + err = compileFlow(context.Background(), flowCommandOptions{repository: repository, source: sourcePath, lock: lockPath, frontend: frontend}) + if err == nil || !strings.Contains(err.Error(), "FLOW_RUNTIME_INVALID") { + t.Fatalf("input-invalid compile result = %v", err) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json")); !os.IsNotExist(statErr) { + t.Fatalf("input-invalid compile published an artifact: %v", statErr) + } + return + } + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + skills, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + for path, content := range skills { + writeFixture(t, repository, path, content) + } + _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: flowCompilerVersion, SourcePath: sourcePath, Source: source, + DependencyLockPath: lockPath, DependencyLock: lock, GeneratedSkills: skills, + }) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/flows/product-delivery.flow.ir.json", artifactRaw) + err = checkFlow(context.Background(), flowCommandOptions{repository: repository}) + if err == nil || !strings.Contains(err.Error(), "FLOW_RUNTIME_INVALID") { + t.Fatalf("input-invalid check result = %v", err) + } + }) + } +} + +func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { + // control-law: questions-and-restarts-preserve-the-exact-plan-worktree-and-run + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("exact plan")) + initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(initial.runID, "run-") || initial.deliveryID != "delivery-one" || initial.objectiveKind != "open-or-updated-pr" || len(initial.parameters) != 0 { + t.Fatalf("initial Flow context = %#v", initial) + } + for _, transitionID := range []string{"objective.bind", "plan.create"} { + preManaged, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", + deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, transitionID: transitionID, + }) + if err != nil { + t.Fatalf("pre-materialization %s binding failed: %v", transitionID, err) + } + if transitionID == "plan.create" { + parameters, parseErr := parseParameters(preManaged.parameters) + if parseErr != nil { + t.Fatal(parseErr) + } + expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery-one.md") + if source, ok := parameters.Get("source_path"); !ok || source != expected { + t.Fatalf("pre-materialization source = %q, present=%t", source, ok) + } + } + } + writeFixture(t, repository, ".boatstack/plans/delivery-one.source", []byte("exact plan")) + writeFixture(t, repository, ".boatstack/plans/inbox/unrelated.md", []byte("other plan")) + resumed, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", + deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, transitionID: "plan.create", + }) + if err != nil { + t.Fatal(err) + } + if resumed.runID != initial.runID { + t.Fatalf("run identity changed: %s != %s", resumed.runID, initial.runID) + } + parameters, err := parseParameters(resumed.parameters) + if err != nil { + t.Fatal(err) + } + if source, ok := parameters.Get("source_path"); !ok || source != filepath.Join(resumed.repository, ".boatstack", "plans", "delivery-one.source") { + t.Fatalf("resumed source = %q, present=%t", source, ok) + } +} + +func TestFlowEntryRejectsSelectedPlanContentSubstitution(t *testing.T) { + // control-law: one-flow-run-binds-the-exact-selected-plan-bytes + repository := flowRepository(t) + planPath := ".boatstack/plans/inbox/delivery-one.md" + writeFixture(t, repository, planPath, []byte("plan A")) + initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, planPath, []byte("plan B")) + _, err = bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", + deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, transitionID: "plan.create", + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_RUN_MISMATCH") { + t.Fatalf("plan substitution result = %v", err) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "plans", "delivery-one.source")); !os.IsNotExist(statErr) { + t.Fatalf("plan substitution produced a managed source: %v", statErr) + } +} + +func TestFlowEntryPreservesSelectedPlanFilenameBeforeMaterialization(t *testing.T) { + // control-law: an-admitted-plan-filename-remains-resolvable-for-the-same-run + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery.MD", []byte("exact plan")) + initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + resumed, err := bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", + deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, transitionID: "plan.create", + }) + if err != nil { + t.Fatal(err) + } + parameters, err := parseParameters(resumed.parameters) + if err != nil { + t.Fatal(err) + } + expected := filepath.Join(initial.repository, ".boatstack", "plans", "inbox", "delivery.MD") + if source, ok := parameters.Get("source_path"); !ok || source != expected { + t.Fatalf("resumed source = %q, present=%t; want %q", source, ok, expected) + } +} + +func TestFlowEntryRejectsAmbiguousPlanFilenameOnResume(t *testing.T) { + // control-law: a-run-cannot-resume-through-a-different-case-colliding-plan-identity + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery.MD", []byte("selected plan")) + initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".boatstack/plans/inbox/delivery.md", []byte("different plan")) + entries, err := os.ReadDir(filepath.Join(repository, ".boatstack", "plans", "inbox")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Skip("filesystem does not preserve case-colliding filenames") + } + _, err = bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", + deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, transitionID: "plan.create", + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_INPUT_INVALID") { + t.Fatalf("ambiguous resume result = %v", err) + } +} + +func TestFlowEntryRejectsObjectiveSubstitutionWithinRun(t *testing.T) { + // control-law: one-flow-run-retains-one-exact-product-objective + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + _, err = bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", host: "codex", + runID: initial.runID, deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, + objectiveID: "objective-substituted", transitionID: "objective.bind", + }) + if err == nil || !strings.Contains(err.Error(), "FLOW_CONTEXT_MISMATCH") { + t.Fatalf("objective substitution result = %v", err) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "state.json")); !os.IsNotExist(statErr) { + t.Fatalf("objective substitution created managed state: %v", statErr) + } +} + +func TestFlowEntryRejectsManagedPlanSymlinkEscape(t *testing.T) { + // control-law: resumed-run-plan-remains-a-regular-repository-file + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("exact plan")) + initial, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + external := filepath.Join(t.TempDir(), "outside.md") + if err := os.WriteFile(external, []byte("outside"), 0o600); err != nil { + t.Fatal(err) + } + managed := filepath.Join(repository, ".boatstack", "plans", "delivery-one.source") + if err := os.MkdirAll(filepath.Dir(managed), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, managed); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + _, err = bindFlowEntry(context.Background(), commandOptions{ + repository: repository, programID: "product-delivery", entryID: "run", runID: initial.runID, host: "codex", + deliveryID: initial.deliveryID, objectiveKind: initial.objectiveKind, objectiveID: initial.objectiveID, + }) + if err == nil || !strings.Contains(err.Error(), "regular non-symlink") { + t.Fatalf("managed symlink result = %v", err) + } +} + +func TestFlowKernelRejectsArtifactChangedAfterEntryBinding(t *testing.T) { + // control-law: run-entry-kernel-and-receipts-bind-one-exact-program-artifact + repository := flowRepository(t) + writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("plan")) + bound, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err != nil { + t.Fatal(err) + } + request, err := buildRequest(surfaces.OperationResolve, bound) + if err != nil { + t.Fatal(err) + } + changed := productDeliveryDocument("product-delivery") + changed.Transitions[0].Priority++ + writeFlowArtifact(t, repository, changed, ".boatstack/flows/product-delivery.flow.ts", []byte("flow source"), "package-lock.json", []byte("lock")) + if _, err := standardKernel(context.Background(), request); err == nil || !strings.Contains(err.Error(), "FLOW_PROGRAM_DRIFT") { + t.Fatalf("artifact drift result = %v", err) + } + for _, path := range []string{".boatstack/state.json", ".boatstack/receipts"} { + if _, statErr := os.Stat(filepath.Join(repository, filepath.FromSlash(path))); !os.IsNotExist(statErr) { + t.Fatalf("artifact drift created managed output %s: %v", path, statErr) + } + } +} + +func TestFlowEntryRejectsPlanInboxSymlinkEscape(t *testing.T) { + // control-law: repository-input-resolution-cannot-follow-an-external-inbox + repository := flowRepository(t) + external := t.TempDir() + writeFixture(t, external, "outside.md", []byte("outside")) + inbox := filepath.Join(repository, ".boatstack", "plans", "inbox") + if err := os.MkdirAll(filepath.Dir(inbox), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, inbox); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + _, err := bindFlowEntry(context.Background(), commandOptions{repository: repository, programID: "product-delivery", entryID: "run", host: "codex"}) + if err == nil || !strings.Contains(err.Error(), "escapes the repository") { + t.Fatalf("external inbox result = %v", err) + } +} + +func TestFlowCompileRetiresOnlyUnmodifiedPriorGeneratedSkills(t *testing.T) { + // control-law: removing-an-entry-cannot-leave-a-stale-authority-bearing-skill + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + writeFixture(t, repository, ".git/keep", nil) + retained := []byte("retained") + retired := []byte("retired") + retainedPath := ".agents/skills/program-keep/SKILL.md" + retiredPath := ".agents/skills/program-remove/SKILL.md" + writeFixture(t, repository, retainedPath, retained) + writeFixture(t, repository, retiredPath, retired) + artifact := controlprogram.Artifact{ + SchemaVersion: controlprogram.ArtifactSchemaVersion, CompilerVersion: flowCompilerVersion, + SourcePath: ".boatstack/flows/program.flow.ts", SourceSHA256: strings.Repeat("a", 64), + DependencyLockPath: "package-lock.json", DependencyLockSHA256: strings.Repeat("b", 64), + ProgramFingerprint: strings.Repeat("c", 64), + GeneratedSkills: map[string]string{retainedPath: fileDigest(retained), retiredPath: fileDigest(retired)}, + } + raw, err := json.Marshal(artifact) + if err != nil { + t.Fatal(err) + } + artifactPath := filepath.Join(repository, ".boatstack", "flows", "program.flow.ir.json") + writeFixture(t, repository, ".boatstack/flows/program.flow.ir.json", raw) + sourcePath := ".boatstack/flows/program.flow.ts" + priorOwnership, err := boatstackruntime.LoadFlowProjectionOwnership(repository, sourcePath) + if err != nil { + t.Fatal(err) + } + ownership := boatstackruntime.NewFlowProjectionOwnership(sourcePath, ".boatstack/flows/program.flow.ir.json", raw, map[string][]byte{retainedPath: retained, retiredPath: retired}) + if err := boatstackruntime.ApplyOwnedFlowProjection(repository, []boatstackruntime.ProjectionWrite{ + {Path: filepath.Join(repository, filepath.FromSlash(retainedPath)), Content: retained, Mode: 0o600}, + {Path: filepath.Join(repository, filepath.FromSlash(retiredPath)), Content: retired, Mode: 0o600}, + {Path: artifactPath, Content: raw, Mode: 0o600, PublishLast: true}, + }, nil, nil, priorOwnership, ownership); err != nil { + t.Fatal(err) + } + paths, artifactPrevious, priorSkills, _, err := ownedProjectionChanges(repository, sourcePath, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + if err != nil { + t.Fatal(err) + } + if len(paths) != 1 || paths[0].Path != filepath.Join(repository, filepath.FromSlash(retiredPath)) || artifactPrevious != fileDigest(raw) { + t.Fatalf("retired paths = %v", paths) + } + if priorSkills[retainedPath] != fileDigest(retained) || priorSkills[retiredPath] != fileDigest(retired) { + t.Fatalf("prior generated skills = %v", priorSkills) + } + writeFixture(t, repository, retiredPath, []byte("user changed")) + paths, _, _, _, err = ownedProjectionChanges(repository, sourcePath, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + if err != nil || len(paths) != 1 { + t.Fatalf("owned retirement plan = %v, %v", paths, err) + } + if err := os.Remove(filepath.Join(repository, filepath.FromSlash(retiredPath))); err != nil { + t.Fatal(err) + } + paths, _, _, _, err = ownedProjectionChanges(repository, sourcePath, artifactPath, map[string]string{retainedPath: fileDigest(retained)}) + if err != nil || len(paths) != 1 || !paths[0].AllowMissing { + t.Fatalf("interrupted retirement was not retryable: %v, %v", paths, err) + } +} diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 5a32bd7..713dc6b 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -18,6 +18,7 @@ import ( boatstack "github.com/operatorstack/boatstack/boatstack" "github.com/operatorstack/boatstack/boatstack/analysis" + "github.com/operatorstack/boatstack/boatstack/delivery" "github.com/operatorstack/boatstack/boatstack/distribution" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" boatstackruntime "github.com/operatorstack/boatstack/boatstack/internal/runtime" @@ -42,7 +43,10 @@ type commandOptions struct { objectiveID string objectiveKind string deliveryID string - flowID string + programID string + flowProgramFingerprint string + entryID string + runID string transitionID string correlationID string prescriptionID string @@ -95,6 +99,9 @@ func run(arguments []string) error { if command == "retro" { return runRetrospective(arguments[1:]) } + if command == "flow" { + return runFlowCommand(arguments[1:]) + } operation, transition, defaults, err := classifyCommand(command) if err != nil { return err @@ -103,10 +110,19 @@ func run(arguments []string) error { if err != nil { return err } + options, err = bindFlowEntry(context.Background(), options) + if err != nil { + return err + } request, err := buildRequest(operation, options) if err != nil { return err } + lease, err := acquireFlowExecutionLease(request) + if err != nil { + return err + } + defer lease.Release() kernel, err := standardKernel(context.Background(), request) if err != nil { return err @@ -114,7 +130,9 @@ func run(arguments []string) error { if (operation == surfaces.OperationApply || operation == surfaces.OperationRecover) && request.Prescription.ID == "" && command != "apply" && command != "recover" { resolveRequest := request resolveRequest.Operation = surfaces.OperationResolve - resolveRequest.FlowID = "" + if resolveRequest.ProgramID == "" { + resolveRequest.FlowID = "" + } resolveRequest.Prescription = protocol.Prescription{} resolved, resolveErr := kernel.Handle(context.Background(), resolveRequest) if resolveErr != nil || resolved.Prescription == nil { @@ -145,7 +163,7 @@ func run(arguments []string) error { } func usageError() error { - return errors.New("usage: boatstack [flags]") + return errors.New("usage: boatstack [flags]") } func runRPC() error { @@ -159,6 +177,15 @@ func runRPC() error { if err := decoder.Decode(&trailing); err != io.EOF { return fmt.Errorf("V2 RPC request contains trailing JSON") } + request, err := bindRPCFlowEntry(context.Background(), request) + if err != nil { + return err + } + lease, err := acquireFlowExecutionLease(request) + if err != nil { + return err + } + defer lease.Release() kernel, err := standardKernel(context.Background(), request) if err != nil { return err @@ -251,7 +278,9 @@ func parseOptions(command string, arguments []string, transition catalog.Transit flags.StringVar(&options.objectiveID, "objective-id", options.objectiveID, "configured objective identity") flags.StringVar(&options.objectiveKind, "objective-kind", options.objectiveKind, "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.programID, "flow", "", "repository Control Program identity") + flags.StringVar(&options.entryID, "entry", "", "named Flow entry") + flags.StringVar(&options.runID, "run-id", "", "opaque active run identity") flags.StringVar(&options.transitionID, "transition", options.transitionID, "stable semantic transition id") flags.StringVar(&options.correlationID, "correlation", "", "command-scoped correlation identity from resolution") flags.StringVar(&options.prescriptionID, "prescription-id", "", "exact prescription identity from resolution") @@ -310,13 +339,33 @@ func standardKernel(ctx context.Context, request surfaces.Request) (boatstack.De programRequest.ConfigurationPath, _ = request.Parameters.Get("config_path") programRequest.ConfigurationFingerprint, _ = request.Parameters.Get("config_sha256") } - program, err := distribution.StandardProgramForRepository(ctx, programRequest) + var program delivery.ControlProgram + var err error + if request.ProgramID != "" { + definition, definitionErr := loadFlowDefinition(ctx, request.Repository, request.ProgramID) + if definitionErr != nil { + return boatstack.DeliveryController{}, definitionErr + } + if definition.Fingerprint() != request.ProgramFingerprint { + return boatstack.DeliveryController{}, fmt.Errorf("FLOW_PROGRAM_DRIFT: bound fingerprint %q does not match current artifact %q", request.ProgramFingerprint, definition.Fingerprint()) + } + program, err = distribution.ProgramForRepository(ctx, programRequest, definition) + } else { + program, err = distribution.StandardProgramForRepository(ctx, programRequest) + } if err != nil { return boatstack.DeliveryController{}, err } return boatstack.NewDeliveryController("", program) } +func acquireFlowExecutionLease(request surfaces.Request) (*boatstackruntime.FlowProjectionLease, error) { + if request.ProgramID == "" || (request.Operation != surfaces.OperationApply && request.Operation != surfaces.OperationRecover) { + return &boatstackruntime.FlowProjectionLease{}, nil + } + return boatstackruntime.AcquireFlowProjectionLease(request.Repository) +} + func followEvents(kernel boatstack.DeliveryController, request surfaces.Request) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() @@ -455,7 +504,7 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface if err != nil { return surfaces.Request{}, err } - authority, err := loadAuthority(options, correlation, objective, now) + authority, err := loadAuthority(options, correlation, objective, parameters, now) if err != nil { return surfaces.Request{}, err } @@ -467,7 +516,7 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface if err != nil { return surfaces.Request{}, err } - flowID := options.flowID + flowID := options.runID if flowID == "" && objective.ID != "" { flowID = "flow-" + objective.ID } @@ -476,7 +525,7 @@ func buildRequest(operation surfaces.Operation, options commandOptions) (surface } return surfaces.Request{ SchemaVersion: surfaces.SchemaVersion, Operation: operation, Repository: options.repository, Host: options.host, CorrelationID: correlation, - FlowID: flowID, Objective: objective, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters, + ProgramID: options.programID, ProgramFingerprint: options.flowProgramFingerprint, EntryID: options.entryID, FlowID: flowID, Objective: objective, TransitionID: catalog.TransitionID(options.transitionID), Authority: authority, Parameters: parameters, Prescription: protocol.Prescription{SchemaVersion: protocol.PrescriptionSchemaVersion, ID: options.prescriptionID, TransitionID: catalog.TransitionID(options.transitionID), Freshness: general.Freshness{ ExpectedInstanceID: options.expectedInstanceID, ExpectedStateRevision: options.expectedStateRevision, ExpectedProgramFingerprint: options.expectedProgramFingerprint, @@ -535,7 +584,7 @@ func isPathParameter(name string) bool { } } -func loadAuthority(options commandOptions, correlation string, objective model.Objective, now time.Time) (protocol.AuthorityBundle, error) { +func loadAuthority(options commandOptions, correlation string, objective model.Objective, parameters protocol.Parameters, now time.Time) (protocol.AuthorityBundle, error) { bundle := protocol.AuthorityBundle{} for _, path := range options.authorityReceipts { raw, err := os.ReadFile(path) @@ -555,7 +604,11 @@ func loadAuthority(options commandOptions, correlation string, objective model.O bundle.Receipts = append(bundle.Receipts, receipt) } if options.humanActor != "" { - fingerprint := hash([]byte(strings.Join([]string{correlation, objective.ID, options.transitionID, options.humanActor}, "\x00"))) + parameterRaw, err := json.Marshal(parameters.Canonical()) + if err != nil { + return protocol.AuthorityBundle{}, err + } + fingerprint := hash([]byte(strings.Join([]string{correlation, objective.ID, options.transitionID, options.humanActor, string(parameterRaw)}, "\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), diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index 85a0ba9..1653620 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -3,12 +3,14 @@ package main import ( "os" "path/filepath" + "strings" "testing" "time" "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" "github.com/operatorstack/boatstack/boatstack/internal/testprogram" ) @@ -83,12 +85,30 @@ func TestTransitionReceiptCannotBeLoadedAsAuthority(t *testing.T) { if err := os.WriteFile(path, []byte(`{"schema_version":5,"id":"trc-old"}`), 0o600); err != nil { t.Fatal(err) } - _, err := loadAuthority(commandOptions{authorityReceipts: stringList{path}}, "correlation", model.Objective{}, time.Now().UTC()) + _, err := loadAuthority(commandOptions{authorityReceipts: stringList{path}}, "correlation", model.Objective{}, nil, time.Now().UTC()) if err == nil { t.Fatal("transition receipt was accepted as authority") } } +func TestHumanPublicationConfirmationBindsExactPreviewFingerprint(t *testing.T) { + // control-law: publication-authority-confirms-exact-preview-bytes + now := time.Now().UTC() + options := commandOptions{humanActor: "reviewer", transitionID: "publication.execute"} + objective := model.Objective{ID: "publish", Kind: model.ObjectiveOpenPR, DeliveryID: "delivery"} + one, err := loadAuthority(options, "correlation", objective, protocol.Parameters{{Name: "preview_fingerprint", Value: strings.Repeat("a", 64)}}, now) + if err != nil { + t.Fatal(err) + } + two, err := loadAuthority(options, "correlation", objective, protocol.Parameters{{Name: "preview_fingerprint", Value: strings.Repeat("b", 64)}}, now) + if err != nil { + t.Fatal(err) + } + if len(one.Receipts) != 1 || len(two.Receipts) != 1 || one.Receipts[0].Fingerprint == two.Receipts[0].Fingerprint { + t.Fatal("different preview fingerprints reused one human confirmation") + } +} + func TestRawCLIReconstructsExactCapabilityPrescription(t *testing.T) { options := commandOptions{ transitionID: "installation.update", host: "cli", repository: ".", prescriptionID: "prx-test", diff --git a/boatstack/controlprogram/artifact.go b/boatstack/controlprogram/artifact.go new file mode 100644 index 0000000..1a6e13d --- /dev/null +++ b/boatstack/controlprogram/artifact.go @@ -0,0 +1,193 @@ +package controlprogram + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +const ArtifactSchemaVersion = 1 + +type Artifact struct { + SchemaVersion int `json:"schema_version"` + CompilerVersion string `json:"compiler_version"` + SourcePath string `json:"source_path"` + SourceSHA256 string `json:"source_sha256"` + DependencyLockPath string `json:"dependency_lock_path"` + DependencyLockSHA256 string `json:"dependency_lock_sha256"` + ProgramFingerprint string `json:"program_fingerprint"` + GeneratedSkills map[string]string `json:"generated_skills"` + Program Document `json:"program"` +} + +type ArtifactInput struct { + CompilerVersion string + SourcePath string + Source []byte + DependencyLockPath string + DependencyLock []byte + GeneratedSkills map[string][]byte +} + +// ProjectionGenerator derives repository projections from compiled executable +// semantics. Artifact digests are evidence about this trusted derivation, not +// an alternative authority for projection contents. +type ProjectionGenerator func(Compiled) (map[string][]byte, error) + +func NewArtifact(compiled Compiled, input ArtifactInput) (Artifact, []byte, error) { + if input.CompilerVersion == "" || !safeRelative(input.SourcePath) || !safeRelative(input.DependencyLockPath) { + return Artifact{}, nil, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: compiler and relative source/lock paths are required") + } + skills := make(map[string]string, len(input.GeneratedSkills)) + for path, raw := range input.GeneratedSkills { + if !safeGeneratedSkillPath(filepath.ToSlash(path)) { + return Artifact{}, nil, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid generated skill path %q", path) + } + skills[filepath.ToSlash(path)] = digest(raw) + } + artifact := Artifact{ + SchemaVersion: ArtifactSchemaVersion, CompilerVersion: input.CompilerVersion, + SourcePath: filepath.ToSlash(input.SourcePath), SourceSHA256: digest(input.Source), + DependencyLockPath: filepath.ToSlash(input.DependencyLockPath), DependencyLockSHA256: digest(input.DependencyLock), + ProgramFingerprint: compiled.Fingerprint, GeneratedSkills: skills, Program: compiled.Document, + } + encoded, err := json.MarshalIndent(artifact, "", " ") + if err != nil { + return Artifact{}, nil, err + } + return artifact, append(encoded, '\n'), nil +} + +func LoadArtifact(source io.Reader) (Artifact, error) { + raw, err := readLimited(source, 32<<20, "CONTROL_PROGRAM_ARTIFACT_INVALID: input exceeds 32 MiB") + if err != nil { + return Artifact{}, err + } + if err := rejectDuplicateKeys(raw); err != nil { + return Artifact{}, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var artifact Artifact + if err := decoder.Decode(&artifact); err != nil { + return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: %w", err) + } + if err := requireEOF(decoder); err != nil { + return Artifact{}, err + } + if artifact.SchemaVersion != ArtifactSchemaVersion || artifact.CompilerVersion == "" || !safeRelative(artifact.SourcePath) || !safeRelative(artifact.DependencyLockPath) || len(artifact.ProgramFingerprint) != 64 || artifact.GeneratedSkills == nil { + return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: artifact envelope is incomplete") + } + for path, fingerprint := range artifact.GeneratedSkills { + if !safeGeneratedSkillPath(path) || len(fingerprint) != 64 { + return Artifact{}, fmt.Errorf("CONTROL_PROGRAM_ARTIFACT_INVALID: invalid generated skill binding") + } + } + return artifact, nil +} + +func safeGeneratedSkillPath(value string) bool { + if !safeRelative(value) { + return false + } + parts := strings.Split(value, "/") + if len(parts) == 4 && parts[0] == ".agents" && parts[1] == "skills" && validID(parts[2]) && parts[3] == "SKILL.md" { + return true + } + if len(parts) == 5 && parts[0] == ".agents" && parts[1] == "skills" && validID(parts[2]) && parts[3] == "agents" && parts[4] == "openai.yaml" { + return true + } + return len(parts) == 4 && parts[0] == ".claude" && parts[1] == "skills" && validID(parts[2]) && parts[3] == "SKILL.md" +} + +func CheckArtifact(repository string, artifact Artifact, compilerVersion string, resolver BindingResolver, generate ProjectionGenerator) (Compiled, error) { + if artifact.CompilerVersion != compilerVersion { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: compiler version changed") + } + repository, err := filepath.Abs(repository) + if err != nil { + return Compiled{}, err + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + return Compiled{}, err + } + checks := []struct{ path, expected, label string }{ + {artifact.SourcePath, artifact.SourceSHA256, "source"}, + {artifact.DependencyLockPath, artifact.DependencyLockSHA256, "dependency lock"}, + } + for _, check := range checks { + raw, readErr := readRepositoryFile(repository, check.path) + if readErr != nil || digest(raw) != check.expected { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: %s does not match artifact", check.label) + } + } + compiled, err := Compile(artifact.Program, resolver) + if err != nil { + return Compiled{}, err + } + if compiled.Fingerprint != artifact.ProgramFingerprint { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: program fingerprint does not match artifact") + } + expected := map[string][]byte{} + if generate != nil { + expected, err = generate(compiled) + if err != nil { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: regenerate projections: %w", err) + } + } + if len(expected) != len(artifact.GeneratedSkills) { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill set does not match compiled program") + } + paths := make([]string, 0, len(expected)) + for path := range expected { + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + if !safeGeneratedSkillPath(path) || artifact.GeneratedSkills[path] != digest(expected[path]) { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill %s is not derived from compiled program", path) + } + raw, readErr := readRepositoryFile(repository, path) + if readErr != nil || !bytes.Equal(raw, expected[path]) { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_STALE: generated skill %s does not match compiled program", path) + } + } + return compiled, nil +} + +func digest(value []byte) string { sum := sha256.Sum256(value); return hex.EncodeToString(sum[:]) } +func safeRelative(value string) bool { + if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\\`) { + return false + } + clean := filepath.Clean(filepath.FromSlash(value)) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) && filepath.ToSlash(clean) == value +} + +func readRepositoryFile(repository, relative string) ([]byte, error) { + path := filepath.Join(repository, filepath.FromSlash(relative)) + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("bound path is not a regular repository file") + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + rel, err := filepath.Rel(repository, resolved) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("bound path escapes repository") + } + return os.ReadFile(resolved) +} diff --git a/boatstack/controlprogram/canonical.go b/boatstack/controlprogram/canonical.go new file mode 100644 index 0000000..5ffb243 --- /dev/null +++ b/boatstack/controlprogram/canonical.go @@ -0,0 +1,629 @@ +package controlprogram + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "regexp" + "sort" +) + +var semanticID = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`) +var semanticReference = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*$`) + +type Compiled struct { + Document Document + Canonical []byte + Fingerprint string +} + +func Load(source io.Reader, resolver BindingResolver) (Compiled, error) { + raw, err := readLimited(source, 16<<20, "CONTROL_PROGRAM_INVALID: input exceeds 16 MiB") + if err != nil { + return Compiled{}, err + } + if err := rejectDuplicateKeys(raw); err != nil { + return Compiled{}, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var document Document + if err := decoder.Decode(&document); err != nil { + return Compiled{}, fmt.Errorf("CONTROL_PROGRAM_INVALID: %w", err) + } + if err := requireEOF(decoder); err != nil { + return Compiled{}, err + } + return Compile(document, resolver) +} + +func readLimited(source io.Reader, limit int64, oversized string) ([]byte, error) { + raw, err := io.ReadAll(io.LimitReader(source, limit+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) > limit { + return nil, fmt.Errorf("%s", oversized) + } + return raw, nil +} + +func Compile(document Document, resolver BindingResolver) (Compiled, error) { + if document.SchemaVersion != SchemaVersion { + return Compiled{}, invalid("schema_version", "unsupported schema") + } + if !validID(document.Program.ID) || document.Program.Version == "" { + return Compiled{}, invalid("program", "id and version are required") + } + var err error + if document.Declarations.Capabilities, err = normalizedReferenceSet("declarations.capabilities", document.Declarations.Capabilities); err != nil { + return Compiled{}, err + } + if document.Declarations.Authorities, err = normalizedReferenceSet("declarations.authorities", document.Declarations.Authorities); err != nil { + return Compiled{}, err + } + if document.Declarations.Effects, err = normalizedReferenceSet("declarations.effects", document.Declarations.Effects); err != nil { + return Compiled{}, err + } + if document.Declarations.Verifiers, err = normalizedReferenceSet("declarations.verifiers", document.Declarations.Verifiers); err != nil { + return Compiled{}, err + } + if document.Declarations.InputResolvers, err = normalizedReferenceSet("declarations.input_resolvers", document.Declarations.InputResolvers); err != nil { + return Compiled{}, err + } + + facets := map[string]Facet{} + for index := range document.Facets { + facet := &document.Facets[index] + if !validID(facet.ID) || (facet.Kind != "enum" && facet.Kind != "string" && facet.Kind != "boolean") { + return Compiled{}, invalid(fmt.Sprintf("facets[%d]", index), "invalid facet") + } + if _, exists := facets[facet.ID]; exists { + return Compiled{}, invalid("facets", "duplicate "+facet.ID) + } + facet.Values, err = normalizedValues("facets."+facet.ID+".values", facet.Values) + if err != nil { + return Compiled{}, err + } + if facet.Kind == "enum" && len(facet.Values) == 0 { + return Compiled{}, invalid("facets."+facet.ID, "enum values are required") + } + if facet.Kind != "enum" && len(facet.Values) != 0 { + return Compiled{}, invalid("facets."+facet.ID, "values are allowed only for enum facets") + } + facets[facet.ID] = *facet + } + if len(facets) == 0 { + return Compiled{}, invalid("facets", "at least one facet is required") + } + sort.Slice(document.Facets, func(i, j int) bool { return document.Facets[i].ID < document.Facets[j].ID }) + + if err := normalizeEvidence(&document, facets); err != nil { + return Compiled{}, err + } + operators, err := normalizeOperators(&document, facets, resolver) + if err != nil { + return Compiled{}, err + } + if err := normalizeTransitions(&document, facets, operators); err != nil { + return Compiled{}, err + } + if err := normalizeTargetsAndEntries(&document, facets); err != nil { + return Compiled{}, err + } + + semantic := stripDescriptions(document) + canonical, err := json.Marshal(semantic) + if err != nil { + return Compiled{}, err + } + digest := sha256.Sum256(canonical) + fingerprint := hex.EncodeToString(digest[:]) + pretty, err := json.MarshalIndent(document, "", " ") + if err != nil { + return Compiled{}, err + } + pretty = append(pretty, '\n') + return Compiled{Document: document, Canonical: pretty, Fingerprint: fingerprint}, nil +} + +func normalizeEvidence(document *Document, facets map[string]Facet) error { + seen := map[string]bool{} + for i := range document.Evidence { + value := document.Evidence[i] + if !validID(value.ID) || !validID(value.Kind) || facets[value.Subject].ID == "" { + return invalid(fmt.Sprintf("evidence[%d]", i), "invalid evidence relation") + } + if seen[value.ID] { + return invalid("evidence", "duplicate "+value.ID) + } + seen[value.ID] = true + } + sort.Slice(document.Evidence, func(i, j int) bool { return document.Evidence[i].ID < document.Evidence[j].ID }) + return nil +} + +func normalizeOperators(document *Document, facets map[string]Facet, resolver BindingResolver) (map[string]Operator, error) { + seen := map[string]Operator{} + for i := range document.Operators { + op := &document.Operators[i] + var expectedBinding *Operator + if !validID(op.ID) || seen[op.ID].ID != "" { + return nil, invalid(fmt.Sprintf("operators[%d].id", i), "invalid or duplicate operator") + } + if op.Binding != nil { + if resolver == nil { + return nil, invalid("operators."+op.ID+".binding", "no binding resolver is available") + } + compiledBinding := op.Binding.Fingerprint != "" + if !compiledBinding && hasInlineSemantics(*op) { + return nil, invalid("operators."+op.ID, "trusted bindings cannot be overridden") + } + resolved, err := resolver.ResolveOperator(op.Binding.Reference, op.Binding.Version) + if err != nil { + return nil, invalid("operators."+op.ID+".binding", err.Error()) + } + if len(resolved.Fingerprint) != 64 { + return nil, invalid("operators."+op.ID+".binding", "binding fingerprint is invalid") + } + if compiledBinding { + if op.Binding.Fingerprint != resolved.Fingerprint { + return nil, invalid("operators."+op.ID+".binding", "binding fingerprint drift") + } + expected := Operator{ID: op.ID, Binding: &OperatorBinding{Reference: op.Binding.Reference, Version: op.Binding.Version, Fingerprint: resolved.Fingerprint}, Capabilities: resolved.Capabilities, Authority: resolved.Authority, Effects: resolved.Effects, Verifier: resolved.Verifier, Recovery: resolved.Recovery, StateEffect: &resolved.StateEffect} + expectedBinding = &expected + } else { + op.Binding.Fingerprint = resolved.Fingerprint + op.Capabilities, op.Authority, op.Effects = resolved.Capabilities, resolved.Authority, resolved.Effects + op.Verifier, op.Recovery, op.StateEffect = resolved.Verifier, resolved.Recovery, &resolved.StateEffect + } + } + var err error + if op.Capabilities, err = normalizedReferenceSet("operators."+op.ID+".capabilities", op.Capabilities); err != nil { + return nil, err + } + if op.Authority, err = normalizedReferenceSet("operators."+op.ID+".authority", op.Authority); err != nil { + return nil, err + } + if op.Effects, err = normalizedReferenceSet("operators."+op.ID+".effects", op.Effects); err != nil { + return nil, err + } + if op.Binding != nil { + document.Declarations.Capabilities = union(document.Declarations.Capabilities, op.Capabilities) + document.Declarations.Authorities = union(document.Declarations.Authorities, op.Authority) + document.Declarations.Effects = union(document.Declarations.Effects, op.Effects) + document.Declarations.Verifiers = union(document.Declarations.Verifiers, []string{op.Verifier}) + } + if missing := firstUndeclared(op.Capabilities, document.Declarations.Capabilities); missing != "" { + return nil, invalid("operators."+op.ID+".capabilities", "undeclared "+missing) + } + if missing := firstUndeclared(op.Authority, document.Declarations.Authorities); missing != "" { + return nil, invalid("operators."+op.ID+".authority", "undeclared "+missing) + } + if missing := firstUndeclared(op.Effects, document.Declarations.Effects); missing != "" { + return nil, invalid("operators."+op.ID+".effects", "undeclared "+missing) + } + if op.Verifier == "" || !contains(document.Declarations.Verifiers, op.Verifier) { + return nil, invalid("operators."+op.ID+".verifier", "undeclared verifier") + } + if len(op.Effects) != 0 && op.Recovery == "" { + return nil, invalid("operators."+op.ID+".recovery", "effectful operator requires recovery") + } + if op.StateEffect == nil { + return nil, invalid("operators."+op.ID+".state_effect", "state effect is required") + } + if err := normalizeStateEffect(op.StateEffect, facets); err != nil { + return nil, invalid("operators."+op.ID+".state_effect", err.Error()) + } + if expectedBinding != nil { + expectedBinding.Capabilities, _ = normalizedReferenceSet("binding.capabilities", expectedBinding.Capabilities) + expectedBinding.Authority, _ = normalizedReferenceSet("binding.authority", expectedBinding.Authority) + expectedBinding.Effects, _ = normalizedReferenceSet("binding.effects", expectedBinding.Effects) + _ = normalizeStateEffect(expectedBinding.StateEffect, facets) + if !sameOperatorSemantics(*op, *expectedBinding) { + return nil, invalid("operators."+op.ID, "compiled binding semantics drift") + } + } + seen[op.ID] = *op + } + var err error + if document.Declarations.Capabilities, err = normalizedReferenceSet("declarations.capabilities", document.Declarations.Capabilities); err != nil { + return nil, err + } + if document.Declarations.Authorities, err = normalizedReferenceSet("declarations.authorities", document.Declarations.Authorities); err != nil { + return nil, err + } + if document.Declarations.Effects, err = normalizedReferenceSet("declarations.effects", document.Declarations.Effects); err != nil { + return nil, err + } + if document.Declarations.Verifiers, err = normalizedReferenceSet("declarations.verifiers", document.Declarations.Verifiers); err != nil { + return nil, err + } + if len(seen) == 0 { + return nil, invalid("operators", "at least one operator is required") + } + sort.Slice(document.Operators, func(i, j int) bool { return document.Operators[i].ID < document.Operators[j].ID }) + return seen, nil +} + +func normalizeTransitions(document *Document, facets map[string]Facet, operators map[string]Operator) error { + seen := map[string]bool{} + for i := range document.Transitions { + value := &document.Transitions[i] + if !validID(value.ID) || seen[value.ID] || operators[value.Operator].ID == "" { + return invalid(fmt.Sprintf("transitions[%d]", i), "invalid transition or operator reference") + } + seen[value.ID] = true + if err := normalizePredicate(&value.Guard, facets); err != nil { + return invalid("transitions."+value.ID+".guard", err.Error()) + } + if err := normalizePredicate(&value.Target, facets); err != nil { + return invalid("transitions."+value.ID+".target", err.Error()) + } + } + if len(seen) == 0 { + return invalid("transitions", "at least one transition is required") + } + for _, operator := range operators { + if operator.Binding == nil && len(operator.Effects) != 0 && !seen[operator.Recovery] { + return invalid("operators."+operator.ID+".recovery", "unknown recovery transition "+operator.Recovery) + } + } + sort.Slice(document.Transitions, func(i, j int) bool { return document.Transitions[i].ID < document.Transitions[j].ID }) + return nil +} + +func normalizeTargetsAndEntries(document *Document, facets map[string]Facet) error { + targets := map[string]bool{} + for i := range document.Targets { + value := &document.Targets[i] + if !validID(value.ID) || targets[value.ID] { + return invalid(fmt.Sprintf("targets[%d].id", i), "invalid or duplicate target") + } + targets[value.ID] = true + if err := normalizePredicate(&value.Predicate, facets); err != nil { + return invalid("targets."+value.ID, err.Error()) + } + } + if len(targets) == 0 { + return invalid("targets", "at least one target is required") + } + sort.Slice(document.Targets, func(i, j int) bool { return document.Targets[i].ID < document.Targets[j].ID }) + entries := map[string]bool{} + for i := range document.Entries { + entry := &document.Entries[i] + if !validID(entry.ID) || entries[entry.ID] || !targets[entry.Target] { + return invalid(fmt.Sprintf("entries[%d]", i), "invalid entry or target reference") + } + entries[entry.ID] = true + inputs := map[string]bool{} + for j := range entry.Inputs { + input := &entry.Inputs[j] + if !validID(input.ID) || !validID(input.Type) || inputs[input.ID] || (input.Resolver != "" && !contains(document.Declarations.InputResolvers, input.Resolver)) || (len(input.Config) != 0 && !json.Valid(input.Config)) { + return invalid(fmt.Sprintf("entries.%s.inputs[%d]", entry.ID, j), "invalid input or resolver") + } + if len(input.Config) != 0 { + if err := rejectDuplicateKeys(input.Config); err != nil { + return invalid(fmt.Sprintf("entries.%s.inputs[%d].config", entry.ID, j), err.Error()) + } + var decoded any + if err := json.Unmarshal(input.Config, &decoded); err != nil { + return invalid(fmt.Sprintf("entries.%s.inputs[%d].config", entry.ID, j), err.Error()) + } + canonical, err := json.Marshal(decoded) + if err != nil { + return err + } + input.Config = canonical + } + inputs[input.ID] = true + } + sort.Slice(entry.Inputs, func(i, j int) bool { return entry.Inputs[i].ID < entry.Inputs[j].ID }) + } + if len(entries) == 0 { + return invalid("entries", "at least one entry is required") + } + sort.Slice(document.Entries, func(i, j int) bool { return document.Entries[i].ID < document.Entries[j].ID }) + return nil +} + +func normalizeStateEffect(effect *StateEffect, facets map[string]Facet) error { + if effect.Kind != "assignments" && effect.Kind != "native" { + return fmt.Errorf("invalid kind") + } + if effect.Kind == "native" { + if effect.NativeHandler == "" || len(effect.Assignments) != 0 { + return fmt.Errorf("native effect requires only a handler") + } + } else if effect.NativeHandler != "" || len(effect.Assignments) == 0 { + return fmt.Errorf("assignment effect requires assignments") + } + seenPre, seenAssign := map[string]bool{}, map[string]bool{} + for i := range effect.Preconditions { + value := &effect.Preconditions[i] + if facets[value.Facet].ID == "" || seenPre[value.Facet] { + return fmt.Errorf("invalid precondition facet") + } + seenPre[value.Facet] = true + var err error + value.Values, err = normalizedValues("precondition.values", value.Values) + if err != nil || len(value.Values) == 0 { + return fmt.Errorf("invalid precondition values") + } + facet := facets[value.Facet] + if facet.Kind == "enum" { + for _, item := range value.Values { + if !contains(facet.Values, item) { + return fmt.Errorf("precondition value %q is not declared by facet %q", item, value.Facet) + } + } + } + } + for i := range effect.Assignments { + value := &effect.Assignments[i] + if facets[value.Facet].ID == "" || seenAssign[value.Facet] { + return fmt.Errorf("invalid assignment facet") + } + seenAssign[value.Facet] = true + if (value.Value == nil) == (value.ValueFrom == nil) { + return fmt.Errorf("assignment requires exactly one value source") + } + if value.Value != nil { + facet := facets[value.Facet] + if facet.Kind == "enum" && !contains(facet.Values, *value.Value) { + return fmt.Errorf("assignment value %q is not declared by facet %q", *value.Value, value.Facet) + } + } + if value.ValueFrom != nil { + count := 0 + for _, source := range []string{value.ValueFrom.Parameter, value.ValueFrom.Admission, value.ValueFrom.Invocation} { + if source != "" { + count++ + } + } + if count != 1 { + return fmt.Errorf("assignment reference requires exactly one source") + } + } + } + sort.Slice(effect.Preconditions, func(i, j int) bool { return effect.Preconditions[i].Facet < effect.Preconditions[j].Facet }) + sort.Slice(effect.Assignments, func(i, j int) bool { return effect.Assignments[i].Facet < effect.Assignments[j].Facet }) + return nil +} + +func normalizePredicate(value *Predicate, facets map[string]Facet) error { + variants := 0 + if len(value.All) != 0 { + variants++ + } + if len(value.Any) != 0 { + variants++ + } + if value.Not != nil { + variants++ + } + if value.Fact != nil { + variants++ + } + if value.True != nil { + variants++ + } + if variants != 1 { + return fmt.Errorf("predicate must contain exactly one AST node") + } + children := value.All + if len(value.Any) != 0 { + children = value.Any + } + for i := range children { + if err := normalizePredicate(&children[i], facets); err != nil { + return err + } + } + if len(value.All) != 0 { + value.All = children + sortPredicates(value.All) + } + if len(value.Any) != 0 { + value.Any = children + sortPredicates(value.Any) + } + if value.Not != nil { + return normalizePredicate(value.Not, facets) + } + if value.Fact != nil { + facet, ok := facets[value.Fact.Facet] + if !ok { + return fmt.Errorf("unknown facet %q", value.Fact.Facet) + } + var err error + value.Fact.Statuses, err = normalizedValues("predicate.statuses", value.Fact.Statuses) + if err != nil { + return err + } + for _, status := range value.Fact.Statuses { + if !contains([]string{"absent", "ambiguous", "conflicting", "known", "stale", "unknown"}, status) { + return fmt.Errorf("unknown fact status %q", status) + } + } + value.Fact.Values, err = normalizedValues("predicate.values", value.Fact.Values) + if err != nil { + return err + } + if facet.Kind == "enum" { + for _, item := range value.Fact.Values { + if !contains(facet.Values, item) { + return fmt.Errorf("unknown value %q for facet %q", item, facet.ID) + } + } + } + } + return nil +} + +func sortPredicates(values []Predicate) { + sort.Slice(values, func(i, j int) bool { + left, _ := json.Marshal(values[i]) + right, _ := json.Marshal(values[j]) + return bytes.Compare(left, right) < 0 + }) +} + +func stripDescriptions(value Document) Document { + value.Facets = append([]Facet(nil), value.Facets...) + value.Evidence = append([]Evidence(nil), value.Evidence...) + value.Operators = append([]Operator(nil), value.Operators...) + value.Transitions = append([]Transition(nil), value.Transitions...) + value.Targets = append([]Target(nil), value.Targets...) + value.Entries = append([]Entry(nil), value.Entries...) + value.Description, value.Program.Description = "", "" + for i := range value.Facets { + value.Facets[i].Description = "" + } + for i := range value.Evidence { + value.Evidence[i].Description = "" + } + for i := range value.Operators { + value.Operators[i].Description = "" + } + for i := range value.Transitions { + value.Transitions[i].Description = "" + } + for i := range value.Targets { + value.Targets[i].Description = "" + } + for i := range value.Entries { + value.Entries[i].Description = "" + } + return value +} + +func hasInlineSemantics(value Operator) bool { + return len(value.Capabilities) != 0 || len(value.Authority) != 0 || len(value.Effects) != 0 || value.Verifier != "" || value.Recovery != "" || value.StateEffect != nil +} +func sameOperatorSemantics(left, right Operator) bool { + left.Description, right.Description = "", "" + leftRaw, leftErr := json.Marshal(left) + rightRaw, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && bytes.Equal(leftRaw, rightRaw) +} +func validID(value string) bool { return semanticID.MatchString(value) } +func contains(values []string, wanted string) bool { + i := sort.SearchStrings(values, wanted) + return i < len(values) && values[i] == wanted +} +func firstUndeclared(values, declarations []string) string { + for _, value := range values { + if !contains(declarations, value) { + return value + } + } + return "" +} +func normalizedSet(field string, values []string) ([]string, error) { + for _, value := range values { + if !validID(value) { + return nil, invalid(field, "invalid declaration "+value) + } + } + return normalizedValues(field, values) +} +func normalizedReferenceSet(field string, values []string) ([]string, error) { + for _, value := range values { + if !semanticReference.MatchString(value) { + return nil, invalid(field, "invalid declaration "+value) + } + } + return normalizedValues(field, values) +} +func normalizedValues(field string, values []string) ([]string, error) { + out := append([]string(nil), values...) + sort.Strings(out) + for i, v := range out { + if v == "" || (i > 0 && out[i-1] == v) { + return nil, invalid(field, "empty or duplicate value") + } + } + return out, nil +} +func union(left, right []string) []string { + seen := make(map[string]bool, len(left)+len(right)) + for _, value := range left { + seen[value] = true + } + for _, value := range right { + if value != "" { + seen[value] = true + } + } + result := make([]string, 0, len(seen)) + for value := range seen { + result = append(result, value) + } + sort.Strings(result) + return result +} +func invalid(field, detail string) error { + return fmt.Errorf("CONTROL_PROGRAM_INVALID: %s: %s", field, detail) +} +func requireEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return invalid("document", "trailing JSON") + } + return nil +} + +func rejectDuplicateKeys(raw []byte) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + var walk func() error + walk = func() error { + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok { + return nil + } + switch delimiter { + case '{': + seen := map[string]bool{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key := keyToken.(string) + if seen[key] { + return invalid("document", "duplicate field "+key) + } + seen[key] = true + if err := walk(); err != nil { + return err + } + } + _, err = decoder.Token() + return err + case '[': + for decoder.More() { + if err := walk(); err != nil { + return err + } + } + _, err = decoder.Token() + return err + } + return nil + } + if err := walk(); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + return invalid("document", "trailing JSON") + } + return nil +} diff --git a/boatstack/controlprogram/canonical_test.go b/boatstack/controlprogram/canonical_test.go new file mode 100644 index 0000000..273a17c --- /dev/null +++ b/boatstack/controlprogram/canonical_test.go @@ -0,0 +1,241 @@ +package controlprogram_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" +) + +func incidentProgram() controlprogram.Document { + mitigated := "mitigated" + return controlprogram.Document{ + SchemaVersion: controlprogram.SchemaVersion, + Program: controlprogram.Program{ID: "incident-response", Version: "1", Description: "human text"}, + Description: "incident control program", + Declarations: controlprogram.Declarations{ + Capabilities: []string{"service.restart"}, Authorities: []string{"incident-commander"}, + Effects: []string{"service.restart"}, Verifiers: []string{"healthcheck"}, InputResolvers: []string{"incident.input"}, + }, + Facets: []controlprogram.Facet{ + {ID: "service", Kind: "enum", Values: []string{"healthy", "degraded"}, Description: "service health"}, + {ID: "incident", Kind: "enum", Values: []string{"open", "mitigated"}}, + }, + Evidence: []controlprogram.Evidence{{ID: "healthcheck", Subject: "service", Kind: "observation", Description: "observed health"}}, + Operators: []controlprogram.Operator{{ + ID: "restart", Capabilities: []string{"service.restart"}, Authority: []string{"incident-commander"}, + Effects: []string{"service.restart"}, Verifier: "healthcheck", Recovery: "restart", + Description: "restart the service", + StateEffect: &controlprogram.StateEffect{Kind: "assignments", Assignments: []controlprogram.StateAssignment{{Facet: "incident", Value: &mitigated}}}, + }}, + Transitions: []controlprogram.Transition{{ + ID: "restart", Operator: "restart", Priority: 10, + Guard: fact("incident", "open"), Target: fact("incident", "mitigated"), Description: "restart service", + }}, + Targets: []controlprogram.Target{{ID: "mitigated", Predicate: fact("incident", "mitigated"), Description: "incident mitigated"}}, + Entries: []controlprogram.Entry{{ID: "respond", Target: "mitigated", Description: "respond to incident", Inputs: []controlprogram.EntryInput{{ID: "incident", Type: "json", Required: true, Resolver: "incident.input", Config: json.RawMessage(`{"b":2,"a":1}`)}}}}, + } +} + +func fact(facet, value string) controlprogram.Predicate { + return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}, Values: []string{value}}} +} + +func clone(t *testing.T, value controlprogram.Document) controlprogram.Document { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var result controlprogram.Document + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + return result +} + +func TestDomainNeutralIncidentProgramCompiles(t *testing.T) { + // control-law: generic-control-program-ir-has-no-software-delivery-dependency + compiled, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + if len(compiled.Fingerprint) != 64 || !bytes.Contains(compiled.Canonical, []byte(`"incident-response"`)) { + t.Fatalf("compiled incident program = %#v", compiled) + } + if compiled.Document.Description == "" || compiled.Document.Program.Description == "" || + compiled.Document.Facets[1].Description == "" || compiled.Document.Evidence[0].Description == "" || + compiled.Document.Operators[0].Description == "" || compiled.Document.Transitions[0].Description == "" || + compiled.Document.Targets[0].Description == "" || compiled.Document.Entries[0].Description == "" { + t.Fatalf("compilation removed declared descriptions: %#v", compiled.Document) + } +} + +func TestCanonicalFingerprintIgnoresOrderingAndDescriptions(t *testing.T) { + // control-law: canonical-program-identity-binds-executable-semantics-only + base, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + equivalent := clone(t, incidentProgram()) + equivalent.Description = "different prose" + equivalent.Program.Description = "different program prose" + equivalent.Transitions[0].Description = "different transition prose" + equivalent.Facets[0], equivalent.Facets[1] = equivalent.Facets[1], equivalent.Facets[0] + equivalent.Entries[0].Inputs[0].Config = json.RawMessage(`{"a":1,"b":2}`) + equivalent.Declarations.Capabilities = append([]string(nil), equivalent.Declarations.Capabilities...) + other, err := controlprogram.Compile(equivalent, nil) + if err != nil { + t.Fatal(err) + } + if other.Fingerprint != base.Fingerprint { + t.Fatalf("equivalent fingerprints differ: %s != %s", other.Fingerprint, base.Fingerprint) + } + changed := clone(t, incidentProgram()) + changed.Transitions[0].Priority++ + semantic, err := controlprogram.Compile(changed, nil) + if err != nil { + t.Fatal(err) + } + if semantic.Fingerprint == base.Fingerprint { + t.Fatal("executable change preserved fingerprint") + } +} + +func TestStrictLoaderRejectsUnknownAndDuplicateFields(t *testing.T) { + // control-law: only-the-closed-ir-schema-crosses-the-compiler-boundary + valid, _ := json.Marshal(incidentProgram()) + unknown := bytes.Replace(valid, []byte(`"schema_version"`), []byte(`"unknown":true,"schema_version"`), 1) + if _, err := controlprogram.Load(bytes.NewReader(unknown), nil); err == nil { + t.Fatal("unknown field was accepted") + } + duplicate := bytes.Replace(valid, []byte(`"schema_version"`), []byte(`"schema_version":"control-program/v1","schema_version"`), 1) + if _, err := controlprogram.Load(bytes.NewReader(duplicate), nil); err == nil { + t.Fatal("duplicate field was accepted") + } +} + +func TestStrictLoadersRejectOversizedTrailingInput(t *testing.T) { + // control-law: size-limited-loaders-never-treat-truncation-as-eof + documentRaw, err := json.Marshal(incidentProgram()) + if err != nil { + t.Fatal(err) + } + compiled, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + _, artifactRaw, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: []byte("source"), + DependencyLockPath: "package-lock.json", DependencyLock: []byte("lock"), GeneratedSkills: map[string][]byte{}, + }) + if err != nil { + t.Fatal(err) + } + oversized := func(raw []byte, limit int) []byte { + if len(raw) >= limit { + t.Fatalf("fixture length %d exceeds limit %d", len(raw), limit) + } + result := append([]byte(nil), raw...) + result = append(result, bytes.Repeat([]byte(" "), limit-len(result))...) + return append(result, 'x') + } + if _, err := controlprogram.Load(bytes.NewReader(oversized(documentRaw, 16<<20)), nil); err == nil || !strings.Contains(err.Error(), "exceeds 16 MiB") { + t.Fatalf("oversized IR result = %v", err) + } + if _, err := controlprogram.LoadArtifact(bytes.NewReader(oversized(artifactRaw, 32<<20))); err == nil || !strings.Contains(err.Error(), "exceeds 32 MiB") { + t.Fatalf("oversized artifact result = %v", err) + } +} + +func TestCompilerRejectsUndeclaredEffectAndMissingRecovery(t *testing.T) { + for name, mutate := range map[string]func(*controlprogram.Document){ + "undeclared-effect": func(value *controlprogram.Document) { value.Operators[0].Effects = []string{"undeclared"} }, + "missing-recovery": func(value *controlprogram.Document) { value.Operators[0].Recovery = "" }, + "invalid-reference": func(value *controlprogram.Document) { value.Transitions[0].Operator = "missing" }, + "recovery-gap": func(value *controlprogram.Document) { value.Operators[0].Recovery = "missing" }, + } { + t.Run(name, func(t *testing.T) { + value := incidentProgram() + mutate(&value) + if _, err := controlprogram.Compile(value, nil); err == nil { + t.Fatal("invalid program compiled") + } + }) + } +} + +func TestArtifactBindsSourceLockSkillsAndCompiler(t *testing.T) { + // control-law: runtime-admits-only-an-exact-source-lock-artifact-projection + repository := t.TempDir() + sourcePath, lockPath, skillPath := "flow.ts", "package-lock.json", ".agents/skills/respond/SKILL.md" + source, lock, skill := []byte("source"), []byte("lock"), []byte("skill") + for path, content := range map[string][]byte{sourcePath: source, lockPath: lock, skillPath: skill} { + absolute := filepath.Join(repository, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(absolute, content, 0o600); err != nil { + t.Fatal(err) + } + } + compiled, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + artifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: sourcePath, Source: source, DependencyLockPath: lockPath, DependencyLock: lock, + GeneratedSkills: map[string][]byte{skillPath: skill}, + }) + if err != nil { + t.Fatal(err) + } + generate := func(controlprogram.Compiled) (map[string][]byte, error) { + return map[string][]byte{skillPath: skill}, nil + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, generate); err != nil { + t.Fatal(err) + } + forgedSkill := []byte("forged skill") + forgedArtifact, _, err := controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: sourcePath, Source: source, DependencyLockPath: lockPath, DependencyLock: lock, + GeneratedSkills: map[string][]byte{skillPath: forgedSkill}, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(skillPath)), forgedSkill, 0o600); err != nil { + t.Fatal(err) + } + if _, err := controlprogram.CheckArtifact(repository, forgedArtifact, "compiler-1", nil, generate); err == nil || !strings.Contains(err.Error(), "derived from compiled program") { + t.Fatalf("self-consistent forged projection result = %v", err) + } + if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(skillPath)), skill, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, sourcePath), []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := controlprogram.CheckArtifact(repository, artifact, "compiler-1", nil, generate); err == nil || !strings.Contains(err.Error(), "source") { + t.Fatalf("stale source result = %v", err) + } +} + +func TestArtifactRejectsGeneratedPathsOutsideHostSkillRoots(t *testing.T) { + compiled, err := controlprogram.Compile(incidentProgram(), nil) + if err != nil { + t.Fatal(err) + } + _, _, err = controlprogram.NewArtifact(compiled, controlprogram.ArtifactInput{ + CompilerVersion: "compiler-1", SourcePath: "flow.ts", Source: []byte("source"), + DependencyLockPath: "package-lock.json", DependencyLock: []byte("lock"), + GeneratedSkills: map[string][]byte{"README.md": []byte("delete me")}, + }) + if err == nil { + t.Fatal("artifact accepted an arbitrary generated deletion path") + } +} diff --git a/boatstack/controlprogram/frontend_conformance_test.go b/boatstack/controlprogram/frontend_conformance_test.go new file mode 100644 index 0000000..61dc8e1 --- /dev/null +++ b/boatstack/controlprogram/frontend_conformance_test.go @@ -0,0 +1,116 @@ +package controlprogram_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" +) + +func TestTypeScriptDSLAndRawIRHaveOneCanonicalFingerprint(t *testing.T) { + // control-law: every-language-frontend-lowers-to-one-canonical-program + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate frontend fixture") + } + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..")) + repositoryRoot := filepath.Dir(moduleRoot) + frontend := filepath.Join(repositoryRoot, "node_modules", ".bin", "boatstack-flow-frontend") + if runtime.GOOS == "windows" { + frontend += ".cmd" + } + if _, err := os.Stat(frontend); err != nil { + if os.Getenv("BOATSTACK_REQUIRE_FLOW_FRONTEND") == "1" { + t.Fatalf("required Flow frontend is unavailable: %v", err) + } + t.Skip("Flow frontend dependencies are not installed") + } + source := filepath.Join(moduleRoot, "testdata", "control-programs", "incident-response.flow.ts") + command := exec.Command(frontend, source) + frontendRaw, err := command.CombinedOutput() + if err != nil { + t.Fatalf("compile TypeScript fixture: %v\n%s", err, frontendRaw) + } + rawPath := filepath.Join(moduleRoot, "testdata", "control-programs", "incident-response.raw.json") + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatal(err) + } + fromTypeScript, err := controlprogram.Load(bytes.NewReader(frontendRaw), nil) + if err != nil { + t.Fatal(err) + } + fromRaw, err := controlprogram.Load(bytes.NewReader(raw), nil) + if err != nil { + t.Fatal(err) + } + if fromTypeScript.Fingerprint != fromRaw.Fingerprint { + t.Fatalf("frontend fingerprints differ: %s != %s", fromTypeScript.Fingerprint, fromRaw.Fingerprint) + } +} + +func TestTypeScriptFrontendRejectsRepositoryCodeWithoutExecutingIt(t *testing.T) { + // control-law: authoring-frontends-parse-repository-declarations-without-module-execution + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate frontend fixture") + } + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..")) + frontend := filepath.Join(filepath.Dir(moduleRoot), "node_modules", ".bin", "boatstack-flow-frontend") + if runtime.GOOS == "windows" { + frontend += ".cmd" + } + if _, err := os.Stat(frontend); err != nil { + t.Skip("Flow frontend dependencies are not installed") + } + directory := t.TempDir() + sentinel := filepath.Join(directory, "executed") + source := filepath.Join(directory, "unsafe.flow.ts") + content := "import { writeFileSync } from 'node:fs';\n" + + "writeFileSync(" + strconv.Quote(sentinel) + ", 'unsafe');\n" + + "export default { schema_version: 'control-program/v1' };\n" + if err := os.WriteFile(source, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + output, err := exec.Command(frontend, source).CombinedOutput() + if err == nil || !strings.Contains(string(output), "trusted Boatstack SDKs") { + t.Fatalf("unsafe frontend result = %v\n%s", err, output) + } + if _, statErr := os.Stat(sentinel); !os.IsNotExist(statErr) { + t.Fatalf("repository module executed: %v", statErr) + } +} + +func TestTypeScriptFrontendRejectsUnboundLocalImports(t *testing.T) { + // control-law: every-frontend-input-is-bound-or-refused + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate frontend fixture") + } + moduleRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..")) + frontend := filepath.Join(filepath.Dir(moduleRoot), "node_modules", ".bin", "boatstack-flow-frontend") + if runtime.GOOS == "windows" { + frontend += ".cmd" + } + if _, err := os.Stat(frontend); err != nil { + t.Skip("Flow frontend dependencies are not installed") + } + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, "helper.ts"), []byte("export default {}"), 0o600); err != nil { + t.Fatal(err) + } + source := filepath.Join(directory, "local.flow.ts") + if err := os.WriteFile(source, []byte("import helper from './helper';\nexport default helper;\n"), 0o600); err != nil { + t.Fatal(err) + } + output, err := exec.Command(frontend, source).CombinedOutput() + if err == nil || !strings.Contains(string(output), "trusted Boatstack SDKs") { + t.Fatalf("local import result = %v\n%s", err, output) + } +} diff --git a/boatstack/controlprogram/ir.go b/boatstack/controlprogram/ir.go new file mode 100644 index 0000000..7753215 --- /dev/null +++ b/boatstack/controlprogram/ir.go @@ -0,0 +1,153 @@ +// Package controlprogram defines Boatstack's domain-neutral Control Program IR. +// Domain packages bind the generic declarations to their own observations and +// operators; this package does not know about software delivery. +package controlprogram + +import "encoding/json" + +const SchemaVersion = "control-program/v1" + +type Document struct { + SchemaVersion string `json:"schema_version"` + Program Program `json:"program"` + Declarations Declarations `json:"declarations"` + Facets []Facet `json:"facets"` + Evidence []Evidence `json:"evidence,omitempty"` + Operators []Operator `json:"operators"` + Transitions []Transition `json:"transitions"` + Targets []Target `json:"targets"` + Entries []Entry `json:"entries"` + Description string `json:"description,omitempty"` +} + +type Program struct { + ID string `json:"id"` + Version string `json:"version"` + Description string `json:"description,omitempty"` +} + +type Declarations struct { + Capabilities []string `json:"capabilities,omitempty"` + Authorities []string `json:"authorities,omitempty"` + Effects []string `json:"effects,omitempty"` + Verifiers []string `json:"verifiers,omitempty"` + InputResolvers []string `json:"input_resolvers,omitempty"` +} + +type Facet struct { + ID string `json:"id"` + Kind string `json:"kind"` + Values []string `json:"values,omitempty"` + Description string `json:"description,omitempty"` +} + +type Evidence struct { + ID string `json:"id"` + Subject string `json:"subject"` + Kind string `json:"kind"` + Description string `json:"description,omitempty"` +} + +// Predicate is a closed AST. Exactly one node variant must be present. +type Predicate struct { + All []Predicate `json:"all,omitempty"` + Any []Predicate `json:"any,omitempty"` + Not *Predicate `json:"not,omitempty"` + Fact *FactPredicate `json:"fact,omitempty"` + True *bool `json:"true,omitempty"` +} + +type FactPredicate struct { + Facet string `json:"facet"` + Statuses []string `json:"statuses,omitempty"` + Values []string `json:"values,omitempty"` +} + +type OperatorBinding struct { + Reference string `json:"reference"` + Version string `json:"version"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type Operator struct { + ID string `json:"id"` + Binding *OperatorBinding `json:"binding,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Authority []string `json:"authority,omitempty"` + Effects []string `json:"effects,omitempty"` + Verifier string `json:"verifier,omitempty"` + Recovery string `json:"recovery,omitempty"` + StateEffect *StateEffect `json:"state_effect,omitempty"` + Description string `json:"description,omitempty"` +} + +type StateEffect struct { + Kind string `json:"kind"` + Preconditions []StatePrecondition `json:"preconditions,omitempty"` + Assignments []StateAssignment `json:"assignments,omitempty"` + NativeHandler string `json:"native_handler,omitempty"` +} + +type StatePrecondition struct { + Facet string `json:"facet"` + Values []string `json:"values"` +} + +type StateAssignment struct { + Facet string `json:"facet"` + Value *string `json:"value,omitempty"` + ValueFrom *ValueReference `json:"value_from,omitempty"` +} + +type ValueReference struct { + Parameter string `json:"parameter,omitempty"` + Admission string `json:"admission,omitempty"` + Invocation string `json:"invocation,omitempty"` +} + +type Transition struct { + ID string `json:"id"` + Operator string `json:"operator"` + Guard Predicate `json:"guard"` + Target Predicate `json:"target"` + Priority int `json:"priority"` + Description string `json:"description,omitempty"` +} + +type Target struct { + ID string `json:"id"` + Predicate Predicate `json:"predicate"` + Description string `json:"description,omitempty"` +} + +type Entry struct { + ID string `json:"id"` + Target string `json:"target"` + Inputs []EntryInput `json:"inputs,omitempty"` + Description string `json:"description,omitempty"` +} + +type EntryInput struct { + ID string `json:"id"` + Type string `json:"type"` + Required bool `json:"required"` + Resolver string `json:"resolver,omitempty"` + Config json.RawMessage `json:"config,omitempty"` +} + +// BindingResolver is the only way a domain can add trusted operator +// semantics. The compiler copies the resolved semantics into the canonical IR +// and binds their exact fingerprint. +type BindingResolver interface { + ResolveOperator(reference, version string) (ResolvedOperator, error) +} + +type ResolvedOperator struct { + Fingerprint string + Capabilities []string + Authority []string + Effects []string + Verifier string + Recovery string + StateEffect StateEffect +} diff --git a/boatstack/delivery_controller.go b/boatstack/delivery_controller.go index 945fc47..2b0243d 100644 --- a/boatstack/delivery_controller.go +++ b/boatstack/delivery_controller.go @@ -80,7 +80,7 @@ func NewDeliveryController(externalStateRoot string, program delivery.ControlPro } func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request) (surfaces.Response, error) { - response := surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation} + response := surfaces.Response{SchemaVersion: surfaces.SchemaVersion, Operation: request.Operation, ProgramID: request.ProgramID, EntryID: request.EntryID, RunID: request.FlowID} if err := request.Validate(k.clock.Now()); err != nil { response.Error = err.Error() return response, err @@ -120,6 +120,13 @@ func (k DeliveryController) Handle(ctx context.Context, request surfaces.Request if resolution.Snapshot.Fingerprint != "" { response.Snapshot = &resolution.Snapshot } + response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, resolution.Decision) + if response.Question == nil && request.FlowID != "" && len(resolution.Decision.Candidates) == 1 { + if transition, ok := k.registry.Lookup(resolution.Decision.Candidates[0]); ok { + questionDecision := supervisor.Decision{Kind: supervisor.DecisionCandidate, Transition: &transition} + response.Question = surfaces.QuestionFor(request.FlowID, resolution.Snapshot.Fingerprint, questionDecision) + } + } response.ProgramChange = programChangeFor(response.Snapshot) if resolveErr != nil { response.Error = resolveErr.Error() diff --git a/boatstack/distribution/standard.go b/boatstack/distribution/standard.go index 86bb3a2..71ac437 100644 --- a/boatstack/distribution/standard.go +++ b/boatstack/distribution/standard.go @@ -53,6 +53,16 @@ type RepositoryProgramRequest struct { // project configuration. A new value is returned per call, so concurrent // repositories never share mutable program state. func StandardProgramForRepository(ctx context.Context, request RepositoryProgramRequest) (delivery.ControlProgram, error) { + return ProgramForRepository(ctx, request, standard.Definition()) +} + +// ProgramForRepository compiles the CoreSystem with one trusted Flow runtime +// and the repository's exact configured extensions. Repository-authored IR is +// admitted by a domain adapter before it can reach this composition boundary. +func ProgramForRepository(ctx context.Context, request RepositoryProgramRequest, runtime delivery.ProgramRuntimeDefinition) (delivery.ControlProgram, error) { + if runtime == nil { + return delivery.ControlProgram{}, fmt.Errorf("repository Flow runtime is required") + } configured, settings, err := ConfiguredExtensions(ctx, request) if err != nil { return delivery.ControlProgram{}, err @@ -60,7 +70,7 @@ func StandardProgramForRepository(ctx context.Context, request RepositoryProgram extensions := append([]delivery.Extension(nil), request.Extensions...) extensions = append(extensions, configured...) return delivery.Compile(ctx, delivery.CompileRequest{ - KernelVersion: boatstack.Version, Core: core.System(), Runtime: standard.Definition(), + KernelVersion: boatstack.Version, Core: core.System(), Runtime: runtime, Extensions: extensions, Settings: settings, }) } diff --git a/boatstack/flow/softwaredelivery/bindings.go b/boatstack/flow/softwaredelivery/bindings.go new file mode 100644 index 0000000..76a2131 --- /dev/null +++ b/boatstack/flow/softwaredelivery/bindings.go @@ -0,0 +1,120 @@ +// Package softwaredelivery binds the domain-neutral Control Program IR to +// Boatstack's trusted software-delivery operators. +package softwaredelivery + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/flow/standard" +) + +const BindingPrefix = "software-delivery/" + +type Resolver struct { + transitions map[string]delivery.Transition +} + +func NewResolver(ctx context.Context) (Resolver, error) { + manifest, err := standard.Definition().RuntimeManifest(ctx) + if err != nil { + return Resolver{}, err + } + transitions := make(map[string]delivery.Transition, len(manifest.Transitions)) + for _, transition := range manifest.Transitions { + transitions[string(transition.ID)] = transition + } + return Resolver{transitions: transitions}, nil +} + +func (r Resolver) ResolveOperator(reference, version string) (controlprogram.ResolvedOperator, error) { + id, ok := strings.CutPrefix(reference, BindingPrefix) + if !ok || id == "" { + return controlprogram.ResolvedOperator{}, fmt.Errorf("unknown trusted binding %q", reference) + } + transition, ok := r.transitions[id] + if !ok { + return controlprogram.ResolvedOperator{}, fmt.Errorf("unknown software-delivery operator %q", id) + } + if version != strconv.Itoa(transition.Version) { + return controlprogram.ResolvedOperator{}, fmt.Errorf("operator %q requires binding version %d", id, transition.Version) + } + fingerprint, err := transitionFingerprint(transition) + if err != nil { + return controlprogram.ResolvedOperator{}, err + } + capabilities := make([]string, len(transition.RequiredCapabilities)) + for index, value := range transition.RequiredCapabilities { + capabilities[index] = string(value) + } + authoritySet := map[string]bool{} + for _, values := range [][]delivery.AuthorityClass{transition.Authority, transition.AuthorityAll} { + for _, value := range values { + authoritySet[string(value)] = true + } + } + authority := make([]string, 0, len(authoritySet)) + for value := range authoritySet { + authority = append(authority, value) + } + sort.Strings(authority) + effectSet := map[string]bool{} + if transition.Effect != "" { + effectSet[string(transition.Effect)] = true + } + for _, values := range [][]delivery.EffectID{transition.LocalEffects, transition.ExternalEffects} { + for _, value := range values { + effectSet[string(value)] = true + } + } + effects := make([]string, 0, len(effectSet)) + for value := range effectSet { + effects = append(effects, value) + } + sort.Strings(effects) + return controlprogram.ResolvedOperator{ + Fingerprint: fingerprint, Capabilities: capabilities, Authority: authority, Effects: effects, + Verifier: transition.Verifier, Recovery: string(transition.Interruption.Recovery), StateEffect: projectStateEffect(transition.StateEffect), + }, nil +} + +func (r Resolver) Transition(reference string) (delivery.Transition, bool) { + id, ok := strings.CutPrefix(reference, BindingPrefix) + if !ok { + return delivery.Transition{}, false + } + transition, ok := r.transitions[id] + return transition, ok +} + +func projectStateEffect(value delivery.StateEffect) controlprogram.StateEffect { + result := controlprogram.StateEffect{Kind: string(value.Kind), NativeHandler: value.NativeHandler} + for _, precondition := range value.Preconditions { + result.Preconditions = append(result.Preconditions, controlprogram.StatePrecondition{Facet: precondition.Facet, Values: append([]string(nil), precondition.Values...)}) + } + for _, assignment := range value.Assignments { + projected := controlprogram.StateAssignment{Facet: assignment.Facet, Value: assignment.Value} + if assignment.ValueFrom.Parameter != "" || assignment.ValueFrom.Admission != "" || assignment.ValueFrom.Invocation != "" { + projected.ValueFrom = &controlprogram.ValueReference{Parameter: assignment.ValueFrom.Parameter, Admission: assignment.ValueFrom.Admission, Invocation: assignment.ValueFrom.Invocation} + } + result.Assignments = append(result.Assignments, projected) + } + return result +} + +func transitionFingerprint(value delivery.Transition) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]), nil +} diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go new file mode 100644 index 0000000..f7279d5 --- /dev/null +++ b/boatstack/flow/softwaredelivery/definition.go @@ -0,0 +1,278 @@ +package softwaredelivery + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/delivery" + "github.com/operatorstack/boatstack/boatstack/flow/standard" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" +) + +// Definition is a trusted adapter. Repository IR selects bindings and adds +// conjunctive predicates; it never supplies native handlers or effects. +type Definition struct { + compiled controlprogram.Compiled + resolver Resolver +} + +func NewDefinition(compiled controlprogram.Compiled, resolver Resolver) (Definition, error) { + if _, err := ObjectiveForEntry(context.Background(), compiled, resolver, compiled.Document.Entries[0].ID); err != nil { + return Definition{}, err + } + return Definition{compiled: compiled, resolver: resolver}, nil +} + +func (d Definition) Fingerprint() string { return d.compiled.Fingerprint } + +func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntimeManifest, error) { + base, err := standard.Definition().RuntimeManifest(ctx) + if err != nil { + return delivery.ProgramRuntimeManifest{}, err + } + operatorByID := map[string]controlprogram.Operator{} + for _, operator := range d.compiled.Document.Operators { + operatorByID[operator.ID] = operator + } + objectives := map[model.ObjectiveKind]bool{} + contracts := map[model.ObjectiveKind]delivery.ObjectiveContract{} + for _, entry := range d.compiled.Document.Entries { + kind, contract, objectiveErr := objectiveContractForEntry(d.compiled, base, entry.ID) + if objectiveErr != nil { + return delivery.ProgramRuntimeManifest{}, objectiveErr + } + objectives[kind], contracts[kind] = true, contract + } + + selected := make([]delivery.Transition, 0, len(d.compiled.Document.Transitions)) + seen := map[delivery.TransitionID]bool{} + for _, declaration := range d.compiled.Document.Transitions { + operator := operatorByID[declaration.Operator] + if operator.Binding == nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("software-delivery transition %q requires a trusted binding", declaration.ID) + } + transition, ok := d.resolver.Transition(operator.Binding.Reference) + if !ok || seen[transition.ID] { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("software-delivery binding %q is unknown or reused", operator.Binding.Reference) + } + if string(transition.ID) != declaration.ID { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q does not match trusted binding identity %q", declaration.ID, transition.ID) + } + seen[transition.ID] = true + guard, predicateErr := conjunctiveConditions(declaration.Guard) + if predicateErr != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q guard does not strengthen the trusted binding: %w", declaration.ID, predicateErr) + } + target, predicateErr := conjunctiveConditions(declaration.Target) + if predicateErr != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q target does not strengthen the trusted binding: %w", declaration.ID, predicateErr) + } + if targetErr := requireImpliedTarget(transition.TargetConditions, target); targetErr != nil { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q target exceeds the trusted binding: %w", declaration.ID, targetErr) + } + transition.SourceConditions = append(transition.SourceConditions, guard...) + transition.TargetConditions = append(transition.TargetConditions, target...) + transition.Priority = declaration.Priority + trustedObjectives := append([]model.ObjectiveKind(nil), transition.ObjectiveKinds...) + transition.ObjectiveKinds = transition.ObjectiveKinds[:0] + for objective := range objectives { + if containsAll(trustedObjectives, []model.ObjectiveKind{objective}) { + transition.ObjectiveKinds = append(transition.ObjectiveKinds, objective) + } + } + if len(transition.ObjectiveKinds) == 0 { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q supports none of the declared entry objectives", declaration.ID) + } + sort.Slice(transition.ObjectiveKinds, func(i, j int) bool { return transition.ObjectiveKinds[i] < transition.ObjectiveKinds[j] }) + selected = append(selected, transition) + } + if len(selected) == 0 { + return delivery.ProgramRuntimeManifest{}, fmt.Errorf("software-delivery Flow selects no transitions") + } + resources, effects, verifiers, recoveries := declarations(selected) + capabilities := []delivery.Capability{delivery.CapabilityHumanApprove} + for index := range selected { + selected[index].RequiredCapabilities = delivery.KernelEffectCapabilities(selected[index]) + capabilities = delivery.UnionCapabilities(capabilities, selected[index].RequiredCapabilities) + } + supported := make([]model.ObjectiveKind, 0, len(objectives)) + objectiveContracts := make([]delivery.ObjectiveContract, 0, len(contracts)) + for objective := range objectives { + supported = append(supported, objective) + objectiveContracts = append(objectiveContracts, contracts[objective]) + } + sort.Slice(supported, func(i, j int) bool { return supported[i] < supported[j] }) + sort.Slice(objectiveContracts, func(i, j int) bool { return objectiveContracts[i].ObjectiveKind < objectiveContracts[j].ObjectiveKind }) + settings, _ := json.Marshal(map[string]string{"flow_id": d.compiled.Document.Program.ID, "flow_fingerprint": d.compiled.Fingerprint}) + base.Version = standard.Version + "+flow." + d.compiled.Fingerprint[:12] + base.SupportedObjectives = supported + base.ObjectiveContracts = objectiveContracts + base.Transitions, base.OwnedResources, base.Effects, base.Verifiers = selected, resources, effects, verifiers + base.Capabilities, base.RecoveryTransitions, base.Settings = capabilities, recoveries, settings + return base, nil +} + +func ObjectiveForEntry(ctx context.Context, compiled controlprogram.Compiled, resolver Resolver, entryID string) (delivery.ObjectiveKind, error) { + base, err := standard.Definition().RuntimeManifest(ctx) + if err != nil { + return "", err + } + kind, _, err := objectiveContractForEntry(compiled, base, entryID) + return kind, err +} + +func objectiveContractForEntry(compiled controlprogram.Compiled, base delivery.ProgramRuntimeManifest, entryID string) (model.ObjectiveKind, delivery.ObjectiveContract, error) { + var targetID string + for _, entry := range compiled.Document.Entries { + if entry.ID == entryID { + targetID = entry.Target + break + } + } + if targetID == "" { + return "", delivery.ObjectiveContract{}, fmt.Errorf("unknown Flow entry %q", entryID) + } + var predicate controlprogram.Predicate + for _, target := range compiled.Document.Targets { + if target.ID == targetID { + predicate = target.Predicate + break + } + } + conditions, err := conjunctiveConditions(predicate) + if err != nil { + return "", delivery.ObjectiveContract{}, fmt.Errorf("entry %q target is not a software-delivery marked state: %w", entryID, err) + } + normalizedTarget := canonicalConditions(conditions) + for _, contract := range base.ObjectiveContracts { + if bytes.Equal(normalizedTarget, canonicalConditions(contract.Conditions)) { + return contract.ObjectiveKind, contract, nil + } + } + return "", delivery.ObjectiveContract{}, fmt.Errorf("entry %q target does not match a trusted software-delivery marked state", entryID) +} + +func conjunctiveConditions(predicate controlprogram.Predicate) ([]delivery.FacetCondition, error) { + if predicate.True != nil { + if !*predicate.True { + return nil, fmt.Errorf("false predicates are not admissible") + } + return nil, nil + } + if predicate.Fact != nil { + statuses := make([]model.FactStatus, len(predicate.Fact.Statuses)) + for index, status := range predicate.Fact.Statuses { + statuses[index] = model.FactStatus(status) + } + return []delivery.FacetCondition{{Facet: model.FacetName(predicate.Fact.Facet), Statuses: statuses, Values: append([]string(nil), predicate.Fact.Values...)}}, nil + } + if len(predicate.All) != 0 { + var result []delivery.FacetCondition + for _, child := range predicate.All { + conditions, err := conjunctiveConditions(child) + if err != nil { + return nil, err + } + result = append(result, conditions...) + } + return result, nil + } + return nil, fmt.Errorf("only true, fact, and all predicates are supported") +} + +func canonicalConditions(values []delivery.FacetCondition) []byte { + copy := append([]delivery.FacetCondition(nil), values...) + for index := range copy { + sort.Slice(copy[index].Statuses, func(i, j int) bool { return copy[index].Statuses[i] < copy[index].Statuses[j] }) + sort.Strings(copy[index].Values) + } + sort.Slice(copy, func(i, j int) bool { + left, _ := json.Marshal(copy[i]) + right, _ := json.Marshal(copy[j]) + return strings.Compare(string(left), string(right)) < 0 + }) + encoded, _ := json.Marshal(copy) + return encoded +} + +func requireImpliedTarget(trusted, repository []delivery.FacetCondition) error { + for _, candidate := range repository { + implied := false + for _, established := range trusted { + if established.Facet == candidate.Facet && conditionImplies(established, candidate) { + implied = true + break + } + } + if !implied { + return fmt.Errorf("facet %q is not established by the trusted operator", candidate.Facet) + } + } + return nil +} + +func conditionImplies(established, candidate delivery.FacetCondition) bool { + if !containsAll(candidate.Statuses, established.Statuses) { + return false + } + if len(candidate.Values) == 0 { + return true + } + return len(established.Values) != 0 && containsAll(candidate.Values, established.Values) +} + +func containsAll[T comparable](superset, subset []T) bool { + for _, value := range subset { + found := false + for _, candidate := range superset { + if value == candidate { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func declarations(transitions []delivery.Transition) ([]string, []string, []string, []delivery.TransitionID) { + resources, effects, verifiers := map[string]bool{}, map[string]bool{}, map[string]bool{} + recoveries := map[delivery.TransitionID]bool{} + for _, transition := range transitions { + for _, resource := range transition.OwnedResources { + resources[resource] = true + } + if transition.Effect != "" { + effects[string(transition.Effect)] = true + } + if transition.Verifier != "" { + verifiers[transition.Verifier] = true + } + if transition.Class == delivery.EventRecovery { + recoveries[transition.ID] = true + } + } + resourceList, effectList, verifierList := mapKeys(resources), mapKeys(effects), mapKeys(verifiers) + recoveryList := make([]delivery.TransitionID, 0, len(recoveries)) + for value := range recoveries { + recoveryList = append(recoveryList, value) + } + sort.Slice(recoveryList, func(i, j int) bool { return recoveryList[i] < recoveryList[j] }) + return resourceList, effectList, verifierList, recoveryList +} + +func mapKeys(values map[string]bool) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go new file mode 100644 index 0000000..55a2eef --- /dev/null +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -0,0 +1,174 @@ +package softwaredelivery_test + +import ( + "context" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/core" + "github.com/operatorstack/boatstack/boatstack/delivery" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" +) + +func compiledFlow(t *testing.T, guard controlprogram.Predicate) (controlprogram.Compiled, softwareflow.Resolver) { + t.Helper() + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + truth := true + document := controlprogram.Document{ + SchemaVersion: controlprogram.SchemaVersion, + Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, + Facets: []controlprogram.Facet{ + {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, + {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, + }, + Operators: []controlprogram.Operator{{ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}}, + Transitions: []controlprogram.Transition{{ID: "publication.observe", Operator: "publication.observe", Guard: guard, Target: controlprogram.Predicate{True: &truth}, Priority: 77}}, + Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{ + fact("verification", "current"), fact("configuration", "verified"), fact("runtime", "verified"), fact("publication", "open"), + }}}}, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr"}}, + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + return compiled, resolver +} + +func fact(facet, value string) controlprogram.Predicate { + return controlprogram.Predicate{Fact: &controlprogram.FactPredicate{Facet: facet, Statuses: []string{"known"}, Values: []string{value}}} +} + +func TestTrustedFlowLowersThroughStandardStateEffectBoundary(t *testing.T) { + // control-law: repository-flow-selects-trusted-semantics-without-redeclaring-native-effects + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err != nil { + t.Fatal(err) + } + manifest, err := definition.RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + if manifest.ID != "boatstack.standard" || len(manifest.Transitions) != 1 || manifest.Transitions[0].StateEffect.NativeHandler != "publication-observe" { + t.Fatalf("lowered manifest = %#v", manifest) + } + program, err := delivery.Compile(context.Background(), delivery.CompileRequest{KernelVersion: "v2.0.0", Core: core.System(), Runtime: definition, Settings: map[string]string{"repo": "fixture"}}) + if err != nil { + t.Fatal(err) + } + if program.Summary().RuntimeTransitionCount != 1 { + t.Fatalf("runtime transition count = %d", program.Summary().RuntimeTransitionCount) + } +} + +func TestRepositoryGuardCanOnlyStrengthenTrustedBinding(t *testing.T) { + compiled, resolver := compiledFlow(t, fact("publication", "candidate")) + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err != nil { + t.Fatal(err) + } + manifest, err := definition.RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(manifest.Transitions[0].SourceConditions) < 2 { + t.Fatal("strengthened condition was not appended") + } + invalid := compiled.Document + invalid.Transitions[0].Guard = controlprogram.Predicate{Any: []controlprogram.Predicate{fact("publication", "candidate"), fact("publication", "open")}} + nonConjunctive, err := controlprogram.Compile(invalid, resolver) + if err != nil { + t.Fatal(err) + } + definition, err = softwareflow.NewDefinition(nonConjunctive, resolver) + if err == nil { + _, err = definition.RuntimeManifest(context.Background()) + } + if err == nil || !strings.Contains(err.Error(), "does not strengthen") { + t.Fatalf("non-conjunctive guard result = %v", err) + } +} + +func TestRepositoryTargetMustBeImpliedByTrustedPostcondition(t *testing.T) { + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + tampered := compiled.Document + tampered.Transitions[0].Target = fact("configuration", "unverified") + unsafe, err := controlprogram.Compile(tampered, resolver) + if err != nil { + t.Fatal(err) + } + definition, err := softwareflow.NewDefinition(unsafe, resolver) + if err == nil { + _, err = definition.RuntimeManifest(context.Background()) + } + if err == nil || !strings.Contains(err.Error(), "target exceeds the trusted binding") { + t.Fatalf("unestablishable repository target result = %v", err) + } +} + +func TestRepositoryTransitionMustMatchTrustedBindingIdentity(t *testing.T) { + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + aliased := compiled.Document + aliased.Transitions[0].ID = "observe-alias" + unsafe, err := controlprogram.Compile(aliased, resolver) + if err != nil { + t.Fatal(err) + } + definition, err := softwareflow.NewDefinition(unsafe, resolver) + if err == nil { + _, err = definition.RuntimeManifest(context.Background()) + } + if err == nil || !strings.Contains(err.Error(), "does not match trusted binding identity") { + t.Fatalf("transition alias result = %v", err) + } +} + +func TestRepositoryTransitionCannotWidenTrustedObjectiveKinds(t *testing.T) { + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + widened := compiled.Document + widened.Facets = append(widened.Facets, controlprogram.Facet{ID: "plan", Kind: "string"}) + widened.Targets = []controlprogram.Target{{ID: "approved-plan", Predicate: fact("plan", "approved")}} + widened.Entries = []controlprogram.Entry{{ID: "autoplan", Target: "approved-plan"}} + unsafe, err := controlprogram.Compile(widened, resolver) + if err != nil { + t.Fatal(err) + } + definition, err := softwareflow.NewDefinition(unsafe, resolver) + if err == nil { + _, err = definition.RuntimeManifest(context.Background()) + } + if err == nil || !strings.Contains(err.Error(), "supports none of the declared entry objectives") { + t.Fatalf("widened objective result = %v", err) + } +} + +func TestCompiledBindingDriftFailsClosed(t *testing.T) { + truth := true + compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) + drifted := compiled.Document + drifted.Operators[0].Binding.Fingerprint = strings.Repeat("a", 64) + if _, err := controlprogram.Compile(drifted, resolver); err == nil || !strings.Contains(err.Error(), "drift") { + t.Fatalf("binding drift result = %v", err) + } + capabilityOverride := compiled.Document + capabilityOverride.Operators[0].Capabilities = append(capabilityOverride.Operators[0].Capabilities, "merge") + if _, err := controlprogram.Compile(capabilityOverride, resolver); err == nil { + t.Fatal("capability escalation was accepted") + } + effectOverride := compiled.Document + tampered := *effectOverride.Operators[0].StateEffect + tampered.NativeHandler = "incompatible-owner" + effectOverride.Operators[0].StateEffect = &tampered + if _, err := controlprogram.Compile(effectOverride, resolver); err == nil { + t.Fatal("trusted state-effect ownership override was accepted") + } +} diff --git a/boatstack/flow/softwaredelivery/inputs.go b/boatstack/flow/softwaredelivery/inputs.go new file mode 100644 index 0000000..4cab392 --- /dev/null +++ b/boatstack/flow/softwaredelivery/inputs.go @@ -0,0 +1,62 @@ +package softwaredelivery + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" +) + +const PlanInboxResolver = "software-delivery.plan-inbox" + +type PlanInbox struct { + Path string `json:"path"` + Cardinality string `json:"cardinality"` +} + +func PlanInboxForEntry(entry controlprogram.Entry) (PlanInbox, error) { + if len(entry.Inputs) != 1 { + return PlanInbox{}, fmt.Errorf("entry %q requires exactly one plan input", entry.ID) + } + input := entry.Inputs[0] + if input.ID != "plan" || input.Type != "markdown-file" || !input.Required || input.Resolver != PlanInboxResolver { + return PlanInbox{}, fmt.Errorf("entry %q requires one required markdown plan resolved by %s", entry.ID, PlanInboxResolver) + } + decoder := json.NewDecoder(bytes.NewReader(input.Config)) + decoder.DisallowUnknownFields() + var config PlanInbox + if err := decoder.Decode(&config); err != nil { + return PlanInbox{}, fmt.Errorf("entry %q has invalid plan inbox config: %w", entry.ID, err) + } + if err := requireInputEOF(decoder); err != nil || config.Cardinality != "exactly-one" || !safeInputPath(config.Path) { + return PlanInbox{}, fmt.Errorf("entry %q requires a canonical repository plan inbox with exactly-one cardinality", entry.ID) + } + return config, nil +} + +func requireInputEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("trailing JSON") + } + return err + } + return nil +} + +func safeInputPath(value string) bool { + if value == "" || strings.Contains(value, `\`) { + return false + } + platformPath := filepath.FromSlash(value) + if filepath.IsAbs(platformPath) { + return false + } + clean := filepath.Clean(platformPath) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) && filepath.ToSlash(clean) == value +} diff --git a/boatstack/flow/softwaredelivery/inputs_test.go b/boatstack/flow/softwaredelivery/inputs_test.go new file mode 100644 index 0000000..3d209c1 --- /dev/null +++ b/boatstack/flow/softwaredelivery/inputs_test.go @@ -0,0 +1,48 @@ +package softwaredelivery_test + +import ( + "encoding/json" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" +) + +func TestPlanInboxForEntryMatchesProductionResolverContract(t *testing.T) { + config := json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one"}`) + valid := controlprogram.Entry{ID: "run", Inputs: []controlprogram.EntryInput{{ + ID: "plan", Type: "markdown-file", Required: true, Resolver: softwareflow.PlanInboxResolver, Config: config, + }}} + if inbox, err := softwareflow.PlanInboxForEntry(valid); err != nil || inbox.Path != ".boatstack/plans/inbox" { + t.Fatalf("production plan inbox = %+v, %v", inbox, err) + } + clone := func() controlprogram.Entry { + entry := valid + entry.Inputs = append([]controlprogram.EntryInput(nil), valid.Inputs...) + return entry + } + invalid := map[string]func(*controlprogram.Entry){ + "missing": func(entry *controlprogram.Entry) { entry.Inputs = nil }, + "extra": func(entry *controlprogram.Entry) { entry.Inputs = append(entry.Inputs, entry.Inputs[0]) }, + "optional": func(entry *controlprogram.Entry) { entry.Inputs[0].Required = false }, + "wrong-id": func(entry *controlprogram.Entry) { entry.Inputs[0].ID = "source" }, + "wrong-type": func(entry *controlprogram.Entry) { entry.Inputs[0].Type = "text" }, + "wrong-resolver": func(entry *controlprogram.Entry) { entry.Inputs[0].Resolver = "repository.file" }, + "unknown-config": func(entry *controlprogram.Entry) { + entry.Inputs[0].Config = json.RawMessage(`{"path":".boatstack/plans/inbox","cardinality":"exactly-one","extra":true}`) + }, + "malformed-config": func(entry *controlprogram.Entry) { entry.Inputs[0].Config = json.RawMessage(`{"path":`) }, + "escaping-path": func(entry *controlprogram.Entry) { + entry.Inputs[0].Config = json.RawMessage(`{"path":"../inbox","cardinality":"exactly-one"}`) + }, + } + for name, mutate := range invalid { + t.Run(name, func(t *testing.T) { + entry := clone() + mutate(&entry) + if _, err := softwareflow.PlanInboxForEntry(entry); err == nil { + t.Fatal("invalid production entry input was accepted") + } + }) + } +} diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go new file mode 100644 index 0000000..10f6627 --- /dev/null +++ b/boatstack/flow/softwaredelivery/skills.go @@ -0,0 +1,85 @@ +package softwaredelivery + +import ( + "encoding/hex" + "fmt" + "path/filepath" + "strings" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" +) + +func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) { + result := map[string][]byte{} + for _, entry := range compiled.Document.Entries { + slug := flowSkillSlug(compiled.Document.Program.ID, entry.ID) + if slug == "boatstack-update" { + return nil, fmt.Errorf("generated Flow skill %q is reserved for kernel maintenance", slug) + } + for _, host := range hosts { + skill := renderSkill(compiled, entry, slug, host) + switch host { + case "codex": + root := filepath.ToSlash(filepath.Join(".agents", "skills", slug)) + result[root+"/SKILL.md"] = skill + result[root+"/agents/openai.yaml"] = []byte(fmt.Sprintf("interface:\n display_name: %q\n short_description: %q\n default_prompt: %q\npolicy:\n allow_implicit_invocation: false\n", title(slug), entry.Description, "Use $"+slug+" to run the repository-owned Boatstack Flow entry.")) + case "claude": + result[filepath.ToSlash(filepath.Join(".claude", "skills", slug, "SKILL.md"))] = skill + default: + return nil, fmt.Errorf("unsupported generated Flow skill host %q", host) + } + } + } + return result, nil +} + +func flowSkillSlug(programID, entryID string) string { + const encodedPrefix = "x0" + encodedEntry := entryID + if strings.Contains(entryID, "-") || strings.HasPrefix(entryID, encodedPrefix) { + encodedEntry = encodedPrefix + hex.EncodeToString([]byte(entryID)) + } + return programID + "-" + encodedEntry +} + +func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, slug, host string) []byte { + description := entry.Description + if description == "" { + description = "Run repository Flow entry " + entry.ID + " to target " + entry.Target + "." + } + description += " Use only when the user explicitly selects this repository Flow entry." + return []byte(fmt.Sprintf(`--- +name: %s +description: %q +--- + +# %s + +Run the repository-owned Flow %q entry %q until its marked target %q is reached. +Boatstack does not interpret the entry name. + +Start with `+"`boatstack next --repo . --flow %s --entry %s --host %s --format json`"+`. +Preserve the returned program fingerprint, entry, run ID, delivery, repository, +worktree, host, actor, authority receipts, prescription, and receipts through +every `+"`next`"+`, `+"`apply`"+`, recovery, question, and re-resolution. + +Apply only the exact immediately preceding prescription and its declared +parameters. A question suspends this run: ask the user, submit only the typed +answer evidence, and resume the same run ID. Nothing continues in the +background while input is missing. Never synthesize authority. + +Stop only when Boatstack reports the marked target, a typed blocker, refusal, +unresolved recovery, or missing authority. This entry grants no merge or deploy +authority. +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host)) +} + +func title(value string) string { + parts := strings.FieldsFunc(value, func(r rune) bool { return r == '-' || r == '_' || r == '.' }) + for index := range parts { + if parts[index] != "" { + parts[index] = strings.ToUpper(parts[index][:1]) + parts[index][1:] + } + } + return strings.Join(parts, " ") +} diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go new file mode 100644 index 0000000..e4e265c --- /dev/null +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -0,0 +1,120 @@ +package softwaredelivery_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/controlprogram" + softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" +) + +func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { + // control-law: hosts-receive-the-same-entry-contract-without-kernel-inference + truth := true + compiled := controlprogram.Compiled{Fingerprint: strings.Repeat("a", 64), Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "product-delivery"}, + Targets: []controlprogram.Target{{ID: "published-pr", Predicate: controlprogram.Predicate{True: &truth}}}, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr", Description: "Publish the reviewed change"}}, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + if len(files) != 3 { + t.Fatalf("generated file count = %d, want 3", len(files)) + } + codex := files[".agents/skills/product-delivery-run/SKILL.md"] + claude := files[".claude/skills/product-delivery-run/SKILL.md"] + if strings.ReplaceAll(string(codex), "--host codex", "--host HOST") != strings.ReplaceAll(string(claude), "--host claude", "--host HOST") { + t.Fatal("Codex and Claude entry contracts differ") + } + value := string(codex) + for _, contract := range []string{"--flow product-delivery --entry run", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy"} { + if !strings.Contains(value, contract) { + t.Fatalf("generated skill lacks %q", contract) + } + } + for path := range files { + if strings.Contains(path, "autoplan") || strings.Contains(path, "boatstack-run") { + t.Fatalf("undeclared entry generated: %s", path) + } + } +} + +func TestGeneratedSkillDescriptionIsQuotedYAML(t *testing.T) { + description := "Implement: parser\n# heading\n---\nnext" + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "product-delivery"}, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr", Description: description}}, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + for path, raw := range files { + if !strings.HasSuffix(path, "SKILL.md") { + continue + } + value := string(raw) + if strings.Count(value, "\n---\n") != 1 { + t.Fatalf("%s contains an injected frontmatter delimiter", path) + } + var rendered string + for _, line := range strings.Split(value, "\n") { + if strings.HasPrefix(line, "description: ") { + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "description: ")), &rendered); err != nil { + t.Fatalf("%s description is not a quoted YAML/JSON scalar: %v", path, err) + } + } + } + expected := description + " Use only when the user explicitly selects this repository Flow entry." + if rendered != expected { + t.Fatalf("%s description = %q, want %q", path, rendered, expected) + } + } +} + +func TestGeneratedSkillsRejectKernelMaintenanceIdentity(t *testing.T) { + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "boatstack"}, + Entries: []controlprogram.Entry{{ID: "update", Target: "done"}}, + }} + if _, err := softwareflow.GenerateSkills(compiled, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("maintenance collision result = %v", err) + } +} + +func TestGeneratedSkillIdentityIsInjectiveAcrossProgramEntryPairs(t *testing.T) { + generate := func(program, entry string) map[string][]byte { + t.Helper() + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: program}, + Entries: []controlprogram.Entry{{ID: entry, Target: "done"}}, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex"}) + if err != nil { + t.Fatal(err) + } + return files + } + first := generate("a-b", "c") + second := generate("a", "b-c") + for path := range first { + if _, collision := second[path]; collision { + t.Fatalf("distinct program/entry pairs collide at %s", path) + } + } + encoded := generate("product-delivery", "run-now") + for path := range encoded { + if strings.Contains(path, "--") { + t.Fatalf("hyphenated entry produced an invalid path: %s", path) + } + } + reservedPrefix := generate("product-delivery", "x072756e2d6e6f77") + for path := range encoded { + if _, collision := reservedPrefix[path]; collision { + t.Fatalf("encoded and literal entry identities collide at %s", path) + } + } +} diff --git a/boatstack/flow/standard/completeness_test.go b/boatstack/flow/standard/completeness_test.go index 323405a..bb46b9a 100644 --- a/boatstack/flow/standard/completeness_test.go +++ b/boatstack/flow/standard/completeness_test.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "reflect" "runtime" "strconv" "strings" @@ -146,7 +147,7 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t 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/softwaredelivery/effects/command_boundary.go" && relative != "internal/softwaredelivery/plant/resolver.go" && relative != "extension/subprocess/subprocess.go" && relative != "internal/runtime/exec_windows.go" { + if relative != "internal/softwaredelivery/effects/command_boundary.go" && relative != "internal/softwaredelivery/plant/resolver.go" && relative != "extension/subprocess/subprocess.go" && relative != "internal/runtime/exec_windows.go" && relative != "internal/runtime/flow_files.go" { t.Errorf("unclassified command boundary in %s", relative) } } @@ -172,6 +173,89 @@ func TestSourceInventoryHasNoWriterOrLifecycleAuthorityOutsideOwnedPackages(t *t } } +func TestFlowCompilerMutationSitesMapToFlowCompileEvent(t *testing.T) { + // control-law: every-flow-projection-mutation-is-one-classified-authoring-event + root := sourceRoot(t) + expected := map[string]map[string]int{ + "cmd/boatstack-helper/flow_command.go": { + "runtime.ApplyOwnedFlowProjection": 1, + }, + "internal/runtime/flow_files.go": { + "os.OpenFile": 1, "os.Remove": 1, "os.Rename": 1, + "os.Root.MkdirAll": 1, "os.Root.OpenFile": 1, "os.Root.Remove": 5, "os.Root.Rename": 3, + "os.File.Write": 1, "os.File.Chmod": 2, + }, + "internal/runtime/flow_ownership.go": { + "os.MkdirAll": 1, "os.OpenFile": 1, "os.Remove": 1, + "os.File.Write": 1, + }, + } + for relative, wanted := range expected { + path := filepath.Join(root, filepath.FromSlash(relative)) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatal(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 + } + observed := map[string]int{} + 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] + switch { + case importPath == "os" && writerCallsForInventory(selector.Sel.Name): + observed["os."+selector.Sel.Name]++ + case owner.Name == "root" && rootWriterCallsForInventory(selector.Sel.Name): + observed["os.Root."+selector.Sel.Name]++ + case (owner.Name == "temporary" || owner.Name == "file") && fileWriterCallsForInventory(selector.Sel.Name): + observed["os.File."+selector.Sel.Name]++ + case strings.HasSuffix(importPath, "/internal/runtime") && (selector.Sel.Name == "ApplyFlowProjection" || selector.Sel.Name == "ApplyOwnedFlowProjection"): + observed["runtime."+selector.Sel.Name]++ + } + return true + }) + if !reflect.DeepEqual(observed, wanted) { + t.Fatalf("flow.compile mutation inventory for %s = %v, want %v", relative, observed, wanted) + } + } +} + +func rootWriterCallsForInventory(name string) bool { + switch name { + case "MkdirAll", "OpenFile", "Remove", "Rename": + return true + default: + return false + } +} + +func fileWriterCallsForInventory(name string) bool { + return name == "Write" || name == "Chmod" +} + +func writerCallsForInventory(name string) bool { + switch name { + case "WriteFile", "Rename", "Remove", "RemoveAll", "Mkdir", "MkdirAll", "Create", "CreateTemp", "MkdirTemp", "OpenFile": + return true + default: + return false + } +} + func TestEveryControllableRuntimeEventHasAnExecutableStateReducer(t *testing.T) { // control-law: registry-entry-cannot-exist-without-declared-runtime-effect-reduction registry := testprogram.StandardRegistry() @@ -366,6 +450,7 @@ func TestPackageImportsPreserveControlProgramDependencyDirection(t *testing.T) { func classifiedProductionFile(relative string) bool { return relative == "delivery_controller.go" || relative == "program_effects.go" || relative == "program_observer.go" || strings.HasPrefix(relative, "cmd/boatstack-helper/") || + strings.HasPrefix(relative, "controlprogram/") || strings.HasPrefix(relative, "delivery/") || strings.HasPrefix(relative, "core/") || strings.HasPrefix(relative, "flow/") || strings.HasPrefix(relative, "distribution/") || strings.HasPrefix(relative, "extension/") || strings.HasPrefix(relative, "internal/softwaredelivery/") || diff --git a/boatstack/flow/standard/transitions.json b/boatstack/flow/standard/transitions.json index dc6d3ba..cf19b8d 100644 --- a/boatstack/flow/standard/transitions.json +++ b/boatstack/flow/standard/transitions.json @@ -75,6 +75,11 @@ "name": "delivery_id", "required": true, "secret": false + }, + { + "name": "source_fingerprint", + "required": false, + "secret": false } ], "prescription": { @@ -977,6 +982,11 @@ "name": "delivery_id", "required": true, "secret": false + }, + { + "name": "source_fingerprint", + "required": false, + "secret": false } ], "prescription": { diff --git a/boatstack/internal/runtime/flow_files.go b/boatstack/internal/runtime/flow_files.go new file mode 100644 index 0000000..8717de8 --- /dev/null +++ b/boatstack/internal/runtime/flow_files.go @@ -0,0 +1,604 @@ +package runtime + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +func RunFlowFrontend(ctx context.Context, executable, sourceName string, source []byte) ([]byte, error) { + if executable == "" || !filepath.IsAbs(executable) || !filepath.IsAbs(sourceName) { + return nil, fmt.Errorf("Flow frontend and source paths must be exact and absolute") + } + command := exec.CommandContext(ctx, executable, "--stdin", sourceName) + command.Stdin = bytes.NewReader(source) + output, err := command.Output() + if err != nil { + if exit, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("Flow frontend failed: %s", string(exit.Stderr)) + } + return nil, err + } + return output, nil +} + +type ProjectionWrite struct { + Path string + Content []byte + Mode os.FileMode + ExpectedPreviousSHA256 string + PublishLast bool +} + +type ProjectionRemoval struct { + Path string + ExpectedSHA256 string + AllowMissing bool +} + +type ProjectionExpectation struct { + Path string + Exists bool + ExpectedSHA256 string +} + +type projectionSnapshot struct { + path string + exists bool + content []byte + mode os.FileMode +} + +type stagedProjection struct { + target string + temporary string +} + +type projectionHooks struct { + afterValidation func() + beforeStage func(string) + afterRemovals func() +} + +// ApplyFlowProjection stages every output before mutating the repository and +// restores the prior bytes if any commit step fails. Generated outputs may not +// traverse repository symlinks. +func ApplyFlowProjection(repository string, writes []ProjectionWrite, removals []ProjectionRemoval, expectations []ProjectionExpectation) error { + return applyFlowProjection(repository, writes, removals, expectations, projectionHooks{}) +} + +func applyFlowProjection(repository string, writes []ProjectionWrite, removals []ProjectionRemoval, expectations []ProjectionExpectation, hooks projectionHooks) error { + return applyFlowProjectionWithOwnership(repository, writes, removals, expectations, hooks, nil) +} + +func applyFlowProjectionWithOwnership(repository string, writes []ProjectionWrite, removals []ProjectionRemoval, expectations []ProjectionExpectation, hooks projectionHooks, ownership *flowProjectionOwnershipChange) error { + if !filepath.IsAbs(repository) || filepath.Clean(repository) != repository { + return fmt.Errorf("projection repository must be exact and absolute") + } + resolvedRepository, err := filepath.EvalSymlinks(repository) + if err != nil || resolvedRepository != repository { + return fmt.Errorf("projection repository must be a resolved directory") + } + root, err := os.OpenRoot(repository) + if err != nil { + return err + } + defer root.Close() + lock, err := acquireProjectionLock(repository) + if err != nil { + return err + } + defer lock.release() + if ownership != nil { + if ownership.prior.path == "" || ownership.next.SourcePath != ownership.prior.sourcePath || (ownership.prior.exists && ownership.prior.Record.SourcePath != ownership.next.SourcePath) { + return fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: provenance update does not identify one source") + } + if err := validateOwnershipSnapshot(ownership.prior); err != nil { + return err + } + } + writes = append([]ProjectionWrite(nil), writes...) + removals = append([]ProjectionRemoval(nil), removals...) + expectations = append([]ProjectionExpectation(nil), expectations...) + sort.Slice(writes, func(i, j int) bool { + if writes[i].PublishLast != writes[j].PublishLast { + return !writes[i].PublishLast + } + return writes[i].Path < writes[j].Path + }) + sort.Slice(removals, func(i, j int) bool { return removals[i].Path < removals[j].Path }) + sort.Slice(expectations, func(i, j int) bool { return expectations[i].Path < expectations[j].Path }) + + seen := map[string]bool{} + snapshots := map[string]projectionSnapshot{} + publishLast := 0 + for _, write := range writes { + if write.Mode.Perm() == 0 || seen[write.Path] { + return fmt.Errorf("projection contains an invalid or duplicate write path") + } + if write.PublishLast { + publishLast++ + } + seen[write.Path] = true + if err := validateProjectionPath(repository, write.Path); err != nil { + return err + } + snapshot, err := snapshotProjectionPath(root, repository, write.Path) + if err != nil { + return err + } + if !projectionWriteAuthorized(snapshot, write) { + return fmt.Errorf("FLOW_PROJECTION_WRITE_UNAUTHORIZED: %s is not absent, exact crash residue, or authorized by kernel provenance", write.Path) + } + snapshots[write.Path] = snapshot + } + if publishLast > 1 { + return fmt.Errorf("projection contains multiple publish-last writes") + } + for _, removal := range removals { + if seen[removal.Path] || removal.ExpectedSHA256 == "" { + return fmt.Errorf("projection cannot write and remove the same path") + } + seen[removal.Path] = true + if err := validateProjectionPath(repository, removal.Path); err != nil { + return err + } + snapshot, err := snapshotProjectionPath(root, repository, removal.Path) + if err != nil { + return err + } + if !snapshot.exists && !removal.AllowMissing { + return fmt.Errorf("projection removal target is missing") + } + if snapshot.exists && projectionDigest(snapshot.content) != removal.ExpectedSHA256 { + return fmt.Errorf("FLOW_PROJECTION_INPUT_CHANGED: removal target %s changed before commit", removal.Path) + } + snapshots[removal.Path] = snapshot + } + for _, expectation := range expectations { + if err := validateProjectionPath(repository, expectation.Path); err != nil { + return err + } + snapshot, err := snapshotProjectionPath(root, repository, expectation.Path) + if err != nil { + return err + } + if snapshot.exists != expectation.Exists || (expectation.Exists && projectionDigest(snapshot.content) != expectation.ExpectedSHA256) { + return fmt.Errorf("FLOW_PROJECTION_INPUT_CHANGED: %s changed before commit", expectation.Path) + } + } + if hooks.afterValidation != nil { + hooks.afterValidation() + } + + staged := make([]stagedProjection, 0, len(writes)) + ownershipTemporary := "" + defer func() { + for _, value := range staged { + _ = root.Remove(value.temporary) + } + if ownershipTemporary != "" { + _ = os.Remove(ownershipTemporary) + } + }() + for _, write := range writes { + relative, relativeErr := projectionRelativePath(repository, write.Path) + if relativeErr != nil { + return relativeErr + } + if err := root.MkdirAll(filepath.Dir(relative), 0o755); err != nil { + return err + } + if err := validateProjectionPath(repository, write.Path); err != nil { + return err + } + if hooks.beforeStage != nil { + hooks.beforeStage(write.Path) + } + temporary, err := stageProjectionFile(root, repository, write.Path, write.Content, write.Mode) + if err != nil { + return err + } + staged = append(staged, stagedProjection{target: write.Path, temporary: temporary}) + } + if ownership != nil { + ownershipTemporary, err = stageOwnership(ownership.prior, ownership.next) + if err != nil { + return err + } + } + + changed := make([]string, 0, len(writes)+len(removals)) + committed := map[string]projectionSnapshot{} + var commitErr error + for index, value := range staged { + if writes[index].PublishLast { + continue + } + current, currentErr := snapshotProjectionPath(root, repository, value.target) + if currentErr != nil { + commitErr = currentErr + break + } + if !projectionWriteAuthorized(current, writes[index]) { + commitErr = fmt.Errorf("FLOW_PROJECTION_WRITE_UNAUTHORIZED: %s changed before replacement", value.target) + break + } + desired := projectionSnapshot{path: value.target, exists: true, content: writes[index].Content, mode: writes[index].Mode.Perm()} + if sameProjectionState(current, desired) { + committed[value.target] = current + continue + } + target, targetErr := projectionRelativePath(repository, value.target) + if targetErr != nil { + commitErr = targetErr + break + } + if commitErr = root.Rename(value.temporary, target); commitErr != nil { + break + } + changed = append(changed, value.target) + committed[value.target] = projectionSnapshot{path: value.target, exists: true, content: writes[index].Content, mode: writes[index].Mode.Perm()} + } + if commitErr == nil { + for _, removal := range removals { + current, currentErr := snapshotProjectionPath(root, repository, removal.Path) + if currentErr != nil { + commitErr = currentErr + break + } + if !current.exists && removal.AllowMissing { + continue + } + if !current.exists || projectionDigest(current.content) != removal.ExpectedSHA256 { + commitErr = fmt.Errorf("FLOW_PROJECTION_INPUT_CHANGED: removal target %s changed before removal", removal.Path) + break + } + relative, relativeErr := projectionRelativePath(repository, removal.Path) + if relativeErr != nil { + commitErr = relativeErr + break + } + if commitErr = root.Remove(relative); commitErr != nil { + break + } + changed = append(changed, removal.Path) + committed[removal.Path] = projectionSnapshot{path: removal.Path} + } + } + if commitErr == nil && hooks.afterRemovals != nil { + hooks.afterRemovals() + } + if commitErr == nil && publishLast == 1 { + for index, value := range staged { + if writes[index].PublishLast { + continue + } + current, currentErr := snapshotProjectionPath(root, repository, value.target) + if currentErr != nil { + commitErr = currentErr + break + } + if !sameProjectionState(current, committed[value.target]) { + commitErr = fmt.Errorf("FLOW_PROJECTION_OUTPUT_CHANGED: %s changed before artifact publication", value.target) + break + } + } + } + if commitErr == nil && publishLast == 1 { + for _, removal := range removals { + current, currentErr := snapshotProjectionPath(root, repository, removal.Path) + if currentErr != nil { + commitErr = currentErr + break + } + if current.exists { + commitErr = fmt.Errorf("FLOW_PROJECTION_OUTPUT_CHANGED: retired output %s reappeared before artifact publication", removal.Path) + break + } + } + } + if commitErr == nil && publishLast == 1 { + for _, expectation := range expectations { + current, currentErr := snapshotProjectionPath(root, repository, expectation.Path) + if currentErr != nil { + commitErr = currentErr + break + } + if current.exists != expectation.Exists || (expectation.Exists && projectionDigest(current.content) != expectation.ExpectedSHA256) { + commitErr = fmt.Errorf("FLOW_PROJECTION_INPUT_CHANGED: %s changed before publication", expectation.Path) + break + } + } + } + if commitErr == nil && publishLast == 1 { + for index, value := range staged { + if !writes[index].PublishLast { + continue + } + current, currentErr := snapshotProjectionPath(root, repository, value.target) + if currentErr != nil { + commitErr = currentErr + break + } + if !projectionWriteAuthorized(current, writes[index]) { + commitErr = fmt.Errorf("FLOW_PROJECTION_WRITE_UNAUTHORIZED: %s changed before replacement", value.target) + break + } + desired := projectionSnapshot{path: value.target, exists: true, content: writes[index].Content, mode: writes[index].Mode.Perm()} + if sameProjectionState(current, desired) { + committed[value.target] = current + break + } + target, targetErr := projectionRelativePath(repository, value.target) + if targetErr != nil { + commitErr = targetErr + break + } + if commitErr = root.Rename(value.temporary, target); commitErr == nil { + changed = append(changed, value.target) + committed[value.target] = projectionSnapshot{path: value.target, exists: true, content: writes[index].Content, mode: writes[index].Mode.Perm()} + } + break + } + } + if commitErr == nil && ownership != nil { + if commitErr = validateOwnershipSnapshot(ownership.prior); commitErr == nil { + commitErr = os.Rename(ownershipTemporary, ownership.prior.path) + if commitErr == nil { + ownershipTemporary = "" + } + } + } + if commitErr == nil { + return nil + } + if rollbackErr := rollbackProjection(root, repository, changed, snapshots, committed); rollbackErr != nil { + return fmt.Errorf("commit Flow projection: %v; rollback failed: %w", commitErr, rollbackErr) + } + return fmt.Errorf("commit Flow projection: %w", commitErr) +} + +func projectionDigest(value []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(value)) +} + +func projectionWriteAuthorized(current projectionSnapshot, write ProjectionWrite) bool { + if !current.exists { + return write.ExpectedPreviousSHA256 == "" + } + desired := projectionSnapshot{exists: true, content: write.Content, mode: write.Mode.Perm()} + if sameProjectionState(current, desired) { + return true + } + return write.ExpectedPreviousSHA256 != "" && projectionDigest(current.content) == write.ExpectedPreviousSHA256 +} + +type projectionLock struct { + file *os.File +} + +func acquireProjectionLock(repository string) (*projectionLock, error) { + gitDirectory, err := projectionGitDirectory(repository) + if err != nil { + return nil, err + } + path := filepath.Join(gitDirectory, "boatstack-flow-projection.lock") + if info, statErr := os.Lstat(path); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) { + return nil, fmt.Errorf("FLOW_PROJECTION_LOCK_INVALID: repository lock is not a regular file") + } else if statErr != nil && !os.IsNotExist(statErr) { + return nil, statErr + } + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o666) + if err != nil { + return nil, err + } + if info, statErr := file.Stat(); statErr != nil || !info.Mode().IsRegular() { + _ = file.Close() + return nil, fmt.Errorf("FLOW_PROJECTION_LOCK_INVALID: repository lock is not a regular file") + } + if err := file.Chmod(0o666); err != nil { + _ = file.Close() + return nil, err + } + if err := lockProjectionFile(file); err != nil { + _ = file.Close() + return nil, fmt.Errorf("FLOW_PROJECTION_BUSY: %w", err) + } + return &projectionLock{file: file}, nil +} + +func projectionGitDirectory(repository string) (string, error) { + marker := filepath.Join(repository, ".git") + info, err := os.Lstat(marker) + if err != nil { + return "", fmt.Errorf("FLOW_PROJECTION_LOCK_UNAVAILABLE: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("FLOW_PROJECTION_LOCK_UNAVAILABLE: .git is a symlink") + } + if info.IsDir() { + return marker, nil + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("FLOW_PROJECTION_LOCK_UNAVAILABLE: .git is not a directory or worktree marker") + } + raw, err := os.ReadFile(marker) + if err != nil { + return "", err + } + line := strings.TrimSpace(string(raw)) + if !strings.HasPrefix(line, "gitdir: ") || strings.Contains(strings.TrimPrefix(line, "gitdir: "), "\n") { + return "", fmt.Errorf("FLOW_PROJECTION_LOCK_UNAVAILABLE: invalid worktree Git marker") + } + gitDirectory := strings.TrimPrefix(line, "gitdir: ") + if !filepath.IsAbs(gitDirectory) { + gitDirectory = filepath.Join(repository, gitDirectory) + } + gitDirectory, err = filepath.EvalSymlinks(filepath.Clean(gitDirectory)) + if err != nil { + return "", fmt.Errorf("FLOW_PROJECTION_LOCK_UNAVAILABLE: %w", err) + } + if info, err := os.Stat(gitDirectory); err != nil || !info.IsDir() { + return "", fmt.Errorf("FLOW_PROJECTION_LOCK_UNAVAILABLE: worktree Git directory is unavailable") + } + return gitDirectory, nil +} + +func (l *projectionLock) release() { + _ = unlockProjectionFile(l.file) + _ = l.file.Close() +} + +func validateProjectionPath(repository, path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("managed output path must be exact and absolute") + } + relative, err := filepath.Rel(repository, path) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return fmt.Errorf("managed output path escapes the repository") + } + current := repository + parts := strings.Split(filepath.Dir(relative), string(filepath.Separator)) + for _, part := range parts { + if part == "." || part == "" { + continue + } + current = filepath.Join(current, part) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + break + } + if statErr != nil { + return statErr + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("managed output parent is a repository symlink") + } + if !info.IsDir() { + return fmt.Errorf("managed output parent is not a directory") + } + } + if info, err := os.Lstat(path); err == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) { + return fmt.Errorf("managed output is not a regular file") + } else if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func projectionRelativePath(repository, path string) (string, error) { + relative, err := filepath.Rel(repository, path) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("managed output path escapes the repository") + } + return relative, nil +} + +func snapshotProjectionPath(root *os.Root, repository, path string) (projectionSnapshot, error) { + relative, err := projectionRelativePath(repository, path) + if err != nil { + return projectionSnapshot{}, err + } + info, err := root.Lstat(relative) + if os.IsNotExist(err) { + return projectionSnapshot{path: path}, nil + } + if err != nil { + return projectionSnapshot{}, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return projectionSnapshot{}, fmt.Errorf("managed output is not a regular file") + } + content, err := root.ReadFile(relative) + if err != nil { + return projectionSnapshot{}, err + } + return projectionSnapshot{path: path, exists: true, content: content, mode: info.Mode().Perm()}, nil +} + +func stageProjectionFile(root *os.Root, repository, path string, content []byte, mode os.FileMode) (string, error) { + relative, err := projectionRelativePath(repository, path) + if err != nil { + return "", err + } + for attempt := 0; attempt < 100; attempt++ { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + temporaryPath := filepath.Join(filepath.Dir(relative), fmt.Sprintf(".boatstack-flow-%x", nonce)) + temporary, openErr := root.OpenFile(temporaryPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, mode) + if os.IsExist(openErr) { + continue + } + if openErr != nil { + return "", openErr + } + if _, err = temporary.Write(content); err == nil { + err = temporary.Chmod(mode) + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + _ = root.Remove(temporaryPath) + return "", err + } + return temporaryPath, nil + } + return "", fmt.Errorf("cannot allocate a staged projection file") +} + +func rollbackProjection(root *os.Root, repository string, changed []string, snapshots, committed map[string]projectionSnapshot) error { + for index := len(changed) - 1; index >= 0; index-- { + snapshot := snapshots[changed[index]] + current, err := snapshotProjectionPath(root, repository, snapshot.path) + if err != nil { + return err + } + if !sameProjectionState(current, committed[changed[index]]) { + continue + } + relative, err := projectionRelativePath(repository, snapshot.path) + if err != nil { + return err + } + if !snapshot.exists { + if err := root.Remove(relative); err != nil && !os.IsNotExist(err) { + return err + } + continue + } + temporary, err := stageProjectionFile(root, repository, snapshot.path, snapshot.content, snapshot.mode) + if err != nil { + return err + } + if err := root.Rename(temporary, relative); err != nil { + _ = root.Remove(temporary) + return err + } + } + return nil +} + +func sameProjectionState(left, right projectionSnapshot) bool { + return left.exists == right.exists && (!left.exists || (sameProjectionMode(left.mode, right.mode) && bytes.Equal(left.content, right.content))) +} + +func sameProjectionMode(left, right os.FileMode) bool { + if runtime.GOOS == "windows" { + // Windows chmod only controls the writable bit. The other Unix + // permission bits are not an observable projection-state identity. + return left.Perm()&0o200 == right.Perm()&0o200 + } + return left.Perm() == right.Perm() +} diff --git a/boatstack/internal/runtime/flow_files_test.go b/boatstack/internal/runtime/flow_files_test.go new file mode 100644 index 0000000..3615a6a --- /dev/null +++ b/boatstack/internal/runtime/flow_files_test.go @@ -0,0 +1,418 @@ +package runtime + +import ( + "bufio" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func resolvedTemporaryRepository(t *testing.T) string { + t.Helper() + repository, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o755); err != nil { + t.Fatal(err) + } + return repository +} + +func TestSameProjectionStateUsesHostPermissionSemantics(t *testing.T) { + // control-law: generated-output-drift-compares-only-host-enforceable-state + committed := projectionSnapshot{exists: true, content: []byte("skill"), mode: 0o644} + groupWritable := projectionSnapshot{exists: true, content: []byte("skill"), mode: 0o666} + if got := sameProjectionState(committed, groupWritable); got != (runtime.GOOS == "windows") { + t.Fatalf("host permission equivalence = %v on %s", got, runtime.GOOS) + } + readOnly := projectionSnapshot{exists: true, content: []byte("skill"), mode: 0o444} + if sameProjectionState(committed, readOnly) { + t.Fatal("writable and read-only projection states compare equal") + } + changed := projectionSnapshot{exists: true, content: []byte("changed"), mode: committed.mode} + if sameProjectionState(committed, changed) { + t.Fatal("changed projection bytes compare equal") + } +} + +func TestFlowProjectionRejectsRepositoryParentSymlink(t *testing.T) { + // control-law: generated-projections-never-traverse-repository-symlinks + repository := resolvedTemporaryRepository(t) + external := resolvedTemporaryRepository(t) + if err := os.Symlink(external, filepath.Join(repository, ".agents")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + target := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + err := ApplyFlowProjection(repository, []ProjectionWrite{{Path: target, Content: []byte("generated"), Mode: 0o644}}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "repository symlink") { + t.Fatalf("symlink projection result = %v", err) + } + if _, statErr := os.Stat(filepath.Join(external, "skills", "product-delivery-run", "SKILL.md")); !os.IsNotExist(statErr) { + t.Fatalf("projection escaped repository: %v", statErr) + } +} + +func TestFlowProjectionRejectsParentSymlinkSwapBeforeStaging(t *testing.T) { + // control-law: repository-root-capability-remains-bound-through-filesystem-mutation + repository := resolvedTemporaryRepository(t) + external := resolvedTemporaryRepository(t) + parent := filepath.Join(repository, ".agents") + if err := os.MkdirAll(parent, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(parent, "skills", "product-delivery-run", "SKILL.md") + swapped := false + err := applyFlowProjection(repository, + []ProjectionWrite{{Path: target, Content: []byte("generated"), Mode: 0o644}}, nil, nil, + projectionHooks{beforeStage: func(path string) { + if swapped || path != target { + return + } + swapped = true + if err := os.Rename(parent, parent+"-original"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, parent); err != nil { + t.Fatal(err) + } + }}) + if err == nil { + t.Fatal("parent symlink swap was accepted") + } + if _, statErr := os.Stat(filepath.Join(external, "skills", "product-delivery-run", "SKILL.md")); !os.IsNotExist(statErr) { + t.Fatalf("projection escaped through swapped parent: %v", statErr) + } +} + +func TestFlowProjectionStagesAllOutputsBeforeReplacement(t *testing.T) { + // control-law: artifact-and-skills-replace-as-one-recoverable-projection + if runtime.GOOS == "windows" { + t.Skip("directory permission failure is not portable to Windows") + } + repository := resolvedTemporaryRepository(t) + artifactDirectory := filepath.Join(repository, ".boatstack", "flows") + artifactPath := filepath.Join(artifactDirectory, "product-delivery.flow.ir.json") + retiredPath := filepath.Join(repository, ".agents", "skills", "old-run", "SKILL.md") + newSkillPath := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + for path, content := range map[string][]byte{artifactPath: []byte("old artifact"), retiredPath: []byte("old skill")} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(artifactDirectory, 0o555); err != nil { + t.Fatal(err) + } + writes := []ProjectionWrite{ + {Path: newSkillPath, Content: []byte("new skill"), Mode: 0o644}, + {Path: artifactPath, Content: []byte("new artifact"), Mode: 0o644, ExpectedPreviousSHA256: projectionDigest([]byte("old artifact"))}, + } + removals := []ProjectionRemoval{{Path: retiredPath, ExpectedSHA256: projectionDigest([]byte("old skill")), AllowMissing: true}} + err := ApplyFlowProjection(repository, writes, removals, nil) + if restoreErr := os.Chmod(artifactDirectory, 0o755); restoreErr != nil { + t.Fatal(restoreErr) + } + if err == nil { + t.Skip("filesystem did not enforce the staging permission failure") + } + for path, expected := range map[string]string{artifactPath: "old artifact", retiredPath: "old skill"} { + actual, readErr := os.ReadFile(path) + if readErr != nil || string(actual) != expected { + t.Fatalf("prior projection %s changed: %q, %v", path, actual, readErr) + } + } + if _, statErr := os.Stat(newSkillPath); !os.IsNotExist(statErr) { + t.Fatalf("new skill became visible before artifact staging: %v", statErr) + } + if err := ApplyFlowProjection(repository, writes, removals, nil); err != nil { + t.Fatal(err) + } + for path, expected := range map[string]string{artifactPath: "new artifact", newSkillPath: "new skill"} { + actual, readErr := os.ReadFile(path) + if readErr != nil || string(actual) != expected { + t.Fatalf("committed projection %s = %q, %v", path, actual, readErr) + } + } + if _, statErr := os.Stat(retiredPath); !os.IsNotExist(statErr) { + t.Fatalf("retired skill remains after successful retry: %v", statErr) + } +} + +func TestFlowProjectionRefusesConcurrentCompiler(t *testing.T) { + // control-law: concurrent-compilers-cannot-interleave-one-projection + repository := resolvedTemporaryRepository(t) + lock, err := acquireProjectionLock(repository) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + err = ApplyFlowProjection(repository, []ProjectionWrite{{Path: target, Content: []byte("skill"), Mode: 0o644}}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_BUSY") { + t.Fatalf("concurrent projection result = %v", err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("refused compiler changed projection: %v", statErr) + } + lock.release() + if err := ApplyFlowProjection(repository, []ProjectionWrite{{Path: target, Content: []byte("skill"), Mode: 0o644}}, nil, nil); err != nil { + t.Fatal(err) + } +} + +func TestFlowProjectionLockNamespaceIgnoresUserCache(t *testing.T) { + // control-law: every-compiler-for-one-worktree-uses-one-lock-namespace + if os.Getenv("BOATSTACK_FLOW_LOCK_CHILD") == "1" { + lock, err := acquireProjectionLock(os.Getenv("BOATSTACK_FLOW_LOCK_REPOSITORY")) + if err != nil { + t.Fatal(err) + } + defer lock.release() + if _, err := os.Stdout.WriteString("ready\n"); err != nil { + t.Fatal(err) + } + _, _ = bufio.NewReader(os.Stdin).ReadString('\n') + return + } + repository := resolvedTemporaryRepository(t) + command := exec.Command(os.Args[0], "-test.run=^TestFlowProjectionLockNamespaceIgnoresUserCache$") + command.Env = append(os.Environ(), + "BOATSTACK_FLOW_LOCK_CHILD=1", + "BOATSTACK_FLOW_LOCK_REPOSITORY="+repository, + "XDG_CACHE_HOME="+filepath.Join(t.TempDir(), "child-cache"), + ) + stdin, err := command.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + if line, err := bufio.NewReader(stdout).ReadString('\n'); err != nil || line != "ready\n" { + t.Fatalf("lock child readiness = %q, %v", line, err) + } + t.Setenv("XDG_CACHE_HOME", filepath.Join(t.TempDir(), "parent-cache")) + target := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + err = ApplyFlowProjection(repository, []ProjectionWrite{{Path: target, Content: []byte("skill"), Mode: 0o644}}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_BUSY") { + t.Fatalf("cross-cache concurrent projection result = %v", err) + } + _, _ = stdin.Write([]byte("release\n")) + _ = stdin.Close() + if err := command.Wait(); err != nil { + t.Fatal(err) + } +} + +func TestFlowProjectionRevalidatesAuthorizationUnderLock(t *testing.T) { + // control-law: projection-authorization-remains-byte-exact-through-commit + repository := resolvedTemporaryRepository(t) + retiredPath := filepath.Join(repository, ".agents", "skills", "old-run", "SKILL.md") + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + if err := os.MkdirAll(filepath.Dir(retiredPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(retiredPath, []byte("generated old skill"), 0o644); err != nil { + t.Fatal(err) + } + removal := ProjectionRemoval{Path: retiredPath, ExpectedSHA256: projectionDigest([]byte("generated old skill")), AllowMissing: true} + err := applyFlowProjection(repository, + []ProjectionWrite{{Path: artifactPath, Content: []byte("new artifact"), Mode: 0o644, PublishLast: true}}, + []ProjectionRemoval{removal}, nil, projectionHooks{afterValidation: func() { + if err := os.WriteFile(retiredPath, []byte("concurrent user edit"), 0o644); err != nil { + t.Fatal(err) + } + }}) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_INPUT_CHANGED") { + t.Fatalf("authorization drift result = %v", err) + } + actual, readErr := os.ReadFile(retiredPath) + if readErr != nil || string(actual) != "concurrent user edit" { + t.Fatalf("concurrent edit changed: %q, %v", actual, readErr) + } + if _, statErr := os.Stat(artifactPath); !os.IsNotExist(statErr) { + t.Fatalf("authorization drift published artifact: %v", statErr) + } +} + +func TestFlowProjectionRefusesUnmanagedOrChangedWriteTargets(t *testing.T) { + // control-law: generated-output-replacement-requires-prior-artifact-ownership-or-exact-crash-residue + for _, test := range []struct { + name string + initial []byte + afterValidate []byte + wantError bool + }{ + {name: "unmanaged", initial: []byte("user skill"), wantError: true}, + {name: "concurrent", afterValidate: []byte("concurrent skill"), wantError: true}, + {name: "exact-crash-residue", initial: []byte("generated skill")}, + } { + t.Run(test.name, func(t *testing.T) { + repository := resolvedTemporaryRepository(t) + skillPath := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + if test.initial != nil { + if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(skillPath, test.initial, 0o644); err != nil { + t.Fatal(err) + } + } + err := applyFlowProjection(repository, []ProjectionWrite{ + {Path: skillPath, Content: []byte("generated skill"), Mode: 0o644}, + {Path: artifactPath, Content: []byte("artifact"), Mode: 0o644, PublishLast: true}, + }, nil, nil, projectionHooks{afterValidation: func() { + if test.afterValidate == nil { + return + } + if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(skillPath, test.afterValidate, 0o644); err != nil { + t.Fatal(err) + } + }}) + if test.wantError { + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_WRITE_UNAUTHORIZED") { + t.Fatalf("write authorization result = %v", err) + } + expected := test.initial + if test.afterValidate != nil { + expected = test.afterValidate + } + if actual, readErr := os.ReadFile(skillPath); readErr != nil || string(actual) != string(expected) { + t.Fatalf("unowned skill changed: %q, %v", actual, readErr) + } + if _, statErr := os.Stat(artifactPath); !os.IsNotExist(statErr) { + t.Fatalf("unauthorized write published artifact: %v", statErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if actual, readErr := os.ReadFile(artifactPath); readErr != nil || string(actual) != "artifact" { + t.Fatalf("crash residue artifact = %q, %v", actual, readErr) + } + }) + } +} + +func TestFlowProjectionRevalidatesCompileInputsUnderLock(t *testing.T) { + // control-law: artifact-publication-remains-bound-to-exact-source-and-lock-bytes + repository := resolvedTemporaryRepository(t) + sourcePath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ts") + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + if err := os.MkdirAll(filepath.Dir(sourcePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sourcePath, []byte("source A"), 0o644); err != nil { + t.Fatal(err) + } + err := applyFlowProjection(repository, + []ProjectionWrite{{Path: artifactPath, Content: []byte("artifact from source A"), Mode: 0o644, PublishLast: true}}, nil, + []ProjectionExpectation{{Path: sourcePath, Exists: true, ExpectedSHA256: projectionDigest([]byte("source A"))}}, projectionHooks{afterRemovals: func() { + if err := os.WriteFile(sourcePath, []byte("source B"), 0o644); err != nil { + t.Fatal(err) + } + }}) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_INPUT_CHANGED") { + t.Fatalf("source drift result = %v", err) + } + if _, statErr := os.Stat(artifactPath); !os.IsNotExist(statErr) { + t.Fatalf("source drift published artifact: %v", statErr) + } +} + +func TestFlowProjectionRevalidatesGeneratedOutputsBeforeArtifactPublication(t *testing.T) { + // control-law: artifact-publication-binds-the-exact-generated-output-bytes + repository := resolvedTemporaryRepository(t) + skillPath := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + err := applyFlowProjection(repository, []ProjectionWrite{ + {Path: skillPath, Content: []byte("generated skill"), Mode: 0o644}, + {Path: artifactPath, Content: []byte("artifact"), Mode: 0o644, PublishLast: true}, + }, nil, nil, projectionHooks{afterRemovals: func() { + if err := os.WriteFile(skillPath, []byte("concurrent edit"), 0o644); err != nil { + t.Fatal(err) + } + }}) + if err == nil || !strings.Contains(err.Error(), "FLOW_PROJECTION_OUTPUT_CHANGED") { + t.Fatalf("generated output drift result = %v", err) + } + if actual, readErr := os.ReadFile(skillPath); readErr != nil || string(actual) != "concurrent edit" { + t.Fatalf("concurrent skill edit changed: %q, %v", actual, readErr) + } + if _, statErr := os.Stat(artifactPath); !os.IsNotExist(statErr) { + t.Fatalf("generated output drift published artifact: %v", statErr) + } +} + +func TestFlowProjectionRecoversAfterCrashBeforeArtifactPublication(t *testing.T) { + // control-law: retired-skills-precede-artifact-publication-and-retry-recovers-a-crash + if os.Getenv("BOATSTACK_FLOW_CRASH_CHILD") == "1" { + repository := os.Getenv("BOATSTACK_FLOW_CRASH_REPOSITORY") + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + retiredPath := filepath.Join(repository, ".agents", "skills", "old-run", "SKILL.md") + newSkillPath := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + _ = applyFlowProjection(repository, []ProjectionWrite{ + {Path: newSkillPath, Content: []byte("new skill"), Mode: 0o644}, + {Path: artifactPath, Content: []byte("new artifact"), Mode: 0o644, ExpectedPreviousSHA256: projectionDigest([]byte("old artifact")), PublishLast: true}, + }, []ProjectionRemoval{{Path: retiredPath, ExpectedSHA256: projectionDigest([]byte("old skill")), AllowMissing: true}}, nil, projectionHooks{afterRemovals: func() { + os.Exit(79) + }}) + os.Exit(78) + } + + repository := resolvedTemporaryRepository(t) + artifactPath := filepath.Join(repository, ".boatstack", "flows", "product-delivery.flow.ir.json") + retiredPath := filepath.Join(repository, ".agents", "skills", "old-run", "SKILL.md") + newSkillPath := filepath.Join(repository, ".agents", "skills", "product-delivery-run", "SKILL.md") + for path, content := range map[string][]byte{artifactPath: []byte("old artifact"), retiredPath: []byte("old skill")} { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + } + command := exec.Command(os.Args[0], "-test.run=^TestFlowProjectionRecoversAfterCrashBeforeArtifactPublication$") + command.Env = append(os.Environ(), "BOATSTACK_FLOW_CRASH_CHILD=1", "BOATSTACK_FLOW_CRASH_REPOSITORY="+repository) + err := command.Run() + exitErr, exited := err.(*exec.ExitError) + if !exited || exitErr.ExitCode() != 79 { + t.Fatalf("crash fixture result = %v", err) + } + if actual, err := os.ReadFile(artifactPath); err != nil || string(actual) != "old artifact" { + t.Fatalf("artifact published before retirement boundary: %q, %v", actual, err) + } + if _, err := os.Stat(retiredPath); !os.IsNotExist(err) { + t.Fatalf("retired skill survived crash boundary: %v", err) + } + + writes := []ProjectionWrite{ + {Path: newSkillPath, Content: []byte("new skill"), Mode: 0o644}, + {Path: artifactPath, Content: []byte("new artifact"), Mode: 0o644, ExpectedPreviousSHA256: projectionDigest([]byte("old artifact")), PublishLast: true}, + } + removals := []ProjectionRemoval{{Path: retiredPath, ExpectedSHA256: projectionDigest([]byte("old skill")), AllowMissing: true}} + if err := ApplyFlowProjection(repository, writes, removals, nil); err != nil { + t.Fatal(err) + } + if actual, err := os.ReadFile(artifactPath); err != nil || string(actual) != "new artifact" { + t.Fatalf("retry artifact = %q, %v", actual, err) + } + if actual, err := os.ReadFile(newSkillPath); err != nil || string(actual) != "new skill" { + t.Fatalf("retry skill = %q, %v", actual, err) + } +} diff --git a/boatstack/internal/runtime/flow_ownership.go b/boatstack/internal/runtime/flow_ownership.go new file mode 100644 index 0000000..c95497f --- /dev/null +++ b/boatstack/internal/runtime/flow_ownership.go @@ -0,0 +1,217 @@ +package runtime + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const flowProjectionOwnershipSchema = 1 + +// FlowProjectionOwnership is kernel-owned provenance stored in Git worktree +// metadata. Repository artifacts describe outputs but do not authorize their +// replacement or retirement. +type FlowProjectionOwnership struct { + SchemaVersion int `json:"schema_version"` + SourcePath string `json:"source_path"` + ArtifactPath string `json:"artifact_path"` + ArtifactSHA256 string `json:"artifact_sha256"` + GeneratedSkills map[string]string `json:"generated_skills"` +} + +type FlowProjectionOwnershipSnapshot struct { + Record FlowProjectionOwnership + exists bool + expectedSHA256 string + path string + sourcePath string +} + +type flowProjectionOwnershipChange struct { + prior FlowProjectionOwnershipSnapshot + next FlowProjectionOwnership +} + +func LoadFlowProjectionOwnership(repository, sourcePath string) (FlowProjectionOwnershipSnapshot, error) { + path, err := flowProjectionOwnershipPath(repository, sourcePath) + if err != nil { + return FlowProjectionOwnershipSnapshot{}, err + } + snapshot := FlowProjectionOwnershipSnapshot{path: path, sourcePath: filepath.ToSlash(sourcePath)} + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return snapshot, nil + } + if err != nil { + return FlowProjectionOwnershipSnapshot{}, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return FlowProjectionOwnershipSnapshot{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: provenance is not a regular file") + } + raw, err := os.ReadFile(path) + if err != nil { + return FlowProjectionOwnershipSnapshot{}, err + } + record, err := decodeFlowProjectionOwnership(raw, sourcePath) + if err != nil { + return FlowProjectionOwnershipSnapshot{}, err + } + snapshot.Record, snapshot.exists, snapshot.expectedSHA256 = record, true, projectionDigest(raw) + return snapshot, nil +} + +func NewFlowProjectionOwnership(sourcePath, artifactPath string, artifact []byte, skills map[string][]byte) FlowProjectionOwnership { + generated := make(map[string]string, len(skills)) + for path, content := range skills { + generated[filepath.ToSlash(path)] = projectionDigest(content) + } + return FlowProjectionOwnership{ + SchemaVersion: flowProjectionOwnershipSchema, SourcePath: filepath.ToSlash(sourcePath), + ArtifactPath: filepath.ToSlash(artifactPath), ArtifactSHA256: projectionDigest(artifact), GeneratedSkills: generated, + } +} + +func (s FlowProjectionOwnershipSnapshot) Exists() bool { return s.exists } + +func ApplyOwnedFlowProjection(repository string, writes []ProjectionWrite, removals []ProjectionRemoval, expectations []ProjectionExpectation, prior FlowProjectionOwnershipSnapshot, next FlowProjectionOwnership) error { + raw, err := json.Marshal(next) + if err != nil { + return err + } + if _, err := decodeFlowProjectionOwnership(raw, prior.sourcePath); err != nil { + return err + } + return applyFlowProjectionWithOwnership(repository, writes, removals, expectations, projectionHooks{}, &flowProjectionOwnershipChange{prior: prior, next: next}) +} + +// AcquireFlowProjectionLease serializes Flow effect execution with official +// projection publication for one Git worktree. +func AcquireFlowProjectionLease(repository string) (*FlowProjectionLease, error) { + lock, err := acquireProjectionLock(repository) + if err != nil { + return nil, err + } + return &FlowProjectionLease{lock: lock}, nil +} + +type FlowProjectionLease struct{ lock *projectionLock } + +func (l *FlowProjectionLease) Release() { + if l != nil && l.lock != nil { + l.lock.release() + l.lock = nil + } +} + +func flowProjectionOwnershipPath(repository, sourcePath string) (string, error) { + if !safeProjectionRelative(sourcePath) { + return "", fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: source path is not canonical") + } + gitDirectory, err := projectionGitDirectory(repository) + if err != nil { + return "", err + } + digest := sha256.Sum256([]byte(filepath.ToSlash(sourcePath))) + return filepath.Join(gitDirectory, "boatstack-flow-projections", hex.EncodeToString(digest[:])+".json"), nil +} + +func decodeFlowProjectionOwnership(raw []byte, sourcePath string) (FlowProjectionOwnership, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var record FlowProjectionOwnership + if err := decoder.Decode(&record); err != nil { + return FlowProjectionOwnership{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return FlowProjectionOwnership{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: trailing data") + } + if record.SchemaVersion != flowProjectionOwnershipSchema || record.SourcePath != filepath.ToSlash(sourcePath) || !safeProjectionRelative(record.ArtifactPath) || len(record.ArtifactSHA256) != 64 || record.GeneratedSkills == nil { + return FlowProjectionOwnership{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: provenance envelope is incomplete") + } + for path, digest := range record.GeneratedSkills { + if !safeProjectionRelative(path) || len(digest) != 64 { + return FlowProjectionOwnership{}, fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: generated output binding is invalid") + } + } + return record, nil +} + +func safeProjectionRelative(value string) bool { + if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\`) { + return false + } + clean := filepath.Clean(filepath.FromSlash(value)) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) && filepath.ToSlash(clean) == value +} + +func validateOwnershipSnapshot(snapshot FlowProjectionOwnershipSnapshot) error { + info, err := os.Lstat(snapshot.path) + if os.IsNotExist(err) { + if snapshot.exists { + return fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_CHANGED: provenance disappeared before commit") + } + return nil + } + if err != nil { + return err + } + if !snapshot.exists || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_CHANGED: provenance changed before commit") + } + raw, err := os.ReadFile(snapshot.path) + if err != nil || projectionDigest(raw) != snapshot.expectedSHA256 { + return fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_CHANGED: provenance changed before commit") + } + return nil +} + +func stageOwnership(snapshot FlowProjectionOwnershipSnapshot, next FlowProjectionOwnership) (string, error) { + if snapshot.path == "" || next.SourcePath == "" { + return "", fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: provenance update is incomplete") + } + raw, err := json.MarshalIndent(next, "", " ") + if err != nil { + return "", err + } + raw = append(raw, '\n') + directory := filepath.Dir(snapshot.path) + if info, statErr := os.Lstat(directory); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) { + return "", fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: provenance directory is unsafe") + } else if statErr != nil && !os.IsNotExist(statErr) { + return "", statErr + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", err + } + for attempt := 0; attempt < 100; attempt++ { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + temporary := filepath.Join(directory, fmt.Sprintf(".boatstack-ownership-%x", nonce)) + file, openErr := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if os.IsExist(openErr) { + continue + } + if openErr != nil { + return "", openErr + } + _, writeErr := file.Write(raw) + if closeErr := file.Close(); writeErr == nil { + writeErr = closeErr + } + if writeErr != nil { + _ = os.Remove(temporary) + return "", writeErr + } + return temporary, nil + } + return "", fmt.Errorf("FLOW_PROJECTION_OWNERSHIP_INVALID: cannot stage provenance") +} diff --git a/boatstack/internal/runtime/projection_lock_unix.go b/boatstack/internal/runtime/projection_lock_unix.go new file mode 100644 index 0000000..824d42e --- /dev/null +++ b/boatstack/internal/runtime/projection_lock_unix.go @@ -0,0 +1,16 @@ +//go:build darwin || linux + +package runtime + +import ( + "os" + "syscall" +) + +func lockProjectionFile(file *os.File) error { + return syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +func unlockProjectionFile(file *os.File) error { + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) +} diff --git a/boatstack/internal/runtime/projection_lock_windows.go b/boatstack/internal/runtime/projection_lock_windows.go new file mode 100644 index 0000000..401aa06 --- /dev/null +++ b/boatstack/internal/runtime/projection_lock_windows.go @@ -0,0 +1,40 @@ +//go:build windows + +package runtime + +import ( + "os" + "syscall" + "unsafe" +) + +const ( + projectionLockFailImmediately = 0x00000001 + projectionLockExclusive = 0x00000002 +) + +var ( + projectionLockFile = syscall.NewLazyDLL("kernel32.dll").NewProc("LockFileEx") + projectionUnlockFile = syscall.NewLazyDLL("kernel32.dll").NewProc("UnlockFileEx") +) + +func lockProjectionFile(file *os.File) error { + var overlapped syscall.Overlapped + result, _, callErr := projectionLockFile.Call( + file.Fd(), uintptr(projectionLockFailImmediately|projectionLockExclusive), 0, + 1, 0, uintptr(unsafe.Pointer(&overlapped)), + ) + if result == 0 { + return callErr + } + return nil +} + +func unlockProjectionFile(file *os.File) error { + var overlapped syscall.Overlapped + result, _, callErr := projectionUnlockFile.Call(file.Fd(), 0, 1, 0, uintptr(unsafe.Pointer(&overlapped))) + if result == 0 { + return callErr + } + return nil +} diff --git a/boatstack/internal/softwaredelivery/effects/artifacts.go b/boatstack/internal/softwaredelivery/effects/artifacts.go index bb8975e..98f2d7e 100644 --- a/boatstack/internal/softwaredelivery/effects/artifacts.go +++ b/boatstack/internal/softwaredelivery/effects/artifacts.go @@ -127,6 +127,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio state.EnabledHosts = append([]string(nil), policy.Hosts...) case "plan.create", "plan.amend": source, _ := admission.Parameters.Get("source_path") + expected, _ := admission.Parameters.Get("source_fingerprint") raw, readErr := os.ReadFile(source) if readErr != nil { return nil, fmt.Errorf("read source plan: %w", readErr) @@ -135,6 +136,9 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, fmt.Errorf("source plan is empty") } fingerprint := sha256Bytes(raw) + if expected != "" && fingerprint != expected { + return nil, fmt.Errorf("source plan fingerprint changed after entry binding") + } path := filepath.Join(artifactRoot, "plans", deliveryID+".source") mutation, mutationErr := mutationFor(path, raw, 0o644, false, false) if mutationErr != nil { diff --git a/boatstack/internal/softwaredelivery/effects/host_skills.go b/boatstack/internal/softwaredelivery/effects/host_skills.go index fb10f34..e396a38 100644 --- a/boatstack/internal/softwaredelivery/effects/host_skills.go +++ b/boatstack/internal/softwaredelivery/effects/host_skills.go @@ -28,20 +28,6 @@ type hostSkillMode struct { } var hostSkillModes = []hostSkillMode{ - { - Slug: "boatstack-autoplan", DisplayName: "Boatstack Autoplan", - Description: "Create and approve a verified delivery plan.", - Target: "the `approved-plan` terminal", - Extra: "This trigger supplies planning intent only. It never approves unseen plan bytes.", - AuthorityContract: deliveryAuthorityContract, - }, - { - Slug: "boatstack-run", DisplayName: "Boatstack Run", - Description: "Drive delivery to an open or updated pull request.", - Target: "the `open-or-updated-pr` terminal", - Extra: "This trigger never grants merge authority. Provider authority remains a separate verified receipt.", - AuthorityContract: deliveryAuthorityContract, - }, { Slug: "boatstack-update", DisplayName: "Boatstack Update", Description: "Apply a checksum-verified Boatstack update.", @@ -51,20 +37,6 @@ var hostSkillModes = []hostSkillMode{ }, } -const deliveryAuthorityContract = `For this operation, request human and repository-policy authority sources. The -repository-policy source remains requested when configuration is -stale, uninitialized, or under recovery; do not pass ` + "`--repository-authority`" + ` -until the current configuration has exact verified fingerprint evidence. - -After a complete ` + "`installation.initialize`" + `, ` + "`configuration.initialize`" + `, or recovery -receipt, re-observe the post-receipt state. If configuration is now verified, -make one bounded attempt for that receipt to materialize the retained source by -adding ` + "`--repository-authority`" + ` to the next resolution. The kernel must derive the -receipt from that exact verified fingerprint. Never derive repository authority -from file presence, authentication, or prior conversation. If the source remains -unverifiable, record it as conclusively rejected and fail closed; do not retry it -again for the same receipt.` - const updateAuthorityContract = `For this operation, request only checksum-verified installation authority. Do not request or materialize repository, provider, publication, product-delivery, or merge authority. Installation receipts cannot be reused to broaden this scope. diff --git a/boatstack/internal/softwaredelivery/effects/host_skills_test.go b/boatstack/internal/softwaredelivery/effects/host_skills_test.go index e845540..e3763c0 100644 --- a/boatstack/internal/softwaredelivery/effects/host_skills_test.go +++ b/boatstack/internal/softwaredelivery/effects/host_skills_test.go @@ -11,8 +11,8 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" ) -func TestHostSkillProjectionExposesExactlyThreeOperationsPerInteractiveHost(t *testing.T) { - // control-law: enabled-hosts-receive-exactly-three-canonical-operation-skills +func TestHostSkillProjectionExposesOnlyKernelMaintenance(t *testing.T) { + // control-law: repository-flow-entries-not-kernel-modes files := desiredHostSkillFiles([]string{"cli", "cursor", "codex", "claude", "gemini", "mcp"}) counts := map[string]int{} for path, raw := range files { @@ -35,8 +35,8 @@ func TestHostSkillProjectionExposesExactlyThreeOperationsPerInteractiveHost(t *t } } for _, host := range []string{"codex", "claude", "gemini", "cursor"} { - if counts[host] != 3 { - t.Fatalf("%s discovered %d operation skills, want exactly 3", host, counts[host]) + if counts[host] != 1 { + t.Fatalf("%s discovered %d kernel operation skills, want exactly 1", host, counts[host]) } } } @@ -63,44 +63,29 @@ func TestHostSkillProjectionPreservesAuthorityBoundaries(t *testing.T) { } } -func TestHostSkillProjectionPreservesDeferredRepositoryAuthority(t *testing.T) { - // control-law: retained-repository-source-rematerializes-once-after-verification +func TestHostSkillProjectionDoesNotClaimDeliveryEntries(t *testing.T) { + // control-law: kernel-skill-projection-cannot-invent-repository-flow-entries files := desiredHostSkillFiles([]string{"cursor", "codex", "claude", "gemini"}) for path, raw := range files { if strings.HasSuffix(path, "openai.yaml") { continue } value := string(raw) - isDelivery := strings.Contains(path, "boatstack-autoplan") || strings.Contains(path, "boatstack-run") + if strings.Contains(value, "boatstack-autoplan") || strings.Contains(value, "boatstack-run") { + t.Fatalf("%s contains a hard-coded delivery entry", path) + } for _, contract := range []string{ - "request human and repository-policy authority sources", - "repository-policy source remains requested", - "do not pass `--repository-authority`\nuntil the current configuration has exact verified fingerprint evidence", - "one bounded attempt for that receipt", - "The kernel must derive the\nreceipt from that exact verified fingerprint", - "do not retry it\nagain for the same receipt", + "request only checksum-verified installation authority", + "Do not\nrequest or materialize repository, provider, publication, product-delivery, or\nmerge authority", + "preserve the healthy admitted\nruntime", + "program-delta fingerprint", + "Do not accept the delta implicitly", + "`--accept-program-change`", + "single atomic\n`installation.reconcile-update` boundary", + "carry the same human authority\nthrough that rollback", } { - if isDelivery && !strings.Contains(value, contract) { - t.Fatalf("%s is missing deferred authority contract %q", path, contract) - } - if !isDelivery && strings.Contains(value, contract) { - t.Fatalf("%s improperly gains delivery authority contract %q", path, contract) - } - } - if !isDelivery { - for _, contract := range []string{ - "request only checksum-verified installation authority", - "Do not\nrequest or materialize repository, provider, publication, product-delivery, or\nmerge authority", - "preserve the healthy admitted\nruntime", - "program-delta fingerprint", - "Do not accept the delta implicitly", - "`--accept-program-change`", - "single atomic\n`installation.reconcile-update` boundary", - "carry the same human authority\nthrough that rollback", - } { - if !strings.Contains(value, contract) { - t.Fatalf("%s is missing update authority boundary %q", path, contract) - } + if !strings.Contains(value, contract) { + t.Fatalf("%s is missing update authority boundary %q", path, contract) } } } @@ -126,7 +111,7 @@ func TestHostSkillProjectionInventoriesEveryDriverPath(t *testing.T) { func TestHostSkillProjectionFailsClosedOnUnmanagedCollision(t *testing.T) { // control-law: unmanaged-host-file-cannot-be-overwritten-by-installation repository := t.TempDir() - path := filepath.Join(repository, ".agents", "skills", "boatstack-run", "SKILL.md") + path := filepath.Join(repository, ".agents", "skills", "boatstack-update", "SKILL.md") if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } @@ -149,7 +134,7 @@ func TestHostSkillProjectionRejectsManagedDrift(t *testing.T) { t.Fatal(err) } applyMutationsForTest(t, mutations) - gemini := filepath.Join(repository, ".gemini", "skills", "boatstack-run", "SKILL.md") + gemini := filepath.Join(repository, ".gemini", "skills", "boatstack-update", "SKILL.md") if err := os.WriteFile(gemini, []byte("drift\n"), 0o644); err != nil { t.Fatal(err) } @@ -178,10 +163,10 @@ func TestHostSkillProjectionRemovesOnlyManagedDisabledHosts(t *testing.T) { t.Fatal(err) } applyMutationsForTest(t, mutations) - if _, err := os.Stat(filepath.Join(repository, ".gemini", "skills", "boatstack-run", "SKILL.md")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(repository, ".gemini", "skills", "boatstack-update", "SKILL.md")); !os.IsNotExist(err) { t.Fatalf("disabled managed Gemini skill still exists: %v", err) } - if _, err := os.Stat(filepath.Join(repository, ".agents", "skills", "boatstack-run", "SKILL.md")); err != nil { + if _, err := os.Stat(filepath.Join(repository, ".agents", "skills", "boatstack-update", "SKILL.md")); err != nil { t.Fatalf("enabled Codex skill was removed: %v", err) } if raw, err := os.ReadFile(unmanaged); err != nil || string(raw) != "unrelated\n" { diff --git a/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go new file mode 100644 index 0000000..b5207b6 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/plan_fingerprint_test.go @@ -0,0 +1,45 @@ +package effects + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/ports" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestPlanEffectRejectsSourceChangedAfterEntryBinding(t *testing.T) { + // control-law: plan bytes bound to a Flow run cannot change before effect preparation + repository := t.TempDir() + source := filepath.Join(t.TempDir(), "plan.md") + bound := []byte("# Plan A\n") + if err := os.WriteFile(source, bound, 0o600); err != nil { + t.Fatal(err) + } + expected := sha256Bytes(bound) + if err := os.WriteFile(source, []byte("# Plan B\n"), 0o600); err != nil { + t.Fatal(err) + } + state := durable.State{} + mutations, err := prepareArtifacts(ports.ControllerLayout{RepositoryRoot: repository}, protocol.Admission{ + Objective: model.Objective{DeliveryID: "delivery-one"}, + Parameters: protocol.Parameters{ + {Name: "source_path", Value: source}, + {Name: "source_fingerprint", Value: expected}, + }, + }, catalog.Transition{ID: "plan.create"}, &state) + if err == nil || !strings.Contains(err.Error(), "changed after entry binding") { + t.Fatalf("replaced plan result = %v", err) + } + if len(mutations) != 0 || state.PlanFingerprint != "" { + t.Fatalf("replaced plan prepared effects: mutations=%#v state=%#v", mutations, state) + } + if _, statErr := os.Stat(filepath.Join(repository, ".boatstack", "plans", "delivery-one.source")); !os.IsNotExist(statErr) { + t.Fatalf("replaced plan created a managed effect: %v", statErr) + } +} diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol.go b/boatstack/internal/softwaredelivery/surfaces/protocol.go index c95994e..e10067f 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol.go @@ -1,7 +1,10 @@ package surfaces import ( + "crypto/sha256" + "encoding/hex" "fmt" + "regexp" "strings" "time" @@ -11,7 +14,9 @@ import ( "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" ) -const SchemaVersion = 5 +const SchemaVersion = 6 + +var flowContextIdentity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) type Operation string @@ -40,6 +45,9 @@ type Request struct { Repository string `json:"repository"` Host string `json:"host"` CorrelationID string `json:"correlation_id"` + ProgramID string `json:"program_id,omitempty"` + ProgramFingerprint string `json:"program_fingerprint,omitempty"` + EntryID string `json:"entry_id,omitempty"` FlowID string `json:"flow_id,omitempty"` Objective model.Objective `json:"objective,omitempty"` TransitionID catalog.TransitionID `json:"transition_id,omitempty"` @@ -58,6 +66,15 @@ func (r Request) Validate(now time.Time) error { if r.Operation != OperationCatalog && (r.Repository == "" || r.Host == "" || r.CorrelationID == "") { return fmt.Errorf("surface request requires repository, host, and correlation identity") } + if (r.ProgramID == "") != (r.EntryID == "") { + return fmt.Errorf("surface request requires both program and entry identity") + } + if r.ProgramID != "" && (!flowContextIdentity.MatchString(r.ProgramID) || len(r.ProgramFingerprint) != 64 || !flowContextIdentity.MatchString(r.EntryID) || r.FlowID == "") { + return fmt.Errorf("surface Flow entry requires semantic program, entry, and run identity") + } + if r.ProgramID == "" && r.ProgramFingerprint != "" { + return fmt.Errorf("surface request cannot carry a program fingerprint without a program") + } if r.Operation != OperationCatalog { knownHost := false for _, host := range CanonicalHostNames() { @@ -125,9 +142,13 @@ type ProgramChange struct { type Response struct { SchemaVersion int `json:"schema_version"` Operation Operation `json:"operation"` + ProgramID string `json:"program_id,omitempty"` + EntryID string `json:"entry_id,omitempty"` + RunID string `json:"run_id,omitempty"` Objective model.Objective `json:"objective,omitempty"` Snapshot *model.Snapshot `json:"snapshot,omitempty"` Decision *supervisor.Decision `json:"decision,omitempty"` + Question *Question `json:"question,omitempty"` Prescription *protocol.Prescription `json:"prescription,omitempty"` Admission *protocol.Admission `json:"admission,omitempty"` Receipt *protocol.TransitionReceipt `json:"receipt,omitempty"` @@ -139,3 +160,32 @@ type Response struct { Guard *supervisor.GuardDecision `json:"guard,omitempty"` Error string `json:"error,omitempty"` } + +// Question is a typed suspension, not a background task. Supplying its +// required evidence and resolving again with the same run identity resumes the +// existing command context. +type Question struct { + ID string `json:"id"` + RunID string `json:"run_id"` + TransitionID catalog.TransitionID `json:"transition_id"` + Prompt string `json:"prompt,omitempty"` + Parameters []catalog.ParameterSpec `json:"parameters,omitempty"` + Authority []catalog.AuthorityClass `json:"authority,omitempty"` + AuthorityAll []catalog.AuthorityClass `json:"authority_all,omitempty"` +} + +func QuestionFor(runID, snapshotFingerprint string, decision supervisor.Decision) *Question { + if runID == "" || decision.Kind != supervisor.DecisionCandidate || decision.Transition == nil { + return nil + } + transition := decision.Transition + if transition.Prescription.AuthorityPrompt == "" && len(transition.Parameters) == 0 && len(transition.Authority) == 0 && len(transition.AuthorityAll) == 0 { + return nil + } + digest := sha256.Sum256([]byte(strings.Join([]string{runID, snapshotFingerprint, string(transition.ID)}, "\x00"))) + return &Question{ + ID: "question-" + hex.EncodeToString(digest[:12]), RunID: runID, TransitionID: transition.ID, + Prompt: transition.Prescription.AuthorityPrompt, Parameters: append([]catalog.ParameterSpec(nil), transition.Parameters...), + Authority: append([]catalog.AuthorityClass(nil), transition.Authority...), AuthorityAll: append([]catalog.AuthorityClass(nil), transition.AuthorityAll...), + } +} diff --git a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go index 6c8dc56..1e4da28 100644 --- a/boatstack/internal/softwaredelivery/surfaces/protocol_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/protocol_test.go @@ -3,6 +3,9 @@ package surfaces import ( "testing" "time" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/catalog" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/supervisor" ) func TestSurfaceSchemaIsFlagDayAndApplyRequiresPrescription(t *testing.T) { @@ -27,3 +30,18 @@ func TestSurfaceSchemaIsFlagDayAndApplyRequiresPrescription(t *testing.T) { t.Fatal("apply without an exact prescription was accepted") } } + +func TestQuestionSuspendsAndBindsOneRunSnapshot(t *testing.T) { + // control-law: human-input-suspension-cannot-cross-run-or-snapshot + transition := catalog.Transition{ + ID: "plan.approve", Parameters: []catalog.ParameterSpec{{Name: "plan_fingerprint", Required: true}}, + Authority: []catalog.AuthorityClass{catalog.AuthorityHuman}, Prescription: catalog.Prescription{AuthorityPrompt: "Approve exact plan bytes"}, + } + decision := supervisor.Decision{Kind: supervisor.DecisionCandidate, Transition: &transition} + one := QuestionFor("run-one", "snapshot-one", decision) + two := QuestionFor("run-one", "snapshot-one", decision) + changed := QuestionFor("run-one", "snapshot-two", decision) + if one == nil || two == nil || changed == nil || one.ID != two.ID || one.ID == changed.ID || one.RunID != "run-one" { + t.Fatalf("question bindings = %#v %#v %#v", one, two, changed) + } +} diff --git a/boatstack/internal/softwaredelivery/surfaces/render.go b/boatstack/internal/softwaredelivery/surfaces/render.go index 1843953..eb28259 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render.go +++ b/boatstack/internal/softwaredelivery/surfaces/render.go @@ -25,8 +25,8 @@ type CommandAST struct { Arguments []string } -func PrescriptionCommand(transition catalog.Transition, prescription protocol.Prescription, correlation, repository string, objective model.Objective, flowID string, parameters protocol.Parameters) CommandAST { - arguments := []string{"apply", "--repo", repository, "--transition", string(transition.ID), "--flow", flowID, +func PrescriptionCommand(transition catalog.Transition, prescription protocol.Prescription, correlation, repository string, objective model.Objective, runID, programID, entryID string, parameters protocol.Parameters) CommandAST { + arguments := []string{"apply", "--repo", repository, "--transition", string(transition.ID), "--run-id", runID, "--correlation", correlation, "--prescription-id", prescription.ID, "--expected-instance-id", prescription.ExpectedInstanceID, "--expected-state-revision", strconv.FormatUint(prescription.ExpectedStateRevision, 10), @@ -34,6 +34,9 @@ func PrescriptionCommand(transition catalog.Transition, prescription protocol.Pr "--expected-snapshot-fingerprint", prescription.ExpectedSnapshotFingerprint, "--expected-objective-binding-fingerprint", prescription.ExpectedObjectiveBindingFingerprint, "--authority-fingerprint", prescription.AuthorityFingerprint} + if programID != "" && entryID != "" { + arguments = append(arguments, "--flow", programID, "--entry", entryID) + } for _, capability := range prescription.RequiredCapabilities { arguments = append(arguments, "--required-capability", string(capability)) } @@ -77,7 +80,7 @@ type HostPrescription struct { // ProjectHostPrescription changes host capability metadata only. Every host // consumes the same semantic command, authority prompt, and postcondition. -func ProjectHostPrescription(host string, transition catalog.Transition, prescription protocol.Prescription, correlation, repository string, objective model.Objective, flowID string, parameters protocol.Parameters) (HostPrescription, error) { +func ProjectHostPrescription(host string, transition catalog.Transition, prescription protocol.Prescription, correlation, repository string, objective model.Objective, runID, programID, entryID string, parameters protocol.Parameters) (HostPrescription, error) { known := false for _, candidate := range CanonicalHostNames() { if host == candidate { @@ -89,7 +92,7 @@ func ProjectHostPrescription(host string, transition catalog.Transition, prescri return HostPrescription{}, fmt.Errorf("unsupported host %q", host) } return HostPrescription{ - Host: host, TransitionID: string(transition.ID), Command: PrescriptionCommand(transition, prescription, correlation, repository, objective, flowID, parameters), + Host: host, TransitionID: string(transition.ID), Command: PrescriptionCommand(transition, prescription, correlation, repository, objective, runID, programID, entryID, parameters), AuthorityPrompt: transition.Prescription.AuthorityPrompt, ExpectedPostcondition: transition.Prescription.ExpectedPostcondition, }, nil } diff --git a/boatstack/internal/softwaredelivery/surfaces/render_test.go b/boatstack/internal/softwaredelivery/surfaces/render_test.go index faaf493..07a9831 100644 --- a/boatstack/internal/softwaredelivery/surfaces/render_test.go +++ b/boatstack/internal/softwaredelivery/surfaces/render_test.go @@ -27,7 +27,7 @@ func TestShellRenderersConsumeOneCommandAST(t *testing.T) { RequiredCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, EffectiveCapabilities: []catalog.Capability{catalog.CapabilityRepositoryWrite, catalog.CapabilityCommandExecute}, } - command := PrescriptionCommand(transition, prescription, "corr-1", "/repo with space", objective, "flow", parameters) + command := PrescriptionCommand(transition, prescription, "corr-1", "/repo with space", objective, "run-1", "product-delivery", "run", parameters) joined := strings.Join(command.Arguments, " ") for _, binding := range []string{"--correlation corr-1", "--prescription-id prx-fixture", "--expected-instance-id repo-fixture", "--expected-state-revision 41", "--expected-program-fingerprint", "--expected-snapshot-fingerprint", "--expected-objective-binding-fingerprint", "--authority-fingerprint auth-fixture", "--required-capability repository.write", "--required-capability command.execute", "--effective-capability repository.write", "--effective-capability command.execute"} { if !strings.Contains(joined, binding) { @@ -130,7 +130,7 @@ func TestEveryHostConsumesOneSemanticPrescription(t *testing.T) { } var canonical HostPrescription for index, host := range CanonicalHostNames() { - projection, err := ProjectHostPrescription(host, transition, prescription, "corr-1", "/repo", objective, "flow", parameters) + projection, err := ProjectHostPrescription(host, transition, prescription, "corr-1", "/repo", objective, "run-1", "product-delivery", "run", parameters) if err != nil { t.Fatal(err) } diff --git a/boatstack/testdata/control-programs/incident-response.flow.ts b/boatstack/testdata/control-programs/incident-response.flow.ts new file mode 100644 index 0000000..50594a2 --- /dev/null +++ b/boatstack/testdata/control-programs/incident-response.flow.ts @@ -0,0 +1,48 @@ +import { + defineFlow, + entry, + evidence, + fact, + facet, + marked, + operator, + transition, +} from "@operatorstack/boatstack"; + +export default defineFlow({ + id: "incident-response", + version: "1", + declarations: { + capabilities: ["service.restart"], + authorities: ["incident-commander"], + effects: ["service.restart"], + verifiers: ["healthcheck"], + }, + facets: [ + facet("incident", "enum", ["open", "mitigated"]), + facet("service", "enum", ["degraded", "healthy"]), + ], + evidence: [evidence("healthcheck", "service", "observation")], + operators: [ + operator("restart", { + capabilities: ["service.restart"], + authority: ["incident-commander"], + effects: ["service.restart"], + verifier: "healthcheck", + recovery: "restart", + state_effect: { + kind: "assignments", + assignments: [{ facet: "incident", value: "mitigated" }], + }, + }), + ], + transitions: [ + transition("restart", "restart", { + guard: fact("incident", ["open"]), + target: fact("incident", ["mitigated"]), + priority: 10, + }), + ], + targets: [marked("mitigated", fact("incident", ["mitigated"]))], + entries: [entry("respond", "mitigated")], +}); diff --git a/boatstack/testdata/control-programs/incident-response.raw.json b/boatstack/testdata/control-programs/incident-response.raw.json new file mode 100644 index 0000000..f19ef0a --- /dev/null +++ b/boatstack/testdata/control-programs/incident-response.raw.json @@ -0,0 +1,47 @@ +{ + "schema_version": "control-program/v1", + "program": { + "id": "incident-response", + "version": "1" + }, + "declarations": { + "capabilities": ["service.restart"], + "authorities": ["incident-commander"], + "effects": ["service.restart"], + "verifiers": ["healthcheck"] + }, + "facets": [ + { "id": "incident", "kind": "enum", "values": ["open", "mitigated"] }, + { "id": "service", "kind": "enum", "values": ["degraded", "healthy"] } + ], + "evidence": [ + { "id": "healthcheck", "subject": "service", "kind": "observation" } + ], + "operators": [ + { + "id": "restart", + "capabilities": ["service.restart"], + "authority": ["incident-commander"], + "effects": ["service.restart"], + "verifier": "healthcheck", + "recovery": "restart", + "state_effect": { + "kind": "assignments", + "assignments": [{ "facet": "incident", "value": "mitigated" }] + } + } + ], + "transitions": [ + { + "id": "restart", + "operator": "restart", + "guard": { "fact": { "facet": "incident", "statuses": ["known"], "values": ["open"] } }, + "target": { "fact": { "facet": "incident", "statuses": ["known"], "values": ["mitigated"] } }, + "priority": 10 + } + ], + "targets": [ + { "id": "mitigated", "predicate": { "fact": { "facet": "incident", "statuses": ["known"], "values": ["mitigated"] } } } + ], + "entries": [{ "id": "respond", "target": "mitigated", "inputs": [] }] +} diff --git a/docs/architecture/boatstack-v2-transition-catalog.md b/docs/architecture/boatstack-v2-transition-catalog.md index c29513f..33c66ed 100644 --- a/docs/architecture/boatstack-v2-transition-catalog.md +++ b/docs/architecture/boatstack-v2-transition-catalog.md @@ -11,12 +11,12 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `configuration.initialize` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.initialize` | `configuration.reconcile` | `declared-neutral` | | `configuration.mutate` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | human/autonomy | `repository.write` | `config_path*`, `config_sha256*` | `configuration` | `verifier:fresh-observation:configuration.mutate` | `configuration.reconcile` | `declared-neutral` | | `configuration.reconcile` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | human/repository-policy | `repository.write` | `transaction_id*` | `configuration` | `verifier:fresh-observation:configuration.reconcile` | `recovery.escalate` | `declared-neutral` | -| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | +| `delivery.slice.advance` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `slice_id*`, `source_revision*` | `delivery-state` | `verifier:fresh-observation:delivery.slice.advance` | `recovery.resume` | `declared-neutral` | | `engagement.begin` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | DORMANT / OBSERVED | OBSERVED / ACTIVE | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.begin` | `recovery.resume` | `declared-neutral` | | `engagement.release` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | DORMANT | repository-policy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.release` | `recovery.resume` | `declared-neutral` | | `engagement.renew` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | authority | ACTIVE | ACTIVE | repository-policy/autonomy | `product.mutate`, `repository.write` | - | `engagement` | `verifier:fresh-observation:engagement.renew` | `recovery.resume` | `declared-neutral` | -| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | -| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | +| `evidence.approval.revoke` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | FRONTIER | human | `product.mutate`, `repository.write` | - | `approval` | `verifier:fresh-observation:evidence.approval.revoke` | `recovery.resume` | `declared-neutral` | +| `evidence.visual.attach` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `manifest_path*`, `privacy_receipt*`, `source_revision*` | `evidence` | `verifier:fresh-observation:evidence.visual.attach` | `recovery.resume` | `declared-neutral` | | `external.branch-changed` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED | none | - | - | - | `verifier:fresh-observation:external.branch-changed` | `-` | `declared-neutral` | | `external.ci-completed` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.ci-completed` | `-` | `declared-neutral` | | `external.configuration-drifted` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / UNRESOLVED | none | - | - | - | `verifier:fresh-observation:external.configuration-drifted` | `-` | `declared-neutral` | @@ -30,30 +30,30 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `external.pr-updated` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | none | - | - | - | `verifier:fresh-observation:external.pr-updated` | `-` | `declared-neutral` | | `external.provider-unavailable` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | UNRESOLVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.provider-unavailable` | `-` | `declared-neutral` | | `external.runtime-disappeared` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBSERVED_EXTERNAL | observed-external | DORMANT / OBSERVED / ACTIVE / RECOVERY / FRONTIER / UNRESOLVED | OBSERVED / RECOVERY | none | - | - | - | `verifier:fresh-observation:external.runtime-disappeared` | `-` | `declared-neutral` | -| `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | -| `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | -| `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | -| `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | -| `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | +| `gate.build.record` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.build.record` | `recovery.resume` | `declared-neutral` | +| `gate.change.record` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.change.record` | `recovery.resume` | `declared-neutral` | +| `gate.journey.record` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.journey.record` | `recovery.resume` | `declared-neutral` | +| `gate.review.record` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | human/repository-policy | `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.review.record` | `recovery.resume` | `declared-neutral` | +| `gate.test.record` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE / TERMINAL | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `source_revision*`, `evidence_path*`, `evidence_fingerprint*` | `gate-evidence` | `verifier:fresh-observation:gate.test.record` | `recovery.resume` | `declared-neutral` | | `installation.initialize` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | DORMANT / OBSERVED | OBSERVED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `config_path*`, `config_sha256*` | `installation` | `verifier:fresh-observation:installation.initialize` | `runtime.reconcile` | `declared-neutral` | | `installation.reconcile-update` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `accept_obligation_change*` | `installation` | `verifier:fresh-observation:installation.reconcile-update` | `recovery.rollback` | `declared-neutral` | | `installation.update` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | owned-local | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `installation` | `verifier:fresh-observation:installation.update` | `runtime.reconcile` | `declared-neutral` | | `invocation.rebind` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / UNRESOLVED | OBSERVED | repository-policy | `repository.write` | - | `identity-binding` | `verifier:fresh-observation:invocation.rebind` | `recovery.resume` | `declared-neutral` | | `objective.bind` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_REQUIRED | authority | OBSERVED / DORMANT / ACTIVE / FRONTIER / TERMINAL / ABANDONED | OBSERVED / ACTIVE / FRONTIER | human/autonomy | `product.mutate`, `repository.write` | `objective_kind*`, `delivery_id*` | `objective` | `verifier:fresh-observation:objective.bind` | `recovery.resume` | `declared-neutral` | -| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | -| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | -| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | -| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | -| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | -| `plan.create` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | -| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | -| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | -| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | -| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | -| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | -| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | -| `publication.preview` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | -| `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | +| `plan.abandon` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | authority | OBSERVED / ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `plan` | `verifier:fresh-observation:plan.abandon` | `recovery.resume` | `declared-neutral` | +| `plan.activate` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | - | `delivery-state` | `verifier:fresh-observation:plan.activate` | `recovery.resume` | `declared-neutral` | +| `plan.amend` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.amend` | `recovery.resume` | `declared-neutral` | +| `plan.approve` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE / TERMINAL | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve` | `recovery.resume` | `declared-neutral` | +| `plan.approve-amendment` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | authority | ACTIVE / FRONTIER | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `plan_fingerprint*`, `actor*` | `approval` | `verifier:fresh-observation:plan.approve-amendment` | `recovery.resume` | `declared-neutral` | +| `plan.create` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `product.mutate`, `repository.write` | `source_path*`, `delivery_id*`, `source_fingerprint` | `plan` | `verifier:fresh-observation:plan.create` | `recovery.resume` | `declared-neutral` | +| `plan.invalidate` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / OBSERVED | FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.invalidate` | `recovery.resume` | `declared-neutral` | +| `plan.validate` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE / FRONTIER | repository-policy | `product.mutate`, `repository.write` | - | `plan-evidence` | `verifier:fresh-observation:plan.validate` | `recovery.resume` | `declared-neutral` | +| `publication.abandon` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | authority | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | - | `publication` | `verifier:fresh-observation:publication.abandon` | `recovery.resume` | `declared-neutral` | +| `publication.correct` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-external | OBSERVED / ACTIVE / TERMINAL | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `publication_id*`, `body_path*`, `body_sha256*` | `publication` | `verifier:fresh-observation:publication.correct` | `publication.reconcile` | `declared-neutral` | +| `publication.execute` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-external | ACTIVE | ACTIVE / RECOVERY | human/autonomy AND external-provider | `command.execute`, `product.mutate`, `publication.publish`, `repository.write` | `preview_fingerprint*` | `publication` | `verifier:fresh-observation:publication.execute` | `publication.reconcile` | `declared-neutral` | +| `publication.observe` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE / RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | repository-policy | `command.execute`, `product.mutate`, `repository.write` | `publication_id*` | `publication-evidence` | `verifier:fresh-observation:publication.observe` | `recovery.resume` | `declared-neutral` | +| `publication.preview` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `publication.prepare`, `repository.write` | `base_ref*`, `head_ref*`, `body_path*` | `publication-preview` | `verifier:fresh-observation:publication.preview` | `recovery.resume` | `declared-neutral` | +| `publication.reconcile` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | ACTIVE / TERMINAL / FRONTIER / UNRESOLVED | human/external-provider | `command.execute`, `product.mutate`, `repository.write` | `publication_id*`, `transaction_id*` | `publication` | `verifier:fresh-observation:publication.reconcile` | `recovery.escalate` | `declared-neutral` | | `recovery.escalate` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | FRONTIER | repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.escalate` | `recovery.escalate` | `declared-neutral` | | `recovery.resume` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/autonomy/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.resume` | `recovery.escalate` | `declared-neutral` | | `recovery.rollback` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `repository.write` | `transaction_id*` | `recovery-journal` | `verifier:fresh-observation:recovery.rollback` | `recovery.escalate` | `declared-neutral` | @@ -62,13 +62,13 @@ Controlling facets: `phase`, `program`, `topology`, `engagement`, `delivery`, `w | `runtime.hydrate` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | OBJECTIVE_REQUIRED | owned-local | OBSERVED / RECOVERY / UNRESOLVED | OBSERVED / ACTIVE / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.hydrate` | `runtime.reconcile` | `declared-neutral` | | `runtime.reconcile` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | SYSTEM_RECOVERY | recovery | RECOVERY / UNRESOLVED | OBSERVED / FRONTIER / TERMINAL | repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*`, `transaction_id*` | `runtime` | `verifier:fresh-observation:runtime.reconcile` | `recovery.escalate` | `declared-neutral` | | `runtime.replace` | core-system:`boatstack.core@1.0.0`
`888b71d9c3d401472edfb234dc78f48eeca0308d915e7bf7e2a52b418164b463` | `boatstack.core` | EXPLICIT_ONLY | owned-local | OBSERVED / RECOVERY | OBSERVED / TERMINAL | human/repository-policy | `repository.write` | `source_revision*`, `runtime_version*`, `runtime_sha256*` | `runtime` | `verifier:fresh-observation:runtime.replace` | `runtime.reconcile` | `declared-neutral` | -| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | -| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | -| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | -| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | -| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | -| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | -| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | -| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`43b24be10c7c809c0198207dc0fecb6551669b0a15568e0362a074ee118f83f7` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | +| `workspace.abandon` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE / FRONTIER | ABANDONED | human | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.abandon` | `recovery.resume` | `declared-neutral` | +| `workspace.activate` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.activate` | `recovery.resume` | `declared-neutral` | +| `workspace.cleanup` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / ACTIVE / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.cleanup` | `recovery.escalate` | `declared-neutral` | +| `workspace.cut` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_PROGRESS | owned-local | OBSERVED / ACTIVE | ACTIVE | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*`, `base_ref*`, `destination*` | `workspace` | `verifier:fresh-observation:workspace.cut` | `workspace.reconcile` | `declared-neutral` | +| `workspace.publish` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE | repository-policy | `product.mutate`, `repository.write` | `branch*` | `workspace-state` | `verifier:fresh-observation:workspace.publish` | `recovery.resume` | `declared-neutral` | +| `workspace.reap` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | OBSERVED / TERMINAL / ABANDONED | OBSERVED / TERMINAL / ABANDONED | human | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.reap` | `recovery.escalate` | `declared-neutral` | +| `workspace.reconcile` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | PROGRAM_RECOVERY | recovery | RECOVERY / UNRESOLVED | DORMANT / OBSERVED / ACTIVE / FRONTIER / TERMINAL / ABANDONED | human/repository-policy | `product.mutate`, `repository.write` | `transaction_id*` | `workspace` | `verifier:fresh-observation:workspace.reconcile` | `recovery.escalate` | `declared-neutral` | +| `workspace.sync` | control-program:`boatstack.standard@1.0.0`
`94432db5a3760b8b884a55f5d1fff6f63b1a492e4c80651d8ef9fe0dd336370c` | `boatstack.standard` | EXPLICIT_ONLY | owned-local | ACTIVE | ACTIVE / FRONTIER | human/autonomy | `command.execute`, `product.mutate`, `repository.write` | `branch*` | `workspace` | `verifier:fresh-observation:workspace.sync` | `recovery.resume` | `declared-neutral` | `*` marks a required parameter. OR authority is shown with `/`; mandatory authority clauses are shown with `AND`. Source and target facet predicates remain in the canonical JSON returned by `boatstack catalog --format json`. diff --git a/docs/control-program-ir.md b/docs/control-program-ir.md new file mode 100644 index 0000000..b7ad0a4 --- /dev/null +++ b/docs/control-program-ir.md @@ -0,0 +1,49 @@ +# Repository Control Program IR + +Boatstack separates authoring languages from executable semantics: + +```text +TypeScript Flow -> raw Control Program IR -> Go canonicalizer -> committed artifact -> kernel +``` + +The `control-program/v1` IR is domain-neutral. It declares typed facets, +evidence relations, predicate ASTs, operators, capabilities, authority, +effects, verification, recovery, transitions, marked targets, and entries. +Software terms such as plans, tests, Git, and pull requests belong to +`@operatorstack/boatstack-software-delivery`, not the base SDK. + +## Compile and check + +Install the TypeScript frontend in the repository, resolve its absolute path, +then run: + +```sh +boatstack flow compile --repo . \ + --frontend "$(pwd)/node_modules/.bin/boatstack-flow-frontend" +boatstack flow check --repo . +boatstack next --repo . --flow product-delivery --entry run +``` + +Compilation sends the exact source bytes to a restricted TypeScript frontend. +The frontend path is explicit authority: Boatstack never selects or executes a +repository `node_modules/.bin` program automatically. +The frontend accepts only literal data and calls to named exports from trusted +Boatstack SDKs. It rejects local imports and other repository code without +executing them. Boatstack then validates, canonicalizes, fingerprints, and +projects generated skills, retires obsolete skills, and publishes the committed +`.flow.ir.json` artifact last as one serialized update. Runtime commands never +execute `flow.ts`. The artifact filename comes from the declared program ID, +not the source filename. + +The artifact binds the source hash, compiler version, dependency-lock hash, +trusted operator fingerprints, canonical program fingerprint, and generated +skill hashes. Unknown fields, duplicate declarations, invalid references, +undeclared inline effects, missing recovery, binding drift, and generated-file +drift fail closed. A source or lock change during compilation also fails closed. + +Trusted software-delivery bindings fix capabilities, authority, effects, +verifiers, recovery, and state effects. A repository may select and order those +operators and add conjunctive guards. It cannot weaken or replace the trusted +contract, alias a trusted transition identity, or leave required entry inputs +unresolved. Durable software state continues to pass through the existing +schema-v4 declared-effect reducer and native-handler boundary. diff --git a/docs/generated-files.md b/docs/generated-files.md index 781fb7d..1491822 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -15,6 +15,32 @@ Boatstack may create these reviewable paths through registered effects: These files are evidence inputs. Their presence alone never establishes engagement, authority, completion, or publication. +## Repository Flow artifacts + +`boatstack flow compile` projects a repository-owned `.flow.ts` source into +committed runtime inputs: + +| Path | Owner | Meaning | +|---|---|---| +| `.boatstack/flows/.flow.ir.json` | Flow compiler | canonical IR plus source, lock, binding, and generated-file hashes | +| `.agents/skills/-/SKILL.md` | Flow compiler | Codex entry projection | +| `.agents/skills/-/agents/openai.yaml` | Flow compiler | Codex skill metadata | +| `.claude/skills/-/SKILL.md` | Flow compiler | Claude entry projection | + +Skill identities are injective across program and entry pairs: hyphens in the +entry component are doubled. The `boatstack-update` identity is reserved for +kernel maintenance and cannot be generated by a repository Flow. + +`boatstack flow check` rejects stale sources, dependency locks, trusted +bindings, program fingerprints, or skills. Runtime commands load only the +checked IR artifact; they do not execute the TypeScript source. +Compilation also rejects repository module imports, concurrent projection, and +source or lock changes observed through projection commit. It requires an +explicit absolute frontend path, revalidates retirement authorization under the +projection lock, and publishes the artifact only after obsolete skills are +retired. Projection filesystem mutations use a repository-root capability, so +a parent-directory symlink swap cannot redirect them outside the repository. + ## Machine-local controller state Embedded worktree state is partitioned under the Git common directory. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1246590..4e91368 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,6 +14,19 @@ Windows users run `install.ps1` in PowerShell. The kernel creates ## Configure one exact objective +Repositories with a compiled Flow normally select a named entry instead: + +```sh +boatstack flow check --repo . +boatstack next --repo . --flow product-delivery --entry run --format json +``` + +The first response returns an opaque run ID. Preserve the program, entry, run +ID, objective, delivery, authority, and prescription through every subsequent +`next`, `apply`, question, and recovery call. + +## Configure one exact objective manually + Every managed delivery has a stable objective ID, delivery ID, and terminal kind. This example targets a verified implementation: @@ -35,7 +48,7 @@ boatstack next --repo . --transition engagement.begin \ --objective-id search-timeout --objective-kind verified-implementation \ --delivery search-timeout --repository-authority --format json -boatstack apply --repo . --transition engagement.begin --flow search-timeout \ +boatstack apply --repo . --transition engagement.begin --run-id search-timeout \ --objective-id search-timeout --objective-kind verified-implementation \ --delivery search-timeout --repository-authority \ --correlation --prescription-id \ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f4ec294 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "boatstack-workspace", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "boatstack-workspace", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "typescript": "5.9.3" + } + }, + "node_modules/@operatorstack/boatstack": { + "resolved": "packages/boatstack", + "link": true + }, + "node_modules/@operatorstack/boatstack-software-delivery": { + "resolved": "packages/boatstack-software-delivery", + "link": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/boatstack": { + "name": "@operatorstack/boatstack", + "version": "0.0.0-development", + "dependencies": { + "typescript": "5.9.3" + }, + "bin": { + "boatstack-flow-frontend": "bin/boatstack-flow-frontend.mjs" + } + }, + "packages/boatstack-software-delivery": { + "name": "@operatorstack/boatstack-software-delivery", + "version": "0.0.0-development", + "dependencies": { + "@operatorstack/boatstack": "0.0.0-development" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cbd5aa4 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "boatstack-workspace", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "build:flow-sdk": "tsc -b packages/boatstack packages/boatstack-software-delivery", + "test:flow-sdk": "npm run build:flow-sdk" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/packages/boatstack-software-delivery/package.json b/packages/boatstack-software-delivery/package.json new file mode 100644 index 0000000..349d9cb --- /dev/null +++ b/packages/boatstack-software-delivery/package.json @@ -0,0 +1,18 @@ +{ + "name": "@operatorstack/boatstack-software-delivery", + "version": "0.0.0-development", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "@operatorstack/boatstack": "0.0.0-development" + } +} diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts new file mode 100644 index 0000000..33e4ae9 --- /dev/null +++ b/packages/boatstack-software-delivery/src/index.ts @@ -0,0 +1,164 @@ +import { + all, + always, + entry, + fact, + facet, + marked, + operator, + transition, + type EntryDefinition, + type EntryInputDefinition, + type FacetDefinition, + type FlowDefinition, + type OperatorDefinition, + type TargetDefinition, + type TransitionDefinition, +} from "@operatorstack/boatstack"; + +const bindingPrefix = "software-delivery/"; +export const planInboxResolver = "software-delivery.plan-inbox"; + +export const softwareDeliveryFacets: FacetDefinition[] = [ + "phase", + "program", + "engagement", + "objective", + "delivery", + "workspace", + "plan", + "configuration", + "configuration-policy", + "runtime", + "publication", + "verification", + "recovery", + "recovery-info", + "transaction", + "terminal", + "recovery_budget", + "recovery_cause", + "recovery_resumption", + "recovery_source_phase", + "source_revision", + "transaction_id", + "transaction_transition", + "workspace_base_ref", + "workspace_branch", + "workspace_path", + "workspace_source_id", + "workspace_source_path", + "workspace_source_ref", + "worktree_fingerprint", +].map((id) => facet(id, "string")); + +export const publishedPR: TargetDefinition = marked( + "published-pr", + all( + fact("verification", ["current"]), + fact("configuration", ["verified"]), + fact("runtime", ["verified"]), + fact("publication", ["open"]), + ), + "A provider-observed open or updated pull request", +); + +export interface TrustedStep { + id: string; + priority: number; +} + +export const runToPublishedPR: TrustedStep[] = [ + { id: "plan.create", priority: 35 }, + { id: "plan.validate", priority: 40 }, + { id: "plan.invalidate", priority: 41 }, + { id: "plan.amend", priority: 42 }, + { id: "plan.approve", priority: 45 }, + { id: "plan.approve-amendment", priority: 46 }, + { id: "plan.activate", priority: 50 }, + { id: "workspace.cut", priority: 52 }, + { id: "workspace.activate", priority: 53 }, + { id: "workspace.sync", priority: 58 }, + { id: "gate.build.record", priority: 61 }, + { id: "gate.test.record", priority: 62 }, + { id: "gate.review.record", priority: 63 }, + { id: "gate.change.record", priority: 64 }, + { id: "gate.journey.record", priority: 64 }, + { id: "evidence.visual.attach", priority: 66 }, + { id: "delivery.slice.advance", priority: 68 }, + { id: "publication.preview", priority: 72 }, + { id: "workspace.publish", priority: 75 }, + { id: "publication.execute", priority: 76 }, + { id: "publication.observe", priority: 77 }, + { id: "publication.correct", priority: 80 }, + { id: "workspace.reconcile", priority: 2 }, + { id: "publication.reconcile", priority: 1 }, +]; + +export function inbox(path: string): EntryInputDefinition { + return { + id: "plan", + type: "markdown-file", + required: true, + resolver: planInboxResolver, + config: { path, cardinality: "exactly-one" }, + }; +} + +export function trustedOperators(steps: TrustedStep[]): OperatorDefinition[] { + return steps.map((step) => + operator(step.id, { + binding: { reference: `${bindingPrefix}${step.id}`, version: "1" }, + }), + ); +} + +export function trustedTransitions( + steps: TrustedStep[], +): TransitionDefinition[] { + return steps.map((step) => + transition(step.id, step.id, { + guard: always, + target: always, + priority: step.priority, + }), + ); +} + +export function productDeliveryFlow(input: { + id: string; + version: string; + steps?: TrustedStep[]; + entries: EntryDefinition[]; + description?: string; +}): FlowDefinition { + const steps = input.steps ?? runToPublishedPR; + return { + id: input.id, + version: input.version, + description: input.description, + declarations: { input_resolvers: [planInboxResolver] }, + facets: softwareDeliveryFacets, + evidence: [ + { id: "plan-evidence", subject: "plan", kind: "artifact" }, + { + id: "publication-evidence", + subject: "publication", + kind: "provider-observation", + }, + ], + operators: trustedOperators(steps), + transitions: trustedTransitions(steps), + targets: [publishedPR], + entries: input.entries, + }; +} + +export function runEntry(path = ".boatstack/plans/inbox"): EntryDefinition { + return entry( + "run", + "published-pr", + [inbox(path)], + "Implement one approved repository plan and publish a pull request", + ); +} diff --git a/packages/boatstack-software-delivery/tsconfig.json b/packages/boatstack-software-delivery/tsconfig.json new file mode 100644 index 0000000..2d496c4 --- /dev/null +++ b/packages/boatstack-software-delivery/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "strict": true, + "skipLibCheck": true + }, + "references": [{ "path": "../boatstack" }], + "include": ["src/**/*.ts"] +} diff --git a/packages/boatstack/bin/boatstack-flow-frontend.mjs b/packages/boatstack/bin/boatstack-flow-frontend.mjs new file mode 100755 index 0000000..22143ef --- /dev/null +++ b/packages/boatstack/bin/boatstack-flow-frontend.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; +import ts from "typescript"; + +const trustedModules = new Set([ + "@operatorstack/boatstack", + "@operatorstack/boatstack-software-delivery", +]); + +async function readInput() { + if (process.argv.length === 3) { + const path = resolve(process.argv[2]); + return { name: path, source: await readFile(path, "utf8") }; + } + if (process.argv.length === 4 && process.argv[2] === "--stdin") { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return { name: resolve(process.argv[3]), source: Buffer.concat(chunks).toString("utf8") }; + } + throw new Error("usage: boatstack-flow-frontend | --stdin "); +} + +function propertyName(node) { + if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) { + return node.text; + } + throw new Error("Flow object keys must be static identifiers or literals"); +} + +async function compile(input) { + const sourceFile = ts.createSourceFile( + input.name, + input.source, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS, + ); + if (sourceFile.parseDiagnostics.length > 0) { + throw new Error("Flow source contains invalid TypeScript syntax"); + } + const imports = new Map(); + let exported; + + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + const moduleName = statement.moduleSpecifier.text; + if (!trustedModules.has(moduleName)) { + throw new Error(`Flow imports may reference only trusted Boatstack SDKs: ${moduleName}`); + } + const clause = statement.importClause; + if (!clause || clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings)) { + throw new Error("Flow imports must use named Boatstack SDK imports"); + } + const loaded = await import(moduleName); + for (const element of clause.namedBindings.elements) { + if (element.isTypeOnly || clause.isTypeOnly) continue; + const importedName = element.propertyName?.text ?? element.name.text; + if (!(importedName in loaded)) { + throw new Error(`Trusted SDK export is unavailable: ${moduleName}.${importedName}`); + } + imports.set(element.name.text, loaded[importedName]); + } + continue; + } + if (ts.isExportAssignment(statement) && !statement.isExportEquals && exported === undefined) { + exported = statement.expression; + continue; + } + if (ts.isEmptyStatement(statement)) continue; + throw new Error("Flow source may contain only trusted imports and one default export"); + } + if (!exported) throw new Error("Flow source must contain one default export"); + + const evaluate = (node) => { + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) { + return evaluate(node.expression); + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if (ts.isIdentifier(node)) { + if (node.text === "undefined") return undefined; + if (!imports.has(node.text)) throw new Error(`Flow identifier is not a trusted SDK import: ${node.text}`); + return structuredClone(imports.get(node.text)); + } + if (ts.isArrayLiteralExpression(node)) return node.elements.map(evaluate); + if (ts.isObjectLiteralExpression(node)) { + const value = {}; + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) { + throw new Error("Flow objects may contain only explicit property assignments"); + } + value[propertyName(property.name)] = evaluate(property.initializer); + } + return value; + } + if (ts.isCallExpression(node)) { + if (!ts.isIdentifier(node.expression) || !imports.has(node.expression.text)) { + throw new Error("Flow calls may invoke only named trusted SDK imports"); + } + const callable = imports.get(node.expression.text); + if (typeof callable !== "function") { + throw new Error(`Trusted SDK import is not callable: ${node.expression.text}`); + } + return callable(...node.arguments.map(evaluate)); + } + throw new Error(`Flow expression is not declarative: ${ts.SyntaxKind[node.kind]}`); + }; + + const value = evaluate(exported); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Flow default export must lower to a Control Program IR object"); + } + return value; +} + +const input = await readInput(); +const output = await compile(input); +process.stdout.write(`${JSON.stringify(output)}\n`); diff --git a/packages/boatstack/package.json b/packages/boatstack/package.json new file mode 100644 index 0000000..5edef89 --- /dev/null +++ b/packages/boatstack/package.json @@ -0,0 +1,22 @@ +{ + "name": "@operatorstack/boatstack", + "version": "0.0.0-development", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "bin": { + "boatstack-flow-frontend": "./bin/boatstack-flow-frontend.mjs" + }, + "files": [ + "bin", + "dist" + ], + "dependencies": { + "typescript": "5.9.3" + } +} diff --git a/packages/boatstack/src/index.ts b/packages/boatstack/src/index.ts new file mode 100644 index 0000000..94796dd --- /dev/null +++ b/packages/boatstack/src/index.ts @@ -0,0 +1,208 @@ +export const CONTROL_PROGRAM_SCHEMA_VERSION = "control-program/v1" as const; + +export type Predicate = + | { true: boolean } + | { fact: { facet: string; statuses?: string[]; values?: string[] } } + | { all: Predicate[] } + | { any: Predicate[] } + | { not: Predicate }; + +export interface FacetDefinition { + id: string; + kind: "enum" | "string" | "boolean"; + values?: string[]; + description?: string; +} + +export interface EvidenceDefinition { + id: string; + subject: string; + kind: string; + description?: string; +} + +export interface StatePrecondition { + facet: string; + values: string[]; +} + +export interface StateValueReference { + parameter?: string; + admission?: string; + invocation?: string; +} + +export interface StateAssignment { + facet: string; + value?: string; + value_from?: StateValueReference; +} + +export type StateEffectDefinition = + | { + kind: "assignments"; + preconditions?: StatePrecondition[]; + assignments: StateAssignment[]; + } + | { + kind: "native"; + preconditions?: StatePrecondition[]; + native_handler: string; + }; + +export interface OperatorDefinition { + id: string; + binding?: { reference: string; version: string }; + capabilities?: string[]; + authority?: string[]; + effects?: string[]; + verifier?: string; + recovery?: string; + state_effect?: StateEffectDefinition; + description?: string; +} + +export interface TransitionDefinition { + id: string; + operator: string; + guard: Predicate; + target: Predicate; + priority: number; + description?: string; +} + +export interface TargetDefinition { + id: string; + predicate: Predicate; + description?: string; +} + +export interface EntryInputDefinition { + id: string; + type: string; + required: boolean; + resolver?: string; + config?: unknown; +} + +export interface EntryDefinition { + id: string; + target: string; + inputs?: EntryInputDefinition[]; + description?: string; +} + +export interface FlowDefinition { + id: string; + version: string; + description?: string; + declarations?: { + capabilities?: string[]; + authorities?: string[]; + effects?: string[]; + verifiers?: string[]; + input_resolvers?: string[]; + }; + facets: FacetDefinition[]; + evidence?: EvidenceDefinition[]; + operators: OperatorDefinition[]; + transitions: TransitionDefinition[]; + targets: TargetDefinition[]; + entries: EntryDefinition[]; +} + +export interface ControlProgramIR { + schema_version: typeof CONTROL_PROGRAM_SCHEMA_VERSION; + program: { id: string; version: string; description?: string }; + declarations: NonNullable; + facets: FacetDefinition[]; + evidence: EvidenceDefinition[]; + operators: OperatorDefinition[]; + transitions: TransitionDefinition[]; + targets: TargetDefinition[]; + entries: EntryDefinition[]; + description?: string; +} + +export function defineFlow(definition: FlowDefinition): ControlProgramIR { + return { + schema_version: CONTROL_PROGRAM_SCHEMA_VERSION, + program: { + id: definition.id, + version: definition.version, + ...(definition.description + ? { description: definition.description } + : {}), + }, + declarations: definition.declarations ?? {}, + facets: definition.facets, + evidence: definition.evidence ?? [], + operators: definition.operators, + transitions: definition.transitions, + targets: definition.targets, + entries: definition.entries, + ...(definition.description ? { description: definition.description } : {}), + }; +} + +export function facet( + id: string, + kind: FacetDefinition["kind"], + values?: string[], +): FacetDefinition { + return { id, kind, ...(values ? { values } : {}) }; +} + +export function evidence( + id: string, + subject: string, + kind: string, +): EvidenceDefinition { + return { id, subject, kind }; +} + +export function operator( + id: string, + definition: Omit, +): OperatorDefinition { + return { id, ...definition }; +} + +export function transition( + id: string, + operatorID: string, + definition: Omit, +): TransitionDefinition { + return { id, operator: operatorID, ...definition }; +} + +export function marked( + id: string, + predicate: Predicate, + description?: string, +): TargetDefinition { + return { id, predicate, ...(description ? { description } : {}) }; +} + +export function entry( + id: string, + target: string, + inputs: EntryInputDefinition[] = [], + description?: string, +): EntryDefinition { + return { id, target, inputs, ...(description ? { description } : {}) }; +} + +export const always: Predicate = { true: true }; + +export function fact( + facetID: string, + values: string[] = [], + statuses: string[] = ["known"], +): Predicate { + return { fact: { facet: facetID, statuses, values } }; +} + +export function all(...predicates: Predicate[]): Predicate { + return { all: predicates }; +} diff --git a/packages/boatstack/tsconfig.json b/packages/boatstack/tsconfig.json new file mode 100644 index 0000000..b90480d --- /dev/null +++ b/packages/boatstack/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/release-notes/2026-08-12-repository-flow-entries.md b/release-notes/2026-08-12-repository-flow-entries.md new file mode 100644 index 0000000..15605d7 --- /dev/null +++ b/release-notes/2026-08-12-repository-flow-entries.md @@ -0,0 +1,3 @@ +### Repository-owned Flow entries + +Repositories can compile typed Control Programs and expose their own named Flow entries to Codex and Claude without adding delivery modes to Boatstack. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..336988b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "packages/boatstack" }, + { "path": "packages/boatstack-software-delivery" } + ] +}